Send usage signals
A signal is one thing your product did for a customer: a document uploaded, a contract reviewed, a call answered. Plans price usage and spend credits by the signal's event name and the numbers in its data, and value metrics turn the same signals into the value you delivered. Send one each time it happens, from your server.
Send a signal
POST /signals/bulk takes a batch of signals. The Node client's signals.send takes one signal or an array:
import { Spondo } from "@spondo/billing";
const spondo = new Spondo(); // reads SPONDO_API_KEY
await spondo.signals.send({
eventName: "document_uploaded",
customer: { externalCustomerId: "acme-legal" },
attribution: { externalProductId: "documind" },
data: { pages: 12 },
idempotencyKey: "doc_8f2a",
});
curl https://billing.getspondo.com/api/v1/signals/bulk \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"signals": [{
"eventName": "document_uploaded",
"customer": { "externalCustomerId": "acme-legal" },
"attribution": { "externalProductId": "documind" },
"data": { "pages": 12 },
"idempotencyKey": "doc_8f2a"
}]
}'
| Field | Required | What it is |
|---|---|---|
eventName | Yes | What happened, such as document_uploaded. Plans and value metrics match on it exactly. |
customer | Yes, or customerUnit | Who it was for: { "externalCustomerId": "acme-legal" } (your ID for the customer, or one of its aliases) or { "customerId": "cus_…" }. |
customerUnit | No | The team, workspace or project inside the customer it was for: { "externalCustomerUnitId": "…" }. On its own, without customer, it identifies the customer too. See Customer units. |
attribution | No | The product it was for: { "externalProductId": "documind" } or { "productId": "prod_…" }. Lets you see revenue, cost and margin per product. |
data | No | Anything to keep with the signal, as a JSON object. Plans can price by its numbers. |
timestamp | No | When it happened, ISO 8601. Defaults to when it arrives. |
idempotencyKey | No, but send one | Your ID for this event. A signal whose key was already stored isn't stored again. |
The response counts what happened to the batch:
{ "ingested": 1, "duplicates": 0, "failed": 0 }
What data is for
data is yours: any JSON object, kept with the signal and returned as sent. A few keys mean something to Spondo Billing:
| Key | What it does |
|---|---|
| A number your plan names | A usage charge or credit rule can name a field that holds the signal's quantity, such as pages. { "pages": 12 } then counts as 12 pages. |
quantity | The quantity when the plan doesn't name a field. Without either, a signal counts as 1. |
trace_id | Links the signal to an OpenTelemetry trace, so the trace's cost becomes the signal's cost. |
external_user_id | Your ID for the person at the customer who did it. If they hold a seat with its own credits, those are spent first. |
Keys starting _derived, _dims or _billing are reserved; a signal using one is refused with 400 RESERVED_SIGNAL_DATA_FIELD.
A missing, negative or non-numeric quantity counts as 1. The quantity is worked out when the signal arrives, using the customer's plan at that moment.
Idempotency keys
Networks fail, and a request that timed out may still have been stored. Give every signal an idempotencyKey, built from the ID of the thing that happened, such as the document's ID or your own event ID. Then resending is always safe:
- A signal whose key Spondo Billing already holds is counted in
duplicatesand not stored again, whatever the rest of it says. The first one sent wins. - This holds within a batch too, and for as long as the signal exists: keys don't expire.
- Keys sent with your org key share one namespace across all your customers, so make them unique across your whole product. Keys sent with a customer key are kept separate for each customer.
Use a stable key, never a random one generated for each attempt: a fresh key on a retry makes it a new signal.
A signal without a key is never treated as a duplicate.
Batches
Send many signals in one request when you can: a batch counts as one request towards your rate limit, however many signals it carries. There's no limit on the number of signals, only on the request's size: 20 MB.
A batch is checked before anything is stored. If one signal is malformed (no eventName, no customer or a customer with no ID in it, data that isn't an object, a reserved data key), the whole batch is refused with 400 and details names the signal, such as signals[3].eventName is required. Fix it and send the batch again.
A signal that names a customer unit belonging to a different customer refuses the batch with 400 CUSTOMER_UNIT_MISMATCH, and one that names only a unit nobody has with 404 CUSTOMER_UNIT_NOT_FOUND.
Once a batch passes those checks, each signal is stored on its own. A signal whose product isn't found, or whose timestamp can't be read, is counted in failed, and the others are still stored. When failed isn't zero, check the product IDs and timestamps; signals with keys are safe to resend once they're right.
Customers and IDs
Signals identify the customer with externalCustomerId, so you don't need to store Spondo Billing's IDs. It matches:
- the customer's
externalId, without regard to case; - any of the customer's aliases, such as the ID another of your systems uses, or a customer's old ID after you changed its
externalId.
customerId takes Spondo Billing's ID. If you send both, customerId wins.
You can send signals before the customer exists, for example while your sign-up flow is still creating them. Such signals are stored and counted in ingested, without a customer. Within a minute of a customer getting that ID, external ID or alias, the signals become theirs: they're priced, spend credits, bill and count towards delivered value from their own timestamps, and pick up the costs traced under them. Until then they're listed with customerId: null, and customer filters don't find them.
Customer units
If your customers have teams, workspaces or projects, model them as customer units and name the unit a signal came from in customerUnit; unit keys match without regard to case. A signal can name the unit alone, and its customer is found from the unit. You can then see usage per unit, cap a unit's spend (usage beyond the cap is held, not charged), and mark units as not billable: their signals are recorded with a quantity of 0, so they show in usage but are never charged.
Customer API keys
Sometimes the system doing the work belongs to your customer: an agent they run, or an on-premises install. Rather than give it your org key, give it a customer API key. A customer key:
- looks like
sb_ck_live_<account>_…; - can call only
POST /signals/bulk; every other operation, and trace ingest, gets403 CUSTOMER_KEY_FORBIDDEN; - can send signals only for its own customer. Each signal must name that customer in
customer, or one of its units incustomerUnit; naming anyone else, or nobody, refuses the batch with403 CUSTOMER_MISMATCH; - keeps its own idempotency keys, separate from yours and other customers'.
Create one with POST /customers/external/{externalId}/api-keys. The response holds the key, and it's the only time you'll see it, so pass it straight to your customer. List a customer's keys with GET …/api-keys and revoke one with DELETE …/api-keys/{apiKeyId}; a key can also be given an expiry. Revoked and expired keys get 401.
curl https://billing.getspondo.com/api/v1/customers/external/acme-legal/api-keys \
--request POST \
--header "Authorization: Bearer $SPONDO_API_KEY" \
--header "Content-Type: application/json" \
--data '{ "name": "Acme on-prem agent" }'
Your customer then sends signals with it exactly as you would:
curl https://billing.getspondo.com/api/v1/signals/bulk \
--request POST \
--header "Authorization: Bearer $CUSTOMER_KEY" \
--header "Content-Type: application/json" \
--data '{ "signals": [{ "eventName": "contract_reviewed", "customer": { "externalCustomerId": "acme-legal" }, "idempotencyKey": "rev-20261109-0042" }] }'
Read signals back
| To | Use |
|---|---|
| List signals, newest first, filtered by customer, product, event name and time | GET /signals (spondo.signals.list) |
| Get one signal | GET /signals/{id} (spondo.signals.get) |
| Get up to 500 signals by ID at once | POST /signals/lookup |
Count signals per hour, day, week or month, optionally by event name or a data field, as JSON or CSV (format=csv) | GET /signals/summary |
Export a monthly usage file per customer, as CSV or JSON, with columns split by up to two data fields, for finance or your warehouse | POST /signals/summary/batch, then poll until it has succeeded |
A signal's createdAt is its timestamp: when it happened, not when it arrived. Late and backdated signals land in the period they happened in.
When signals are billed
Signals are billed by the charges on the customer's orders, as each signal arrives:
- A prepaid-credits charge spends credits:
quantity × creditCostfor each signal of its event (or the bracket's credits, for bracketed pricing). Credits come out of the person's seat first, ifexternal_user_idnames one, then the customer's grants. When they run out, extra credits are overage, billed at the overage price. - A usage charge totals the quantity of its event's signals over the billing period and prices it per unit or in tiers.
For orders that bill as Stripe subscriptions, credit usage is reported to Stripe as each signal arrives, so Stripe's invoice for the period includes it. Products, plans and pricing explains each way to price usage.