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.
- 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)
composer require cainy/billbee-php-sdkIf your project doesn't already ship a PSR-18 client, add one:
composer require guzzlehttp/guzzle
# or
composer require symfony/http-clientuse 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);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.
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 memorytoArray() loads the entire collection, so prefer foreach for large result
sets.
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.
Resources that hang off another resource are collections too:
foreach ($billbee->customers()->addresses($customerId) as $address) {
// ...
}
foreach ($billbee->customers()->orders($customerId) as $order) {
// ...
}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 presentuse 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.
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.
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);https://app.billbee.io/swagger/ui/index
composer test # PHPUnit
composer analyse # PHPStan (level max)
composer fix-cs # PHP-CS-Fixer
composer ci # all threeFork the repository and open a pull request.
MIT, see LICENSE.