BillKit/DocsConsole
SDK

PHP

Framework-agnostic client with a bundled curl transport and an optional PSR-18 seam.

v1.1.1PHP 8.1+OfficialSource

Install

composer require billkit-eu/billkit-php

PHP 8.1 or newer, with ext-json. Composer also installs the PSR-18, PSR-17 and PSR-3 interface packages. No HTTP client library is required; the SDK sends requests with curl (ext-curl, suggested rather than required) unless you inject a PSR-18 client, in which case curl is not needed.

The package follows semantic versioning from 1.0.0: nothing in a 1.x release breaks code written against an earlier 1.x. Coming from 0.x, read Upgrading from 0.x.

Initialise

Every constructor argument is optional and named. A null key falls back to the BILLKIT_API_KEY environment variable; with neither, the constructor throws InvalidArgumentException, and so does an empty or whitespace-only key passed explicitly. timeout is in seconds, as in Guzzle, and defaults to 30, of which at most 10 go on connecting. A timeout above 600 seconds is refused with InvalidArgumentException, which is what catches a 0.x millisecond value such as 30000.

php
<?php

use BillKit\BillKitClient;
use BillKit\RetryPolicy;

$client = new BillKitClient(
  apiKey: 'bk_test_...',
  baseUrl: 'https://api.billkit.eu',
  timeout: 30.0,
  retryPolicy: new RetryPolicy(maxAttempts: 4),
);

Making calls

Every resource is a readonly property on the client, each mirroring the routes under /v1/<resource>. Every method has one of two shapes and always ends with the same two arrays: create(array $params = [], array $opts = []), or with the object’s id first, cancel(string $id, array $params = [], array $opts = []). A webhook delivery takes two ids, the endpoint’s and the delivery’s. $params is what the API receives, under its own snake_case names. $opts is how the SDK sends it: idempotency_key, timeout (seconds), max_retries and headers. An unknown option throws InvalidArgumentException before anything is sent.

PropertyMethods
customerscreate retrieve update delete all autoPagingIterator setVatNumber purge (needs ['confirmed' => true])
products, prices, taxRatescreate retrieve update all autoPagingIterator
couponscreate retrieve update validate all autoPagingIterator
checkoutSessionscreate retrieve
oneShotPaymentscreate retrieve all autoPagingIterator
subscriptionsretrieve all autoPagingIterator cancel reactivate pause resume previewUpdate update reauthorizePaymentMethod createUsageRecord listUsageRecords autoPagingIteratorUsageRecords retrieveUsageSummary
paymentsretrieve retrieveProvider all autoPagingIterator
refundscreate retrieve all autoPagingIterator
disputes, events, auditLogsretrieve all autoPagingIterator
invoicesretrieve retrievePdf sendEmail void all autoPagingIterator
creditNotesretrieve retrievePdf all autoPagingIterator
webhookEndpointscreate retrieve update delete rotateSecret all autoPagingIterator listEventTypes listDeliveries autoPagingIteratorDeliveries retrieveDelivery redeliver
billingPortalSessionscreate revoke
apiKeyscreate retrieve revoke all autoPagingIterator
tenantretrieveCapabilities retrieveBillingProfile setBillingProfile retrievePortalBranding setPortalBranding rotateProviderCredential export
testClockscreate retrieve advance delete all autoPagingIterator (test mode only)

The live event stream has no method here; call that route over HTTP.

Every method returns a typed object generated from the API’s schema, such as BillKit\Model\Customer, with each known field as a read-only property. The same object is read-only array access over the raw response, and both styles are permanent: $customer->id and $customer['id'] are the same value. toArray() gives the plain array 0.x returned, has() answers what array_key_exists() used to, and a field the SDK does not know yet is still under $customer['field']. Enum fields are strings with the known values as constants, such as Subscription::STATUS_PAST_DUE; a value added later is never an error. retrievePdf and tenant->export() return the raw bytes as a string.

Every typed property is nullable, and reading one never throws. A field the payload does not carry reads as null, and that includes fields the API always sends today, id among them. The reason is events: an event’s data is the object as it was recorded, events->retrieve() and webhook redeliveries serve that snapshot unchanged, and an event recorded before a field existed does not have it. Check for null where a missing value matters.

