cainy / billbee-php-sdk
A modern PHP SDK for the Billbee API
Requires
- php: ^8.3
- php-http/discovery: ^1.20
- phpdocumentor/reflection-docblock: ^5.4
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/log: ^3.0
- symfony/property-access: ^7.4 || ^8.0
- symfony/property-info: ^7.4 || ^8.0
- symfony/serializer: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.86
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client implementation (^7.8)
- symfony/http-client: An alternative PSR-18 HTTP client implementation
This package is auto-updated.
Last update: 2026-08-20 21:28:40 UTC
README
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-Aftersupport - 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 optionalsha256=prefix. If Billbee differs, adaptWebhookVerifier::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.