billkit-eu / billkit-php
Official PHP SDK for BillKit: a Stripe-Billing-shape multi-tenant SaaS API on Mollie.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5
Suggests
- php-http/discovery: Auto-wire a PSR-18 HTTP client + PSR-17 factories instead of the bundled curl transport.
Provides
None
Conflicts
None
Replaces
None
README
Official PHP SDK for BillKit, a Stripe-Billing-shape, multi-tenant SaaS billing API running on Mollie.
- PHP 8.1+, PSR-4, no hard runtime dependencies beyond
ext-curl/ext-json. - Full resource coverage, typed exception hierarchy, automatic retries with idempotency, cursor auto-pagination, and webhook signature verification.
- Bring-your-own PSR-18 HTTP client (Guzzle, Symfony HttpClient, ...) when you need custom transport behaviour.
Install
composer require billkit-eu/billkit-php
Quick start
use BillKit\BillKitClient; $client = new BillKitClient('bk_test_...'); // or set BILLKIT_API_KEY $customer = $client->customers->create([ 'email' => 'ada@example.com', 'name' => 'Ada Lovelace', ]); $product = $client->products->create(['name' => 'Pro']); $price = $client->prices->create([ 'product_id' => $product['id'], 'amount_cents' => 999, 'currency' => 'EUR', 'interval' => 'month', ]); $session = $client->checkoutSessions->create([ 'customer_id' => $customer['id'], 'price_id' => $price['id'], 'success_url' => 'https://app.example.com/done', 'cancel_url' => 'https://app.example.com/pricing', ]);
Every method returns the decoded JSON body as a plain associative array.
The SDK deliberately ships no model classes so responses forward through your
own data layer unchanged.
One-shot payments
Charge a customer a single time without creating a mandate: no subscription,
no renewal. Create the payment, redirect the shopper to redirect_url, then
(optionally) refund it later. refund_window_days sets how long the charge
stays refundable: 0 disables refunds, the default is 30, the max is 365.
$payment = $client->oneShotPayments->create([ 'customer_id' => $customer['id'], 'amount_cents' => 1999, 'currency' => 'EUR', 'method' => 'ideal', 'success_url' => 'https://app.example.com/done', 'cancel_url' => 'https://app.example.com/cart', ]); header('Location: ' . $payment['redirect_url']); // send the shopper to pay // The payment settles via the one_shot_payment.succeeded / .failed webhooks. // Refund a settled one-shot payment (omit amount_cents for a full refund): $client->refunds->create(['one_shot_payment_id' => $payment['id']]); // ...or refund part of it. A charge can carry several partials: $client->refunds->create(['one_shot_payment_id' => $payment['id'], 'amount_cents' => 500]);
Configuration
use BillKit\BillKitClient; use BillKit\RetryPolicy; $client = new BillKitClient( apiKey: 'bk_test_...', baseUrl: 'https://api.billkit.eu', // override for self-hosted timeoutMs: 30_000, retryPolicy: new RetryPolicy(maxAttempts: 4), logger: $psrLogger, // opt-in; omitted = silent );
The API key resolves from the constructor argument, falling back to the
BILLKIT_API_KEY environment variable.
Auto-pagination
List endpoints expose all() (one page) and autoPagingIterator() (a
Generator that walks every page via the has_more + starting_after
cursor protocol):
foreach ($client->customers->autoPagingIterator() as $customer) { echo $customer['id'], "\n"; } // Server-side filters are first-class where the API supports them: foreach ($client->events->autoPagingIterator(type: 'customer.created') as $event) { // ... }
Error handling
Non-2xx responses raise a typed subclass of BillKit\Exception\BillKitException,
so you catch the case you care about instead of branching on status codes:
use BillKit\Exception\ResourceMissingException; use BillKit\Exception\RateLimitException; use BillKit\Exception\BillKitException; try { $client->customers->retrieve('cus_missing'); } catch (ResourceMissingException $e) { // 404 } catch (RateLimitException $e) { sleep((int) ceil($e->retryAfter ?? 1)); } catch (BillKitException $e) { error_log($e->errorType . ': ' . $e->getMessage() . ' (request ' . $e->requestId . ')'); }
Hierarchy: ApiConnectionException, AuthenticationException (401),
PermissionException (403), ResourceMissingException (404),
ConflictException (409), RateLimitException (429), InvalidRequestException
(4xx), ServerException (5xx), all extending BillKitException.
Retries & idempotency
Transient failures (connection errors, 5xx, and 429 with a short Retry-After)
are retried with jittered exponential backoff. Every mutating call is sent with
an auto-generated Idempotency-Key, so a retried request never double-charges.
Supply your own to coalesce retries across process restarts:
$client->refunds->create([ 'payment_id' => 'pay_1', 'idempotency_key' => 'refund-order-4711', ]);
Webhooks
Verify the BillKit-Signature header before trusting a webhook body:
use BillKit\Webhooks; use BillKit\Exception\WebhookVerificationException; try { $event = Webhooks::verifySignature( payload: file_get_contents('php://input'), signatureHeader: $_SERVER['HTTP_BILLKIT_SIGNATURE'] ?? null, secret: getenv('BILLKIT_WEBHOOK_SECRET'), ); } catch (WebhookVerificationException $e) { http_response_code(400); exit; } // $event is the decoded, verified payload.
Custom HTTP client (PSR-18)
By default the SDK uses a bundled curl transport. To route requests through your own PSR-18 client (for custom TLS, proxies, or connection pooling), inject it alongside PSR-17 factories:
use BillKit\BillKitClient; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Psr7\HttpFactory; $factory = new HttpFactory(); $client = new BillKitClient( apiKey: 'bk_test_...', httpClient: new GuzzleClient(), requestFactory: $factory, streamFactory: $factory, );
Logging (PSR-3)
The SDK is silent by default: it defaults to a NullLogger and writes nowhere, so it can't take over your application's logging. Inject any PSR-3 logger to opt in:
use BillKit\BillKitClient; use Monolog\Handler\StreamHandler; use Monolog\Logger; $log = new Logger('billkit'); $log->pushHandler(new StreamHandler('php://stderr', Logger::DEBUG)); $client = new BillKitClient( apiKey: 'bk_test_...', logger: $log, );
billkit.DEBUG: BillKit request {"method":"POST","url":"https://api.billkit.eu/v1/customers","attempt":1,"max_attempts":3}
billkit.DEBUG: BillKit response {"method":"POST","url":".../v1/customers","status":503,"duration_ms":84,"request_id":"req_9f2a"}
billkit.WARNING: BillKit retrying {"method":"POST","url":".../v1/customers","reason":"HTTP 503","attempt":1,"delay_ms":500}
billkit.DEBUG: BillKit response {"method":"POST","url":".../v1/customers","status":200,"duration_ms":91,"request_id":"req_9f2b"}
- debug: one record per attempt, one per response (
status,duration_ms,request_id; quote that id to support). - warning: one record per retry, with the reason and the delay before the next attempt.
Never logged: your API key or the Authorization header; request and response bodies (they carry customer PII); the query string (list filters carry values like email=); only the path is logged. The final failure isn't logged either: it's thrown as a typed BillKitException carrying the status, request id and retry-after, and logging it here too would hand you a duplicate you can't suppress.
Using Laravel? The billkit-eu/billkit-laravel package wires a log channel for you via config/billkit.php.
API surface
Every resource is a property on the client. List resources expose all() (one
page) and autoPagingIterator() (walk all pages).
$client->... |
Methods |
|---|---|
customers |
create, retrieve, update, delete, all, autoPagingIterator, setVatNumber, purge |
products |
create, retrieve, update (archive with ['active' => false]), all, autoPagingIterator |
prices |
create, retrieve, update (archive with ['active' => false]), all, autoPagingIterator |
checkoutSessions |
create, retrieve |
oneShotPayments |
create, retrieve |
subscriptions |
retrieve, all, autoPagingIterator (filter by customer_id, status, renewal_state), cancel, pause, resume, reactivate, previewUpdate, update, reauthorizePaymentMethod |
refunds |
create, retrieve, all, autoPagingIterator |
webhookEndpoints |
create, retrieve, update (retire with ['status' => 'disabled']), rotateSecret, all, autoPagingIterator, allDeliveries, autoPagingIteratorDeliveries, retrieveDelivery, redeliver |
events |
retrieve, all, autoPagingIterator |
tenant |
capabilities, portalBranding, setPortalBranding, rotateProviderCredential |
coupons |
create, retrieve, update (withdraw with ['active' => false]), validate, all, autoPagingIterator |
taxRates |
create, retrieve, update (retire with ['active' => false]), all, autoPagingIterator |
invoices |
retrieve, all, autoPagingIterator |
auditLogs |
retrieve, all, autoPagingIterator |
payments |
retrieve, all, autoPagingIterator |
billingPortalSessions |
create, revoke |
Retiring something, and deleting something
delete() exists on customers and webhookEndpoints, and it returns ['id' => ..., 'object' => ..., 'deleted' => true] rather than the object: it has left the API, so there is nothing to hand back. A deleted endpoint takes its delivery rows with it, because those are readable only through the endpoint that owns them; the events stay in $client->events, which is the record of what you were sent.
The catalogue is retired through its update route instead, because it stays readable afterwards. Prices, products, tax rates and coupons take ['active' => false]. Each of them has to survive: subscriptions renew against a price by id, an invoice records the VAT percentage a tax rate produced, and a redeemed coupon is part of what a customer was charged.
['status' => 'disabled'] on a webhook endpoint is the other half of the pair, not a substitute for deleting. It stops delivery and keeps the endpoint, its secret and its history, and it can be turned back on.
A price accepts active and nothing else, because the amount, currency and interval are fixed at creation. active itself moves both ways: it decides what new checkouts may buy, not what anyone was charged.
// Stop selling a price. It stays readable; customers on it keep renewing. $archived = $client->prices->update($price['id'], ['active' => false]); // Stop sending to an endpoint, without losing its signing secret. $client->webhookEndpoints->update($endpoint['id'], ['status' => 'disabled']); // Remove one entirely, along with its delivery rows. $client->webhookEndpoints->delete($endpoint['id']); // => ['deleted' => true, ...] // Remove a customer. Refused while they hold a subscription that can // still charge them. $client->customers->delete($customer['id']); // => ['deleted' => true, ...]
Finding paused subscriptions
status and renewal_state answer different questions, and only one of them knows about pausing. status is where the subscription stands with its payments (incomplete, trialing, active, past_due, canceled). renewal_state is what happens when the current period ends (auto_renew, paused, canceling, stopped). Pausing sets renewal_state and leaves status at active, because the customer has paid for the period they are in:
$paused = $client->subscriptions->all(['renewal_state' => 'paused']); // Both filters take a comma-separated list, and carry onto every page: foreach ($client->subscriptions->autoPagingIterator(100, ['status' => 'active,past_due']) as $sub) { // ... }
['status' => 'paused'] is not an accepted value and throws InvalidRequestException.
Archiving a price
A price's amount, currency and interval are fixed at creation, so you stop selling one rather than editing it. The price keeps its id and stays readable, because subscriptions renew against it by id. Subscriptions already on it keep renewing at it; what stops is new business. Re-archiving is a no-op, so a retry is safe, and ['active' => true] puts it back on sale unchanged.
$archived = $client->prices->update($price['id'], ['active' => false]); // $archived['active'] === false $back = $client->prices->update($price['id'], ['active' => true]); // $back['active'] === true, and the amount is exactly what it always was
Plus BillKit\Webhooks::verifySignature(...) (static) for inbound webhooks.
Development
composer install composer test # PHPUnit composer analyse # PHPStan (level max) composer cs # php-cs-fixer (apply) composer cs:check # php-cs-fixer (dry-run)
License
Apache-2.0