Objects are immutable. Writing through array access, or to a field the class does not declare, throws LogicException; assigning a declared property is PHP’s own readonly Error. Copy with toArray() to change something. json_encode($customer) writes the response back, with an empty metadata as {}; toArray() cannot keep that distinction, so an empty metadata is [] there.

php
$customer = $client->customers->create([
  'email' => 'ada@example.com',
  'name'  => 'Ada Lovelace',
]);

echo $customer->id;

Auto-pagination

List resources expose all() for one page, a Collection with data and has_more, and autoPagingIterator() for a Generator that walks every page over the has_more and starting_after cursors. Paging is forward-only. autoPagingIterator() takes exactly the same params as all(): the filters that resource supports, such as type on events, and limit for the page size.

A Collection can be serialized, so Cache::remember() works on a page, but the restored copy cannot fetch the pages after it: its autoPagingIterator() yields the items it holds and then throws LogicException if has_more is true. Run the list call again to continue.

php
foreach ($client->customers->autoPagingIterator() as $customer) {
  echo $customer['id'], "\n";
}

foreach ($client->events->autoPagingIterator(['type' => 'customer.created']) as $event) {
  handle($event);
}

Idempotency and retries

Connection errors, timeouts and 5xx other than 501 are retried with jittered exponential backoff, and so is a 409 idempotency_in_progress, which means a request with the same key is still in flight; every other 4xx fails at once. A 429 is retried only when its Retry-After is 30 seconds or less. The default RetryPolicy makes four attempts in total, starting at 500 ms and doubling up to 8 seconds between them.

Every mutating call is sent with an auto-generated Idempotency-Key, minted once per call and reused on every retry, so a retry never doubles a charge. Pass your own as the idempotency_key option to make that hold across process restarts. Putting it in the params instead throws, rather than sending the request unprotected. max_retries changes the budget for one call, and 0 sends it once.

php
$client->refunds->create(
  ['payment_id' => 'pay_9XKp2vQ1'],
  ['idempotency_key' => 'refund-order-4711'],
);

Errors

Non-2xx responses throw a subclass of BillKit\Exception\ApiException, chosen by HTTP status. Each carries statusCode, requestId, headers, rawBody (the decoded body), httpBody (the body as received) and the envelope’s errorType, errorCode, param and reason (the finer-grained cause some codes have, such as renewal_processing on a 409 operation_pending). RateLimitException adds retryAfter in seconds. A 2xx whose body is not JSON, which is a captive portal or a proxy answering in the API’s place, throws ApiException itself. Every error from a request extends BillKit\Exception\BillKitException; an argument mistake caught before anything is sent throws InvalidArgumentException.

php
use BillKit\Exception\ApiException;
use BillKit\Exception\NotFoundException;
use BillKit\Exception\RateLimitException;

try {
  $client->customers->retrieve('cus_gone');
} catch (NotFoundException $e) {
  return null;
} catch (RateLimitException $e) {
  sleep((int) ceil($e->retryAfter ?? 1));
} catch (ApiException $e) {
  error_log($e->errorType . ': ' . $e->getMessage() . ' (request ' . $e->requestId . ')');
}

The subclasses are AuthenticationException (401), PaymentException (402: a refused charge, errorCode payment_refused, or a suspended account, tenant_suspended), PermissionDeniedException (403), NotFoundException (404), ConflictException (409), RateLimitException (429), InvalidRequestException (400 and any other 4xx) and ServerException (5xx). A failed connection is ApiConnectionException, and a timeout its subclass ApiTimeoutException; with an injected PSR-18 client, that client’s own exception is on getPrevious(). A bad argument caught before any request, such as customers->purge() without ['confirmed' => true], is a plain InvalidArgumentException.

To fake an API error in your own tests, build it with ApiException::factory($status, $error, $headers). It returns the subclass a real response with that status would produce, so ApiException::factory(404, ['code' => 'resource_missing']) is a NotFoundException, and a Retry-After header fills retryAfter on a 429. The constructors are internal.

Erasing a customer

customers->purge() erases a customer’s personal data for a GDPR request and cannot be undone, so it requires ['confirmed' => true]. Without it the SDK throws InvalidArgumentException before sending anything; it never confirms on your behalf. The answer is a PurgedResource, the deletion marker (id, object customer, deleted and purged both true) rather than the customer. A purged customer has also left the API, so retrieving it afterwards is a NotFoundException. delete() is different: it removes the customer from the API and keeps their data.

