Developers
An API you can build a business on.
Biller is API-first: the console is just another client of the same public endpoints. Commands are safe to retry, previews are priced by the engine that bills, and every change you care about arrives as a signed event.
- Base path
- /v1
- Auth
- Bearer sk_test_… / sk_live_…
- Money
- integer minor units, ISO 4217
- Ids
- UUIDv7
- Time
- RFC 3339, UTC
- Errors
- RFC 9457 problem details
Conventions
Predictable by design.
Every endpoint follows the same rules, so once you have called one you know how the rest behave.
Environment from the key
A test key reads and writes test data; a live key, live data. The tenant and environment always come from the credential, never from the request.
Envelopes
One object comes back as {"data": …}. Lists add has_more and next_cursor, take a limit of 1 to 100, and accept ids[] for batch lookups.
Exact numbers
Posted amounts are integers in the currency’s minor unit, in fields ending _minor. Rates, unit prices and quantities are decimal strings, so nothing is lost to floating point.
Dates that mean something
Instants are RFC 3339 UTC. Business dates such as due dates are YYYY-MM-DD, read in the subscription’s or contract’s billing time zone.
Optimistic concurrency
Versioned resources return ETag: "vN". Send If-Match with a PATCH and a stale write is answered with 412 instead of overwriting someone else’s change.
Flat resources
Responses carry foreign-key ids such as customer_id instead of nested objects, so payloads stay small and every shape is stable.
Errors you can branch on.
Every failure is an application/problem+json document with a stable code, validation errors by field, and the request id to quote when you contact us.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Request-Id: 01a10b11-9428-738c-a439-fdd153a72659
{
"type": "https://docs.biller.example/problems/idempotency-key-required",
"title": "An Idempotency-Key header is required.",
"status": 400,
"detail": "Send an Idempotency-Key header (1-255 visible characters) with this request.",
"instance": "/v1/payments",
"code": "IDEMPOTENCY_KEY_REQUIRED",
"request_id": "01a10b11-9428-738c-a439-fdd153a72659"
}Idempotency
Retry anything. Nothing happens twice.
Creation and money commands require an Idempotency-Key. The first request claims it; a retry with the same request gets the stored response back.
- Keys are scoped to the environment and kept for 24 hours
- Server errors, auth failures and rate limits aren’t stored, so they can be retried
- Contract execution, billing periods, late fees and usage events are idempotent even without the header
- Payment attempts carry their own reference to the gateway, so an uncertain charge is reconciled, not repeated
curl "$BILLER_API/payments" \
-H "Authorization: Bearer $BILLER_API_KEY" \
-H "Idempotency-Key: 7c1f0e8a-0a51-4b9b-9d64-51f6b3a0c2d8" \
-H "Content-Type: application/json" \
-d @payment.json# Same key, same request: the stored response, unchanged
Idempotent-Replayed: true
# Same key, different body
409 IDEMPOTENCY_KEY_REUSED
# Same key while the first request is still running
409 IDEMPOTENCY_REQUEST_IN_PROGRESSPreviews
The preview is the invoice.
Changes with money attached can be priced before they are made. A preview writes nothing, and committing the same input posts exactly what it showed.
POST /v1/subscriptions/{id}/preview-changeQuantity, price and item changes with their prorationPOST /v1/contracts/{id}/amendments/{amendment}/previewThe next contract version, its totals and the exact proration per subscription
Every computed line carries a calculation_trace: the period, the fraction of it remaining, the pricing used and the policy versions that produced the number.
Webhooks
Signed CloudEvents, delivered until they land.
Each change is recorded in the same transaction as the event that announces it, then delivered to your endpoints as a CloudEvents 1.0 document.
- Biller-SignatureHMAC-SHA256 over the timestamp and body
- Rotationboth secrets sign for 24 hours after a rotation
- Retries1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h
- Replayany delivery, from the API or the console
- FencedHTTPS only; no private, loopback or metadata addresses
{
"specversion": "1.0",
"id": "01a10b01-62e9-737e-8f5b-f16c3ed1ef81",
"type": "com.biller.billing.invoice.finalized.v1",
"source": "https://api.biller.example/tenants/01a10b01-032f-70be-9a73-460a06da1b18",
"subject": "invoices/01a10b01-62b8-71fb-b218-faaa510fcf97",
"time": "2026-09-25T14:00:00.000Z",
"datacontenttype": "application/json",
"tenantid": "01a10b01-032f-70be-9a73-460a06da1b18",
"environment": "test",
"aggregateversion": 4,
"data": {
"id": "01a10b01-62b8-71fb-b218-faaa510fcf97",
"number": "INV-000020",
"status": "open",
"currency": "CAD",
"total_minor": 551,
"amount_due_minor": 551,
"due_date": "2026-10-02"
}
}import { createHmac, timingSafeEqual } from 'node:crypto';
/** Biller-Signature: t=<unix>,v1=<hex>[,v1=<hex> during a rotation] */
export function verify(header: string, body: string, secret: string, tolerance = 300) {
const parts = header.split(',').map((part) => part.trim().split('=', 2));
const t = Number(parts.find(([key]) => key === 't')?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest();
return parts
.filter(([key]) => key === 'v1')
.some(([, hex = '']) => {
const given = Buffer.from(hex, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}function verifyBillerSignature(string $header, string $body, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}Event types
- Customers
- customer.created · updated · archived
- Quotes
- quote.sent · accepted · rejected · expired
- Contracts
- contract.executed · amended · activated · renewed · expired · terminated
- Subscriptions
- subscription.created · updated · paused · resumed · canceled · expired
- Invoices
- invoice.created · finalized · paid · partially_paid · voided · marked_uncollectible
- Credit notes
- credit_note.issued
- Payments
- payment.processing · succeeded · failed · returned
- Refunds
- refund.succeeded · failed
- Payment methods
- payment_method.attached · detached
- Mandates
- mandate.activated · canceled
- Receivables
- collection_action.executed · payment_plan.defaulted
- Access
- entitlements.updated
Entitlements
One call decides access.
Your product asks whether a customer may use a feature, optionally for a quantity. The answer combines every grant in effect, overrides and service holds.
- granted
- The feature is on
- within_limit
- The requested quantity fits the limit
- limit_exceeded
- It doesn’t
- disabled
- The feature is granted but switched off
- not_granted
- Nothing grants the feature
- suspended
- A service hold is in place
curl "$BILLER_API/entitlements/check" \
-H "Authorization: Bearer $BILLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "01a10b01-062f-721a-a7e8-f2630010fa44",
"feature_key": "max_vehicles",
"quantity": "58"
}'{
"data": {
"allowed": true,
"reason": "within_limit",
"feature_key": "max_vehicles",
"value_type": "quantity",
"value": "60"
}
}curl "$BILLER_API/usage-events" \
-H "Authorization: Bearer $BILLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"meter": "api_calls",
"customer_id": "01a10b01-062f-721a-a7e8-f2630010fa44",
"event_id": "gw-2026-10-05T14",
"quantity": "8000",
"occurred_at": "2026-10-05T14:00:00Z"
}'# The same event_id again returns the stored event
"duplicate": true
# A different payload under the same event_id
409 USAGE_EVENT_CONFLICT
# A correction is a new event that points at the old one
"adjusts_event_id": "gw-2026-10-05T14", "quantity": "-120"What’s in /v1
The whole product, as endpoints.
Everything the console does, your code can do: the same endpoints, the same validation, the same audit trail.
- Customers
- customers, balances, payment methods, statements
- Catalog
- products, prices, tax rates, features, meters
- Commercial
- quotes, public acceptance links, contracts, amendments
- Billing
- subscriptions, previews, invoices, credit notes, pending charges
- Payments
- payments, refunds, allocations, mandates, PAD files, settlements
- Receivables
- collection policies and steps, payment plans, aging, communications
- Access
- entitlement checks, overrides, service holds, usage events and summaries
- Reporting
- overview, ledger accounts and journal entries
- Platform
- API keys, webhook endpoints and deliveries, events, audit log, team
Build on Biller.
Get a test environment and keys, or walk through your billing with us first.