beckonbilling / api-client
Official PHP client for the Beckon Billing REST API (v1).
Requires
- php: >=8.2
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client, auto-discovered by php-http/discovery.
- nyholm/psr7: A lightweight PSR-7/PSR-17 message and factory implementation, auto-discovered.
- symfony/http-client: An alternative PSR-18 HTTP client, auto-discovered by php-http/discovery.
Provides
None
Conflicts
None
Replaces
None
README
The official PHP client for the Beckon Billing
REST API (v1) - a small, typed, idiomatic wrapper over the public
/api/v1 surface: customers, article categories, articles, quotes, orders, outbound
invoices and recurring invoices, the read-only units and document-templates
vocabularies, plus user-token authentication.
- PSR-4 / PHP
>=8.2, no framework required. - Bring your own HTTP client - any PSR-18 client works and is auto-discovered.
- Typed exceptions you can branch on with a stable
error.key. - The canonical API contract lives in
openapi.yaml(import it into Postman/Insomnia). Building AI tooling on top? SeeAGENTS.md.
This is a thin, open-source HTTP wrapper - it holds no secrets and no server code. You still need a valid API token to do anything.
Installation
composer require beckonbilling/api-client
You also need any PSR-18 HTTP client + PSR-17 factories. If you don't already have one, add Guzzle (auto-discovered):
composer require guzzlehttp/guzzle
Quick start
require 'vendor/autoload.php'; use BeckonBilling\ApiClient\Client; $client = new Client([ 'token' => 'bbp_your_token', // organisation or user token 'base_uri' => 'https://portal.beckonbilling.com', // your portal host 'organisation' => 'organisation-uuid', // optional default scope ]); // List open invoices $page = $client->outboundInvoices->list(['status' => 'issued', 'paid' => 0]); foreach ($page as $invoice) { echo $invoice->public_index, ' - ', $invoice->gross_total, "\n"; } // Create a customer $customer = $client->customers->create([ 'label' => 'Musterfirma AG', 'customer_info' => "Musterfirma AG\nIndustriestraße 45\n1220 Wien", ]); // Download an invoice PDF file_put_contents('invoice.pdf', $client->outboundInvoices->pdf($invoice->id()));
Authentication
Every request sends Authorization: Bearer <token>. There are two token
flavours - both authorise through the same per-feature model:
Organisation tokens
Created in the portal under Firma -> API-Zugriff. Bound to one organisation;
carry an explicit permission grant + the send/bank capabilities. Pass the
bbp_... token as token.
User tokens (app sign-in)
Mint a token from email + password - no portal visit:
$client = new Client(['base_uri' => 'https://portal.beckonbilling.com']); // no token yet $session = $client->auth->login('user@example.com', 'secret'); // $session = ['token' => 'bbp_...', 'expires_at' => ..., 'user' => ..., 'organisations' => [...]] $authed = new Client([ 'token' => $session['token'], 'base_uri' => 'https://portal.beckonbilling.com', 'organisation' => $session['organisations'][0]['id'], ]);
If two-factor authentication is enabled, login() throws an
AuthenticationException whose getErrorKey() is tfa_required until you pass
a code:
$session = $client->auth->login('user@example.com', 'secret', [ 'tfa_code' => '123456', 'remember_device' => true, // returns a 30-day device_token ]);
Organisation scoping
Set a default organisation in the config, or override per call:
$client->customers->list([], ['organisation' => 'another-org-uuid']); $client->customers->list([], ['organisation' => null]); // send no organisation param
A user token with exactly one organisation may omit it entirely.
Resources
| Property | Entity | Feature |
|---|---|---|
$client->customers |
Customers | customers |
$client->articleCategories |
Article categories | articles |
$client->articles |
Articles | articles |
$client->units |
Units (read-only) | none - any valid token |
$client->documentTemplates |
Document templates (read-only) | quotes / outbound_invoices |
$client->quotes |
Quotes | quotes |
$client->orders |
Orders (Auftraege) - no internal rate, no profitability, no sub-routes | orders |
$client->outboundInvoices |
Outbound invoices | outbound_invoices |
$client->recurringInvoices |
Recurring invoices | recurring_invoices |
$client->auth |
User-token auth | - |
Each writable resource offers list(), autoPaging(), get(), create(),
update(), delete(). Quotes and invoices add lifecycle actions (issue,
send/cancel, setPaid, pdf); articles add variants. units and
documentTemplates are read-only - list(), autoPaging(), get().
Quotes::convert() is deprecated: POST /quotes/{id}/convert was retired on
2026-08-28 and now answers 410 on every call, so the method throws a local
GoneException (quote_conversion_moved) instead of making a request. A won
quote becomes an order, and the order is what gets invoiced - see AGENTS.md
for what that means for this client today.
Deprecations
recurring_invoice.document_ids - the attachment list carried as internal
INTEGER ids, the one place this API ever exposed them - is deprecated as of
the next minor release and removed in the next major release. Use
document_uuids, the same attachments addressed by uuid:
$client->recurringInvoices->update($id, [ 'document_uuids' => ['1f2e…', '9a8b…'], // the way to set attachments ]); $template->documentUuids(); // read them back $template->documentIds(); // still works, @deprecated
Nothing on the wire changes while both exist: the API keeps emitting and
accepting either key, and a request carrying both is applied from
document_uuids. Move anyway - an integer id belonging to another
organisation, or to nothing at all, is dropped in silence rather than refused,
so a wrong value cannot be told apart from a saved one.
Pagination
Lists return a Collection (data, total, limit, offset; iterable and
countable):
$page = $client->customers->list(['limit' => 50, 'offset' => 0]); $page->total; // full server-side count $page->hasMore(); // more pages available?
Or iterate everything lazily:
foreach ($client->customers->autoPaging() as $customer) { // ... }
Error handling
Non-2xx responses throw a typed exception. Every one exposes
getStatusCode(), getErrorKey() (the stable slug) and getResponse().
use BeckonBilling\ApiClient\Exception\ApiException; use BeckonBilling\ApiClient\Exception\PermissionException; try { $client->outboundInvoices->issue($id); } catch (PermissionException $e) { if ($e->getErrorKey() === 'send_not_permitted') { // token lacks the `send` capability } } catch (ApiException $e) { // any other API error error_log($e->getStatusCode() . ' ' . $e->getErrorKey() . ': ' . $e->getMessage()); }
| Exception | Status |
|---|---|
AuthenticationException |
401 |
PermissionException |
403 (incl. send_not_permitted / bank_not_permitted) |
NotFoundException |
404 |
ConflictException |
409 |
GoneException |
410 (a route retired for good, e.g. quote_conversion_moved) |
ValidationException |
400 / 422 |
RateLimitException |
429 |
ServerException |
5xx |
TransportException |
network failure (no response) |
All extend ApiException, so a single catch (ApiException $e) covers everything.
Custom HTTP client
Inject any PSR-18 client / PSR-17 factories (for a preconfigured Guzzle, retries, proxies, timeouts, test doubles ...):
use GuzzleHttp\Client as Guzzle; use Nyholm\Psr7\Factory\Psr17Factory; $psr17 = new Psr17Factory(); $client = new Client([ 'token' => 'bbp_...', 'base_uri' => 'https://portal.beckonbilling.com', 'http_client' => new Guzzle(['timeout' => 10]), 'request_factory' => $psr17, 'stream_factory' => $psr17, ]);
Working models
Resource objects wrap the JSON payload: read documented fields as properties or array keys, and any field the API adds later stays reachable without a client upgrade.
$customer->label; // property access $customer['label']; // array access $customer->id(); // the UUID $customer->toArray(); // the full payload $invoice->isPaid(); // a few typed helpers on quotes/invoices
Contributing / development
composer install composer test # PHPUnit, fully mocked - no network
The API contract in openapi.yaml and the agent guide in AGENTS.md are part
of the release and are kept in sync with the API surface. The reasoning behind
them lives in docs/decisions/ as ADRs; a change that
affects more than one file, or that a consumer would notice, gets one.
License
MIT - see LICENSE.