cainy/billbee-php-sdk

A modern PHP SDK for the Billbee API

Maintainers

Package info

github.com/cainydev/billbee-php-sdk

pkg:composer/cainy/billbee-php-sdk

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

v4.0.0 2026-08-20 21:24 UTC

This package is auto-updated.

Last update: 2026-08-20 21:28:40 UTC


README

Packagist License PHP Version

Billbee API SDK for PHP

A modern PHP client for the Billbee API.

  • PSR-18 / PSR-17 / PSR-3: bring your own HTTP client and logger
  • Automatic retries with exponential backoff, full jitter, and Retry-After support
  • Client-side rate limiting so you stay under Billbee's quota
  • Lazy auto-pagination: iterate millions of rows at constant memory
  • Typed exception hierarchy: no more silently-ignored error codes
  • Webhook signature verification: timing-safe HMAC
  • PHPStan level max, no baseline

Upgrading from v3? See UPGRADING.md.

Requirements

  • PHP 8.3+
  • A PSR-18 HTTP client
  • A Billbee API key. Email support@billbee.de with a short note about what you're building
  • The API module enabled in your account (settings)

Install

composer require cainy/billbee-php-sdk

If your project doesn't already ship a PSR-18 client, add one:

composer require guzzlehttp/guzzle
# or
composer require symfony/http-client

Quick start

use BillbeeDe\BillbeeAPI\Billbee;

$billbee = Billbee::make(
    username: 'your-billbee-username',
    apiPassword: 'your-api-password',
    apiKey: 'your-api-key',
);

foreach ($billbee->products() as $product) {
    printf("%s | %s | %.2f\n", $product->id, $product->sku, $product->price);
}

An endpoint is a lazy collection. Building one performs no HTTP request; pages are fetched as you iterate, and stop the moment you break.

$order = $billbee->orders()->find(123);          // Order
$order = $billbee->orders()->findOrNull(123);    // Order|null, no exception
$count = $billbee->orders()->count();            // one request, reads totalRows

$billbee->orders()->setState(123, OrderState::PAID);

Configuration

use BillbeeDe\BillbeeAPI\Billbee;
use BillbeeDe\BillbeeAPI\Config;
use BillbeeDe\BillbeeAPI\Http\RetryPolicy;

$config = new Config(
    username: 'username',
    apiPassword: 'api-password',
    apiKey: 'api-key',
    timeout: 30.0,
    retryPolicy: new RetryPolicy(maxAttempts: 5, baseDelay: 1.0),
    requestsPerMinute: 50,      // null to disable client-side pacing
    logRequests: true,
    logger: $psrLogger,
);

$billbee = new Billbee($config, httpClient: new \GuzzleHttp\Client());

Config is immutable; derive variants with with():

$verbose = $config->with(logRequests: true, logResponseBodies: true);

Credentials are always redacted from log output.

Iterating and paging

foreach over an endpoint walks every page lazily, at constant memory, paced by the rate limiter:

foreach ($billbee->orders() as $order) {
    // fetches page by page as needed
    if ($order->id === $target) {
        break;   // stops here, no further requests
    }
}

When you need the paging metadata, ask for a single page:

$page = $billbee->orders()->page();

$page->paging->page;
$page->paging->totalPages;
$page->paging->totalRows;
$page->paging->hasMorePages();

foreach ($page as $order) {
    // just this page
}

Other collection operations:

$billbee->orders()->count();     // total rows, one request
$billbee->orders()->first();     // first Order or null, one small request
$billbee->orders()->toArray();   // eager: every page into memory

toArray() loads the entire collection, so prefer foreach for large result sets.

Queries

Filters are immutable query objects, applied with where():

use BillbeeDe\BillbeeAPI\Query\OrderQuery;
use BillbeeDe\BillbeeAPI\Type\OrderState;

$query = (new OrderQuery())
    ->minOrderDate(new DateTimeImmutable('-7 days'))
    ->orderStates(OrderState::PAID, OrderState::SHIPPED)
    ->pageSize(250);

foreach ($billbee->orders()->where($query) as $order) {
    // ...
}

$firstPage = $billbee->orders()->where($query)->page();

where() returns a new collection and leaves the original untouched, so a base query can be safely shared and specialised:

$recent = $billbee->orders()->where($query);
$paid   = $recent->where($query->orderStates(OrderState::PAID));

Because queries are plain immutable values, they serialize cleanly into a queued job.

Nested collections

Resources that hang off another resource are collections too:

foreach ($billbee->customers()->addresses($customerId) as $address) {
    // ...
}

foreach ($billbee->customers()->orders($customerId) as $order) {
    // ...
}

Typed values

Wire values that have a fixed set of options are native backed enums, so they are checked at the call site rather than at runtime:

use BillbeeDe\BillbeeAPI\Type\OrderState;
use BillbeeDe\BillbeeAPI\Type\PaymentType;

$billbee->orders()->setState(123, OrderState::PAID);

$order = $billbee->orders()->find(123);
$order->paymentMethod === PaymentType::PAYPAL;

An unknown value from the API raises SerializationException rather than being silently coerced, so a schema change surfaces immediately.

Fields backed by an open, changing vendor list stay strings, because a strict enum there would reject legitimate new values. Those expose a typed accessor that returns null when the value is not recognised:

$order->seller->partner();        // ?Partner
$order->seller->platform;         // the raw string, always present

Error handling

use BillbeeDe\BillbeeAPI\Exception\BillbeeException;
use BillbeeDe\BillbeeAPI\Exception\NotFoundException;
use BillbeeDe\BillbeeAPI\Exception\RateLimitException;

try {
    $order = $billbee->orders()->find(12345);
} catch (NotFoundException) {
    // no such order
} catch (RateLimitException $e) {
    // only reached after retries are exhausted
    sleep($e->retryAfter ?? 60);
} catch (BillbeeException $e) {
    report($e->getMessage(), $e->statusCode, $e->requestId);
}
Exception Raised on
ApiException Billbee envelope reported a non-zero ErrorCode
AuthenticationException 401, 403
NotFoundException 404
RateLimitException 429, carries retryAfter
ValidationException 400, 422, carries errors
ServerException 5xx
TransportException network failure
SerializationException malformed payload

All extend BillbeeException and carry statusCode, requestId, and responseBody.

Webhooks

use BillbeeDe\BillbeeAPI\Exception\WebhookSignatureException;

$verifier = $billbee->webhookVerifier();

try {
    $event = $verifier->parse($request->getContent(), $request->header('X-Billbee-Signature'));
} catch (WebhookSignatureException) {
    abort(401);
}

match ($event->type) {
    'order.created' => handleNewOrder($event->payload),
    default => null,
};

Signatures are compared with hash_equals, so verification is timing-safe.

Verify the scheme before relying on it. Billbee does not publicly document its webhook signature format, so this implements the de-facto standard used by GitHub, Stripe, and Shopify: HMAC-SHA256(rawRequestBody, webhookSecret), sent as hex or base64 with an optional sha256= prefix. If Billbee differs, adapt WebhookVerifier::expectedDigest(), the rest of the class stays valid. Please open an issue if you confirm the real scheme.

Testing your integration

Inject any PSR-18 client, no network required:

use Http\Mock\Client as MockClient;

$http = new MockClient();
$http->addResponse($psr7Response);

$billbee = new Billbee($config, httpClient: $http);

Official API documentation

https://app.billbee.io/swagger/ui/index

Development

composer test       # PHPUnit
composer analyse    # PHPStan (level max)
composer fix-cs     # PHP-CS-Fixer
composer ci         # all three

Contributing

Fork the repository and open a pull request.

License

MIT, see LICENSE.