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"
    }]
  }'
FieldRequiredWhat it is
eventNameYesWhat happened, such as document_uploaded. Plans and value metrics match on it exactly.
customerYes, or customerUnitWho it was for: { "externalCustomerId": "acme-legal" } (your ID for the customer, or one of its aliases) or { "customerId": "cus_…" }.
customerUnitNoThe team, workspace or project inside the customer it was for: { "externalCustomerUnitId": "…" }. On its own, without customer, it identifies the customer too. See Customer units.
attributionNoThe product it was for: { "externalProductId": "documind" } or { "productId": "prod_…" }. Lets you see revenue, cost and margin per product.
dataNoAnything to keep with the signal, as a JSON object. Plans can price by its numbers.
timestampNoWhen it happened, ISO 8601. Defaults to when it arrives.
idempotencyKeyNo, but send oneYour 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:

KeyWhat it does
A number your plan namesA 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.
quantityThe quantity when the plan doesn't name a field. Without either, a signal counts as 1.
trace_idLinks the signal to an OpenTelemetry trace, so the trace's cost becomes the signal's cost.
external_user_idYour 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 duplicates and 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, gets 403 CUSTOMER_KEY_FORBIDDEN;
  • can send signals only for its own customer. Each signal must name that customer in customer, or one of its units in customerUnit; naming anyone else, or nobody, refuses the batch with 403 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

ToUse
List signals, newest first, filtered by customer, product, event name and timeGET /signals (spondo.signals.list)
Get one signalGET /signals/{id} (spondo.signals.get)
Get up to 500 signals by ID at oncePOST /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 warehousePOST /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 × creditCost for each signal of its event (or the bracket's credits, for bracketed pricing). Credits come out of the person's seat first, if external_user_id names 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.