Spondo Billing API
The Spondo Billing API records your customers' usage and your AI costs, prices them with plans and credits, and runs billing. Send requests to https://billing.getspondo.com/api/v1. Requests and responses are JSON over HTTPS, with camelCase field names.
New to Spondo Billing? Start with the Quickstart. From Node, the @spondo/billing client wraps this API; every operation below shows its call next to the curl.
Authentication
Send an API key as a bearer token in the Authorization header (or, if your HTTP client makes that awkward, in an X-API-Key header):
curl https://billing.getspondo.com/api/v1/customers \
--header "Authorization: Bearer $SPONDO_API_KEY"
| Key | Looks like | Can call |
|---|---|---|
| Org API key | sb_live_<account>_… |
Every operation. Create these on the dashboard's Developers page. |
| Customer API key | sb_ck_live_<account>_… |
Only POST /signals/bulk, and only for its own customer. Create these with Create a customer API key. |
| Test key | sb_test_… |
Every operation, but only in a test environment such as a local server with demo data, never at billing.getspondo.com. |
<account> is your account's ID: 16 hexadecimal characters that route the request to your account. Keys are stored hashed and shown once, when created. Keep org keys on your server: they can read and change everything in your account.
A key someone created in the dashboard works only while they are still an owner or admin of the account. If they leave or lose that role, their keys stop working, so create the keys your production systems use as someone who will stay.
A missing, unknown or revoked key gets 401 UNAUTHORIZED. A customer key calling anything but POST /signals/bulk gets 403 CUSTOMER_KEY_FORBIDDEN.
Records and IDs
Every record has a Spondo Billing ID with a prefix that says what it is: cus_ for customers, prod_ for products, plan_ for plans, ord_ for orders, inv_ for invoices, and so on. Operations take these IDs in their paths.
You don't have to store them. Customers, contacts, products and plans also take an externalId, your own ID for the record, and each operation on them has an /external/{externalId} twin: GET /customers/external/acme-legal returns the same customer as GET /customers/cus_Q2x9vB7mK4pT1sLw. Customer external IDs match without regard to case. In request bodies, pass externalCustomerId or externalProductId wherever an operation takes customerId or productId.
Lists
List operations return a page of records and where it sits in the whole list:
{
"data": [{ "id": "cus_Q2x9vB7mK4pT1sLw", "name": "Acme Legal LLP" }],
"pagination": { "limit": 10, "offset": 0, "total": 42, "hasMore": true }
}
Choose the page with limit, from 1 to 100 (default 10), and offset, the number of records to skip (default 0). A limit outside that range is clamped to it. To read everything, raise offset by limit until hasMore is false. Many lists also filter by customer with customerId or externalCustomerId.
Errors
Errors use HTTP status codes and a JSON body with a stable code to branch on:
{
"error": "Bad Request",
"code": "VALIDATION_ERROR",
"details": "name is required"
}
error is the status's name and details says what went wrong, for people. Each operation lists the codes particular to it; these apply everywhere:
| Status | Code | What to do |
|---|---|---|
400 |
VALIDATION_ERROR |
Correct the field details names. A body that isn't valid JSON gets this too. |
401 |
UNAUTHORIZED |
Send a valid API key. |
403 |
CUSTOMER_KEY_FORBIDDEN |
Use an org key: customer keys can only send signals. |
404 |
NOT_FOUND |
Check the ID. API_ENDPOINT_NOT_FOUND means the method and path don't exist. |
409 |
DUPLICATE_EXTERNAL_ID |
A record already has that external ID (customers answer 400 with this code). If you were retrying a create, the first attempt worked: fetch the record by its external ID. |
409 |
CONFLICT |
The record is still referenced by others, so it can't be deleted or changed that way. |
429 |
RATE_LIMITED |
Wait for Retry-After seconds, then send the request again. |
500 |
INTERNAL_ERROR |
Something went wrong on our side. Retry later; signals with an idempotencyKey are always safe to resend. |
Rate limits
Each API key can make 50 requests a second, with bursts of up to 500, which leaves room for batch jobs. A request over the limit gets 429 RATE_LIMITED and a Retry-After header with the seconds to wait. The Node client waits and retries for you. Requests with an unknown key are limited to 10 a minute from each IP address.
Batch operations, such as POST /signals/bulk, count as one request however many records they carry, so batch high-volume writes.
Idempotency
Network failures can leave you unsure whether a write took effect.
- Signals take an
idempotencyKey. A signal sent again with a key already stored is reported as a duplicate and not stored twice, so resending a batch is always safe. Send usage signals has the details. - Cost records take an
idempotencyKeytoo, with the same rule: a record whose key is already stored is counted induplicates. - Credit grants take an
idempotencyKey. Sending the same grant again with it returns the original grant withcreated: false; sending a different grant with it fails with409 IDEMPOTENCY_KEY_REUSED. POST /provisionrequires anIdempotency-Keyheader. A retry with the same key and body returns the first response; the same key with a different body fails with409 IDEMPOTENCY_KEY_REUSED.- Setting a cap to the values already in force changes nothing, and funding instructions return the same bank details each time, so both are safe to repeat.
- Creates with an
externalIdare safe to retry: if the first attempt worked, the retry getsDUPLICATE_EXTERNAL_IDand you can fetch the record by its external ID.
Money
Amounts of money come in two forms, and each field's description says which it uses:
| Form | Example | Used for |
|---|---|---|
| Minor units (cents for USD) | 9900 = $99.00 |
Prices in pricing and orders (fractions allowed for small usage prices, such as 0.5 for half a cent), invoices and their lines, payments and allocations, amendments' …Minor fields, and money in the analytics views. |
| Decimal major units | 99.5 = $99.50 |
Cost records (POST /costs/bulk), billed totals on orders, and every figure in value metrics, delivered value and value receipts. |
Currencies are ISO 4217 codes, such as USD. Credits are not money: credit amounts and balances are numbers of credits, with up to six decimal places, and the credits ledger gives them as decimal strings so they stay exact.
Dates and times
Timestamps are ISO 8601 in UTC, such as 2026-11-09T14:30:00.000Z. Dates without a time are YYYY-MM-DD. You can send timestamps with any offset.
Versions
This is version 1 of the API, in the path as /api/v1. Spondo Billing may add operations, optional request fields and response fields to it, so ignore fields you don't recognise. Breaking changes come with a new version in the path.