Customers, orders and credits
A customer is a business you sell to. An order puts a customer on a plan: what they bought, from when and at what price. Credits are the balance their usage draws on. This guide covers all three; Take payment with Stripe covers checkouts, which create orders when a customer pays.
Customers
Create a customer when they sign up, with your own ID for them as externalId:
await spondo.customers.create({
name: "Acme Legal LLP",
externalId: "acme-legal",
email: "billing@acmelegal.com",
billingAddress: { line1: "1 Fleet Street", city: "London", zipCode: "EC4Y 1AA", country: "GB" },
defaultCurrency: "USD",
});
From then on, use the external ID: in signals (externalCustomerId), in traces (meter.customer_id), and in paths through each operation's /external/{externalId} twin, such as GET /customers/external/acme-legal. External IDs match without regard to case, and must be unique: a customer that already has the ID gets 400 DUPLICATE_EXTERNAL_ID, and one that has it in different case, or as an alias, gets 409 EXTERNAL_ID_IN_USE.
| Field | Notes |
|---|---|
name | Required. |
externalId | Your ID for the customer. Change it with an update; the old ID stays as an alias, so signals that still send it keep working. |
email, phone, website, legalName, vatNumber, billingAddress | Contact and invoicing details. Send "" to clear email, phone or website. |
defaultCurrency | The currency the customer is billed in. Set it when you create the customer. |
metadata | Your own key-value data, returned as sent. |
creationState | active (default), or draft while you're still setting the customer up. |
status | active, or churned once churnDate is set. |
Update a customer with PUT /customers/{id}. It changes only the fields you send; billingAddress and metadata are replaced whole.
When a customer leaves
Set churnDate on the customer: they show as churned, and their history stays. Setting it back to null reactivates them. Deleting a customer is only possible while they have no orders, invoices, payments, credit grants or signals; otherwise it fails with 409 CUSTOMER_HAS_ASSOCIATIONS, listing what's attached.
Aliases
An alias is another ID a customer is known by: the ID another of your systems uses, or the one an acquired product used. Signals and traces can identify the customer by any of its aliases. Add one with POST /customers/external/acme-legal/aliases:
curl https://billing.getspondo.com/api/v1/customers/external/acme-legal/aliases \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{ "alias": "hubspot-48213", "name": "HubSpot company ID" }'
External IDs, aliases and customer unit keys share one namespace across your account, so an ID always means one customer.
Billing contacts
Contacts are the people at a customer who deal with billing. Create them with POST /contacts, give them the BILLING or ACCOUNTS_PAYABLE role, and name the ones who should receive an order's invoices in its billingContactIds; without any, invoices go to the customer's billing contacts, then to the customer's email. Contacts take an externalId too, with /contacts/external/{externalId} twins. A contact an order still names can't be deleted (409 CONTACT_HAS_ASSOCIATIONS).
curl https://billing.getspondo.com/api/v1/contacts \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{ "customerExternalId": "acme-legal", "firstName": "Priya", "lastName": "Shah", "email": "ap@acmelegal.com", "roles": ["ACCOUNTS_PAYABLE"] }'
What a customer has
GET /customers/{id}/state (spondo.customers.state) returns a snapshot: the customer's orders and their state (draft, active, paused or ended), and their credit balance in each credit currency. Use it to decide what a customer can do in your product right now.
Customer units
If your customers have teams, workspaces or projects, model them as customer units: a tree of up to 16 levels under each customer. A customer with an external ID gets a root unit keyed by it.
curl https://billing.getspondo.com/api/v1/customers/external/acme-legal/customer-units \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{ "externalId": "acme-legal-litigation", "name": "Litigation team", "externalType": "team" }'
Name the unit in a signal's customerUnit to attribute usage to it. With units you can:
- Track spend per team:
GET …/customer-units/{unit}/spendtotals the credits a unit and everything beneath it spent over a window, the current month by default. - Cap it:
PUT …/customer-units/{unit}/capsets the most credits a unit may spend each month, such as{ "amount": 5000 }. Usage beyond the cap is held: it spends nothing and isn't billed until the next cap period. (The signal that reaches the cap is spent in full, so a unit can end slightly over.) A cap is versioned: each change applies from itseffectiveFrom, so you can schedule one. Thebilling-credit-cap-reachedwebhook tells you when a unit reaches its cap. - Make it free: set
billable: falseand its signals are recorded with a quantity of 0, so they count in usage but are never charged. Units under a non-billable unit aren't billable either.
Deleting a unit marks it deleted; its key stays reserved and its past usage still counts.
Orders
An order is a customer's subscription: one or more lines, each a product on a plan, with a start date and optionally an end date. A checkout creates one when the customer pays. Create one yourself for deals agreed outside a checkout:
const order = await spondo.orders.create({
customerId: "cus_Q2x9vB7mK4pT1sLw",
name: "Acme Legal: Growth",
startDate: "2026-11-01",
lines: [{ productId: "prod_7Hk2LmQ9xR4tVb1N", planId: "plan_4Fd8sK1mW9qZ2xCv" }],
paymentTerms: "Net30",
creationState: "active",
});
A line without a planId gets the product's default plan. To agree a custom price, list the line's attributes (its charges) with their own pricing, quantities, discounts or credits; those replace the plan's charges for this order, and your plan stays as it is.
Lifecycle
| Status | Means |
|---|---|
draft | Created but not started. Not billed. |
active | Billing from its start date. |
paused | Not billing for now. |
ended | Replaced, cancelled, or past its end date. |
An order is created as a draft unless you send creationState: "active". Activate a draft with POST /orders/{id}/activate. A customer can have an active order for each product: activating an order for a product ends the customer's active order on that product on the new order's start date (an upgrade), and their orders for other products carry on. If the new order starts in the past, the old one is credited for what it billed beyond that date; an old order that bills through a Stripe subscription can't be replaced from a past date (409 ORDER_BILLED_IN_STRIPE). Two running orders can't price the same event, because a signal would bill on both: activating one that does, or amending a charge so it does, fails with 409 CUSTOMER_HAS_ACTIVE_ORDER. Once active, the order's credits are granted as each period begins, and one-time packs once. An order can start in the past: it's credited from its start date, its signals since then spend those credits, and the next billing run invoices the cycles already due.
The order's terms decide how it's invoiced: its billingAnchor (the day each cycle starts), paymentTerms, and whether invoices post and email themselves (autoPostInvoices, autoSendBillingEmails). Once an order is active, its startDate, billingAnchor, billingFrequencyOverride and billingFrequencyCustomMonths are fixed (409 ORDER_NOT_DRAFT): end it and start a new one instead. Take payment with Stripe covers them. On the order, billedThrough says how far it's been invoiced and pendingBillingAmount what its next invoice holds so far.
To end an order, set its endDate. For 90 days after it ends (counted again if its end date moves), usage that arrives late, and any corrections, are billed on a final true-up invoice. DELETE /orders/{id} removes an order completely, so use it only for orders created by mistake: it fails with 409 CONFLICT once the order has invoices.
Change an order
Change what an active order charges (quantities, prices, adding or ending charges, moving end dates) with amendments, in three steps:
GET /orders/{id}/amendments/optionslists each charge, what can change about it and the order's currentorderVersion.POST /orders/{id}/amendments/previewwith your intents returns the plan: what changes, the proration and credits it causes, and aplanHash.POST /orders/{id}/amendmentswith the same intents and theplanHashapplies it.
{
"orderVersion": 3,
"intents": [
{ "type": "updatePricing", "orderLineAttributeId": "6f1c…", "newUnitPriceMinor": 59900, "treatment": "prorate" }
]
}
When a change applies depends on its treatment, and the preview shows it as each operation's effectiveFrom. With prorate, a change applies today and the rest of the current period is charged or credited on the next invoice: raising $499 to $599 on 9 November, in a period from 1 November to 1 December, adds $73.33 (22 of 30 days). Without proration, the default, it applies from the charge's next billing period. A changed charge gets a new ID from the day the change applies.
To change the number of seats, amend a seat-based charge's quantity. A change from a later date keeps today's seats until then. Reducing seats below those already assigned fails with SEATS_ASSIGNED, in the preview and on execution, so unassign some first. If the order changed since your preview, executing fails with 409 ORDER_VERSION_CONFLICT or 409 PLAN_CONFLICT: preview again.
Seats
A seat-based charge creates one seat per unit of quantity. Assign seats to people by your ID for them:
curl https://billing.getspondo.com/api/v1/orders/ord_8Jt3Vw0Ym5Ln2Qp7/seat-assignments \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{ "assignments": [{ "seatId": "2c9d…", "userExternalId": "user_1042" }] }'
GET /orders/{id}/seats lists seats with who holds each, and when each seat starts and ends (startsAt, endsAt), including the seats of a seat change still to come, so you can assign them ahead of time. A person holds at most one seat of each charge, and a seat whose charge has ended can't be assigned (409 SEAT_ENDED).
From your CRM
POST /provision turns a closed deal into a customer, contact, products and order in one call, creating or updating each by its external ID. Send an Idempotency-Key header with the deal's ID: a retry with the same key and body returns the first response, and the same key with a different body gets 409 IDEMPOTENCY_KEY_REUSED. Provisioning again updates a draft order; an active one is changed with amendments.
Credits
Credits are the balance a customer's usage draws on. Each signal of an event their order prices in credits spends quantity × creditCost as it arrives; when the balance runs out, further credits are overage, charged at the overage price. Products, plans and pricing explains how charges grant and price them.
A customer's credits come from:
- Their order's allowances, granted as each allocation period begins (monthly unless the charge says otherwise). An allowance expires at the end of its period, unless the charge rolls some of it over.
- Seat allowances, when a charge grants credits to each seat. A person's seat credits, matched by the signal's
data.external_user_id, are spent before the customer's. - Grants you make, such as a welcome bonus or a top-up they bought. They last until spent or expired.
Credits are spent from the grants valid at the signal's time: seat credits first, then the customer's; soonest-expiring first, and allowances before other grants. Credits a charge grants when an invoice is posted or paid are pending until then: usage that draws on them is held, and becomes overage if the credits never arrive.
Grant credits
await spondo.request("POST", "/customers/external/acme-legal/credits/grants", {
body: {
creditCurrencyKey: "credits",
amount: 500,
expiresAt: "2027-02-01T00:00:00Z",
idempotencyKey: "welcome-bonus:acme-legal",
},
});
amount is a number of credits, with up to six decimal places. Send an idempotencyKey so a retry can't grant twice: the same key with the same grant returns the original grant with created: false, and the same key with a different grant fails with 409 IDEMPOTENCY_KEY_REUSED. With Stripe connected, the grant is mirrored to Stripe as a credit grant, so it reduces the overage Stripe bills.
Balances and history
| To see | Use |
|---|---|
| What a customer has left now, per credit currency (and per seat, for seat credits), with each grant and what's pending | GET /customers/{id}/credits/balances |
| Every grant and spend, newest first | GET /credits/transactions (spondo.credits.transactions) |
| Credits spent per day, week or month, by event | GET /credits/transactions/summary |
In the transaction history, a grant has a positive amount and a spend a negative one; amounts are decimal strings, such as "-12.5", so they stay exact. Each entry's originCode says why: a grant from a plan_benefit, a one_time pack, a rollover or a manual_grant; a spend for usage, overage, held usage, or the expiration of unused credits. GET /customers/{id}/credits/pending-consumption lists held usage and what it's waiting for.
Credit currencies
Most products need one kind of credit, the default currency with key credits. For a separate balance, such as research credits that only some features use, create another currency with POST /credits/currencies and grant it by its key.