Node client
@spondo/billing calls the Spondo Billing API from your server. It has a typed method for the calls most products make (sending signals and managing customers, products, plans, orders, checkouts and invoices) and request() for every other operation in the API reference. Cost tracing lives in the same package, at @spondo/billing/tracing.
Use it only on your server. An API key can read and change everything in your Spondo Billing account, so it must never reach browser code.
Install
npm install @spondo/billing
The client runs wherever fetch does: Node 20 or later, Bun, Deno and edge runtimes. It has no runtime dependencies. Tracing needs Node and the OpenTelemetry packages listed on the Tracing page.
Create a client
Create one client when your server starts and use it everywhere:
import { Spondo } from "@spondo/billing";
export const spondo = new Spondo(); // reads SPONDO_API_KEY
Create the key on the Developers page of the dashboard and keep it in your secret store. The constructor throws when there is no key.
| Option | Default | What it does |
|---|---|---|
apiKey | SPONDO_API_KEY | The org API key the client sends as Authorization: Bearer …. |
baseUrl | SPONDO_BASE_URL, then https://billing.getspondo.com | Where the API is. Leave it unset unless you run against another environment. The client adds /api/v1. |
maxRetries | 2 | How many times a rate-limited call (429) is sent again. |
fetch | globalThis.fetch | A fetch to use instead, for example one with a proxy or a test double. |
const spondo = new Spondo({
apiKey: process.env.BILLING_KEY,
maxRetries: 4,
});
Send usage
A signal is one thing your product did for a customer. signals.send takes one signal or an array and returns how many were stored:
const result = await spondo.signals.send({
eventName: "document_uploaded",
customer: { externalCustomerId: "acme-legal" },
attribution: { externalProductId: "documind" },
data: { pages: 12 },
idempotencyKey: "doc_8f2a", // a retry with the same key is stored once
});
// { ingested: 1, duplicates: 0, failed: 0 }
Send usage signals covers idempotency keys, batches and customer keys.
Resources
Methods that take a Ref accept either Spondo Billing's ID for the record ("cus_Q2x9vB7mK4pT1sLw") or your own ID for it ({ externalId: "acme-legal" }). Lists take limit (1 to 100, default 10) and offset, and return { data, pagination: { limit, offset, total, hasMore } }.
| Resource | Methods |
|---|---|
signals | send(signal | signals[]), list({ customerId, externalCustomerId, signalName, createdAtFrom, createdAtTo, limit, offset }), get(id) |
customers | create(body), list(), get(ref), update(ref, body), delete(ref), state(ref) |
products | create(body), list({ name }), get(ref), update(ref, body) |
plans | create(body), list(), get(ref), update(ref, body) |
orders | create(body), list({ customerId, externalCustomerId }), get(id), update(id, body), delete(id) |
checkouts | create(body), list(), get(id), delete(id) |
invoices | list({ customerId, externalCustomerId }), get(id), lines(id) |
credits | transactions({ customerId, externalCustomerId }) |
Each method sends the operation of the same name in the API reference, so its page lists the fields a body takes and what comes back. Every operation's page shows the call with this client next to the curl.
const customer = await spondo.customers.create({
name: "Acme Legal LLP",
externalId: "acme-legal",
email: "billing@acmelegal.com",
});
await spondo.customers.get({ externalId: "acme-legal" });
await spondo.customers.update("cus_Q2x9vB7mK4pT1sLw", { email: "finance@acmelegal.com" });
// What the customer has: active orders, entitlements and credit balances.
const state = await spondo.customers.state({ externalId: "acme-legal" });
// A link to send the customer: they pick a plan and pay.
const { url } = await spondo.checkouts.create({
products: [{ id: "prod_7Hk2LmQ9xR4tVb1N" }],
externalCustomerId: "acme-legal",
successUrl: "https://documind.example/welcome",
});
Any other operation
request(method, path, { query, body, headers }) sends any /api/v1 operation. Write the path without /api/v1. Query values that are null or undefined are left out, and the body is sent as JSON. headers adds request headers, such as the Idempotency-Key that POST /provision requires; the client's own Authorization and Content-Type win over any you pass. It returns the parsed response, or undefined for an empty one.
const balances = await spondo.request("GET", "/customers/external/acme-legal/credits/balances");
await spondo.request("POST", "/customers/cus_Q2x9vB7mK4pT1sLw/credits/grants", {
body: { creditCurrencyKey: "credits", amount: 500, idempotencyKey: "welcome-grant:acme-legal" },
});
await spondo.request("POST", "/provision", {
headers: { "Idempotency-Key": "sf-0065g00000AbCdE-v1" },
body: provisioning, // the deal from your CRM
});
const { data } = await spondo.request("GET", "/signals/summary", {
query: { externalCustomerId: "acme-legal", interval: "day", startDate: "2026-10-01", endDate: "2026-10-31" },
});
Pass a type to describe the response: spondo.request<{ data: Invoice[] }>("GET", "/invoices").
Errors
A call that fails throws a SpondoError with the API's error fields:
| Field | What it is |
|---|---|
status | The HTTP status, such as 404. |
code | The API's error code, such as NOT_FOUND or VALIDATION_ERROR. HTTP_ERROR when the response had no JSON error body. |
message | The API's details: what went wrong, for people. |
retryAfter | On a 429, the seconds the server asked you to wait; otherwise null. |
import { SpondoError } from "@spondo/billing";
try {
await spondo.customers.create({ name: "Acme Legal LLP", externalId: "acme-legal" });
} catch (e) {
if (e instanceof SpondoError && e.code === "DUPLICATE_EXTERNAL_ID") {
// Already created, for example by an earlier attempt: fetch it instead.
return spondo.customers.get({ externalId: "acme-legal" });
}
throw e;
}
Network failures reach you as the error fetch throws, not as a SpondoError.
Retries
When the API answers 429 Too Many Requests, the client waits for the seconds in its Retry-After header (one second if there is none) and sends the same request again, up to maxRetries times. After that it throws the SpondoError, whose retryAfter tells you how long to wait before your own retry. Other errors are thrown at once.
Resending a signal is always safe when it carries an idempotencyKey: a signal sent twice with the same key is stored once. For other writes that time out, check whether the first attempt took effect before sending it again; for example, look the record up by your external ID.
The rate limits are generous: 50 requests a second for each API key, with bursts of up to 500.