- Nothing is silently missing. A request whose confirmation never arrived still produces an event, typed
settled, with its cost marked unknown rather than zero. The worst case is a recorded request with an unknown cost, never a missing request. - Everything is idempotent. Event ids are stable across retries and replays. Recorded spend is never mutated: budget resets move period boundaries, corrections arrive as new events, and the ledger is append-only.
- Push and pull agree. The pull surface serves the same canonical envelopes the webhooks deliver, from the same ledger, so a checksum mismatch means something real.
The billing surface (webhook delivery and the pull APIs) is an Enterprise capability, gated by the same plan flag as webhook endpoints. Reads require
gatewaySpend:view; replay requires gatewaySpend:manage. Both are organization-tier permissions carried by an organization API key.The spend event
Spend events ride the standard envelope ({id, type, created, schema_version, data}). The data payload:
event_idis the envelopeid: the gateway request id with a type suffix. Dedup on it.gateway_request_idis the plain join key across the settled and completed pair, and it is the same ULID your client received in theX-LangWatch-Gateway-Request-Idresponse header, so you can join request-side logs to billing events.occurred_atis request time, never ingest or delivery time. Late events land in the right invoice period.statusissuccessorerroron completed events. Failed provider calls are events too;errorcarries{class, http_status}so your billing decides whether to charge for them.end_user_idis the attributed end user (see attribution).metadatais your caller echo, returned verbatim: this is how billing joins events to your own tenant and user records.model_provider_idnames the provider credential that actually served the request.
Money representation
Get your ingest right once:- Quantities are exact integers per token class:
input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens,reasoning_tokens. - Cost is
cost.nano_usd, an int64 in billionths of a USD ($0.003808 arrives as3808000), together withrate_version, the pricing catalog version used to rate it.total_usdis a display-friendly decimal string of the same amount. - Never parse floats for money. Sum the integers; round exactly once, at invoice time. Currency is USD, single and stated.
Settled events and supersession
A request is admitted when the gateway accepts it and confirmed when its outcome (tokens, cost, duration) arrives. If the confirmation never arrives inside the settlement window (30 minutes by default), the request is settled:- Quantities, cost, and duration are
null: unknown is not zero. Never book a settled event as $0. - A settled request never appears in the completed stream, so your billing can trust
completedfor money and treatsettledas a work queue. - If the confirmation later arrives, a real
gateway.request.completedfor the samegateway_request_idfollows and supersedes the settled event. Your consumer replaces the settled figure with the completed one. Replace, never sum. The type-suffixed event ids exist precisely so your dedup layer does not swallow the second event.
Attribution: what the request carries
Attribution is captured on the request path; nothing is provisioned per end user.- End-user id resolution precedence: the
x-langwatch-end-user-idheader, thenx-litellm-end-user-id(a migration alias, accepted so LiteLLM-shaped clients need no change), then the OpenAIuserbody field. Headers win over the body. - Metadata echo: send
x-langwatch-metadata(a JSON object, 4 KB cap). It returns verbatim asmetadataon every spend event for that request. - Request id: every gateway response carries
X-LangWatch-Gateway-Request-Id, the same ULID that keys the spend event.
end_user_id is whatever you send. The gateway stores and echoes it verbatim, so if your application passes a raw email address in the OpenAI user field, that address is what lands in webhook payloads, ledger rows, and the reconciliation pull, and it stays there for the 13-month retention window.Pass a stable pseudonymous id instead, such as a hash of the account id, whenever you would rather not have personal data in those places. It has to be stable, because it is the identity budgets and rollups group by; a value that changes per session bills the same person as many.Reconciliation: two grains, one ledger
The pull surface reads the spend ledger, not the webhook stream, so it includes settled rows and admitted (in-flight) rows the push deliberately types differently. Reconcile in two passes: checksums first, item diff only on divergence.Fast path: aggregate checksums
group_by is virtual_key or end_user; from/to are unix milliseconds and required; limit caps rows (default 500, max 1000); project_id optionally narrows. Each row:
settled_count counts unpriced requests separately; settled rows are never included in the cost sums. Compare a closed period’s event_count and nano_usd against what your billing ingested. Matching checksums end the reconciliation; totals-only comparison is not enough on divergence, because one dropped and one duplicated request of similar cost can net to nearly zero.
Item diff: the cursor walk
fromandto(unix milliseconds) are required, must be safe integers, andfrom <= to; the walk is a ranged read by contract.- Pages are stable under live writes: rows are served in insert order, so a row folded late is picked up by a later page of the same walk instead of being skipped. Offset pagination cannot promise that; cursors can.
- Filters:
virtual_key_id,end_user_id,project_id,model, andstatus(success,error,admitted,confirmed,failed,settled).limitdefaults to 50, max 200. - Rows come back as the same canonical envelopes the webhooks deliver, plus
next_cursor. Diff bygateway_request_id. - A garbled
cursoris refused with400rather than silently restarting the walk. - In-flight requests appear with
status=admittedand null quantities (typedgateway.request.admittedon this surface only; they are never pushed). Treat them as not yet billable.
The settled work queue
GET /api/gateway/v1/spend-events?status=settled&from=...&to=... lists requests recorded without a price. Each is bounded, visible, and resolvable; when a late confirmation supersedes one, it leaves this queue and the completed event carries the money.
Retention
Spend records are retained for a fixed 13 months, independent of your organization’s trace retention settings. A tenant retention policy shrunk to weeks does not touch billing records. The 13 months bound your reconciliation and replay window by contract.Per-end-user spend
The read a rebilling platform polls at period close, or renders in its own UI next to the user’s allowance:- The rollup (
cost,request_count,usage) reads the spend ledger over a rolling window:windowisday,week, ormonth(last 24 hours, 7 days, or 30 days, defaulting tomonth), and explicitfrom/toin unix milliseconds override it. capsis every attributed-user template in your organization that applies to this user, each carrying its current budget period spend, boundary-aware, which is the figure enforcement uses. That period is the budget’s own, so it will not match the rolling rollup above unless the two happen to align; the rollup is for showing usage,capsis for showing headroom. An empty array means no template applies to that user.virtual_key_idnarrows both the rollup and the caps to one anchor key.
gatewaySpend:view.
Replay
- The window is capped at 7 days per call, and a window holding more than 10,000 envelopes is refused with a request to narrow it.
- The response reports
{endpoint_id, replay_id, replayed, window};replay_ididentifies this replay in delivery bookkeeping without touching envelope ids. - Downstream dedup windows are finite (Metronome documents 34 days, Stripe meters 24 hours and up). Replaying a range older than your billing system’s dedup window can double-bill on your side. For old ranges, prefer pull-and-diff over replay.
gatewaySpend:manage. The endpoint must be an active endpoint of your organization.
The ledger UI
Settings > Gateway > Billing events (/settings/gateway/billing-events) renders the spend ledger: cursor-paged, filterable by virtual key, end user, model, status, and date range, with token classes and cost per row and drill-through to the trace. Delivery health lives one screen over, on each endpoint’s deliveries view under Settings > Webhooks, so the pair to keep open during an integration bring-up is this ledger beside your endpoint’s health strip: if the numbers here match your ledger and the lag there stays near zero, the pipeline works.
Operational notes
- Failed requests are events. Decide explicitly whether your billing charges for provider errors;
statusanderrorgive you the branch. - Blocked requests are not. A request rejected before dispatch (budget breach, missing end-user id, disabled key) produced no provider spend and no spend event. The rejection is visible on the budget events and in gateway metrics instead.
- Settlement window: 30 minutes by default. Self-hosted deployments can tune it; see self-hosting webhooks and billing.
- No content, ever. Spend events carry ids, counts, classes, and your metadata echo. Prompts and responses never enter the billing pipe.
See also
- Webhooks: endpoints, signatures, retries, health, the events log.
- Metering and rebilling your customers: the end-to-end cookbook, with the runnable demo repo.
- Budgets: caps on tenants and end users, thresholds, resets.
- Virtual keys: the tenant credential, disable and revoke semantics.