php
$purged = $client->customers->purge($customer->id, ['confirmed' => true]);

Webhooks

php
<?php

use BillKit\Webhooks;
use BillKit\Exception\WebhookVerificationException;

$secret = getenv('BILLKIT_WEBHOOK_SECRET');
if (!is_string($secret) || $secret === '') {
  throw new \RuntimeException('BILLKIT_WEBHOOK_SECRET is not set.');
}

try {
  $event = Webhooks::verifySignature(
      payload: (string) file_get_contents('php://input'),
      signatureHeader: $_SERVER['HTTP_BILLKIT_SIGNATURE'] ?? null,
      secret: $secret,
  );
} catch (WebhookVerificationException $e) {
  http_response_code(400);
  exit;
}

Read the body raw. A framework that has already parsed and re-encoded it will not verify. The signature is compared in constant time, a timestamp more than five minutes from now, earlier or later, is rejected, and toleranceSeconds: changes that window. WebhookVerificationException extends BillKitException.

The verified event comes back as its typed class, one per event type the SDK knows, with a typed data: a subscription.updated delivery is a SubscriptionUpdatedEvent whose data is a Subscription. A type the SDK does not know yet is a plain BillKit\Model\Event, never an exception, so route with instanceof and ignore the rest. previous_attributes is always present and null when nothing changed.

Bring your own HTTP client

The bundled curl transport is the default. For custom TLS, proxies or connection pooling, inject any PSR-18 client. The request and stream factories are required alongside it. An injected client keeps its own timeouts, so the timeout option applies to the bundled transport only.

php
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

The SDK defaults to a NullLogger and writes nowhere. Pass any PSR-3 logger as logger: to opt in. You get one debug record per attempt and per response, and one warning per retry. Your API key, request and response bodies and the query string are never logged.

Upgrading from 0.x

1.0 is a deliberate break, and every change is listed in the package’s CHANGELOG.md with before and after code. Composer’s ^0.9 does not admit 1.0, so nothing upgrades until you widen the constraint. In short:

  • Responses are typed objects (BillKit\Model\Customer, …) rather than arrays. Array access keeps working; code that needs a real array (a parameter typed array, array_key_exists(), writing into the response) needs ->toArray() or ->has().
  • all() returns a Collection. $page['data'] still works, and foreach ($page as $item) now iterates the items.
  • Request options moved out of $params into a final $opts array: create($params, ['idempotency_key' => ...]). Every positional $idempotencyKey argument is gone, and idempotency_key inside $params throws.
  • Timeouts are seconds, not milliseconds: timeoutMs: 15_000 is timeout: 15.0. A positional 0.x call such as new BillKitClient($key, null, 30000) would now mean 30000 seconds, so anything above 600 throws.
  • catch (ApiException) now catches every HTTP error, 4xx included. In 0.x it was the parent of ServerException only; catch ServerException where you meant a 5xx. PermissionException is PermissionDeniedException, ResourceMissingException is NotFoundException, and timeouts are ApiTimeoutException. The HTTP fields moved from BillKitException to ApiException, and WebhookVerificationException now extends BillKitException.
  • A 2xx response whose body is not JSON now throws ApiException instead of returning an empty result.
  • autoPagingIterator() takes the same $params as all(); the page size is limit.
  • customers->purge($id) needs ['confirmed' => true], and returns a PurgedResource marker rather than the customer.
  • method is payment_method on checkout sessions and one-off payments (and the model constants METHOD_* are PAYMENT_METHOD_*), test_clock is test_clock_id on customers, and a dispute’s reversed is reversed_at.
  • The status and renewal_state filters on subscriptions, status on disputes and type on events take a PHP list, sent comma-joined: ['status' => ['active', 'past_due']]. percent_off takes up to two decimal places and reads back as a float.
  • tenant->capabilities(), billingProfile() and portalBranding() are retrieveCapabilities(), retrieveBillingProfile() and retrievePortalBranding(), and webhookEndpoints->allDeliveries() is listDeliveries().
  • Coupons take percent_off or amount_off_cents instead of discount_value, and discount_type reads back 'percent' or 'fixed_amount'.
  • Webhooks::verifySignature() returns a typed Event, and previous_attributes is always present, null when nothing changed.