The runnable reference: agent-billing-demo
A tiny open-source agent-platform SaaS implementing every step below: sign-up provisions a real tenant key and real caps, chat rides the gateway, and the meters on screen are driven by the signed events its own receiver ingests. The webhook receiver, ledger, reconciliation loop, and provisioning exist in both TypeScript and Python. Point your coding agent at that repo as the canonical integration example; every file is written to be copied from.
What you need
- An Enterprise plan or license (webhooks and the billing APIs gate on it).
- Two credentials, because the surfaces are deliberately separated. A gateway key for provisioning (
virtualKeys:*,gatewayBudgets:*), and an organization API key for the billing and webhook surfaces (webhookEndpoints:manage,gatewaySpend:view), whose permissions only an organization-scoped role can grant. The curls below show which one each call takes. See RBAC. - A provider credential configured once at organization scope (Settings > Model Providers). Tenants never see it.
1. Provision a tenant
Signing up a customer is four calls from your backend: mint the key, attach a hard cap and a soft cap, add the per-end-user allowance. Store the key id, the secret (shown once), and the budget ids on your customer row.- Stamp your own id and retry safely. Put your customer id in
external_idon the key and every budget, and send anIdempotency-Keyon each create. A signup that times out halfway then replays with the same key and the same body: the calls that already succeeded return their original response instead of minting a second key, andexternal_idlets you find what you created without having stored our ids first. That is what turns provisioning from “store four ids in a transaction and hope” into a retryable job, and it means you can look a tenant up with?external_id=acme-tenant-4171instead of keeping a mapping table in sync. See retrying a create safely. manualwindows for tenant caps put period close in your hands: the budget accrues until your billing cycle resets it (step 6). Use calendar windows (month, aligned UTC) instead if you bill on calendar months and want automatic resets, or setcycle_anchor_atto the moment the customer signed up so their period rolls on their own anniversary rather than the 1st. The three combine freely.- The hard and soft caps are two budgets, not one field:
blockat the real ceiling,warnbelow it. Crossing 80 percent of either emitsgateway.budget.threshold_crossed; hitting a limit emitsgateway.budget.breached. See Budgets. - The attributed-user template is one rule, “each distinct end user on this key: $25 per month”. Buckets appear lazily on first spend; there is nothing to create or delete per user. Non-uniform caps are additional templates on other anchors.
2. Register your webhook endpoint (once)
Your billing ledger is fed by webhook endpoints. Register your receiver once, org-wide, subscribed to the spend stream (and the budget signals if you want them):201 response includes the signing secret, shown once. Give it to your receiver, then use the endpoint’s test fire to prove the path before real traffic.
3. The request path
Your application calls the gateway with the tenant’s key and two attribution fields:user field (or the x-langwatch-end-user-id header, which wins over the body) is what the per-user template enforces on and what end_user_id carries on every spend event. The metadata echo is how your billing joins events back to your own records without lookups.
4. Receive, verify, ingest
Your receiver does three things, in order:- Verify the signature over the exact raw request bytes with your endpoint secret, rejecting stale timestamps. Copy the verifier from the webhooks reference or from the demo repo (
ts/src/verify-signature.ts,python/verify_signature.py). - Dedup by envelope
idand ingest. At-least-once delivery plus stable ids means an upsert keyed onidis enough; retries and replays become no-ops. - Handle the settled pair: a
gateway.request.settledevent books the request with unknown cost andneeds_reconciliation; if agateway.request.completedlater arrives for the samegateway_request_id, replace the settled row with it. Never sum the pair. Field-by-field payload docs: Billing & spend events.
cost.nano_usd as an integer, sum integers, round once at invoice time.
5. Reconcile
Nightly, or at period close, reconcile in two grains against the ledger (details: reconciliation):gateway_request_id set, since a different set can sum the same, so a biller that needs request-level certainty walks the events regardless. On divergence, walk spend-events for that key and window by cursor and compare gateway_request_id counts per id rather than set membership: one request dropped and another booked twice leaves the id sets looking close and the money wrong. The ids whose counts disagree are your drops and duplicates. If every count agrees and the totals still differ, no request is missing and the same request is priced differently on each side, so compare cost.nano_usd per id rather than its presence. Both reads take the same filters, so you can narrow the walk to exactly what the checksum covered. For a delivery gap (receiver outage, endpoint auto-disabled), replay the gap window to your endpoint, honoring your downstream biller’s dedup window.
Narrowing and grouping
Repeat a filter to widen it and name two filters to narrow. Both reads acceptproject_id, team_id, external_id, virtual_key_id, end_user_id, principal_user_id, model, provider_key, request_type, label, metadata and status. Only status=admitted differs: the summaries refuse it, because a rollup sums the cost of requests past admission and an in-flight request has none. Ask spend-events for those.
metadata is written key:value and splits on the first colon, so a value may contain colons of its own. A key may not contain a colon, and both halves must be non-empty. Repeating a key widens that key: metadata=tier:gold&metadata=tier:silver matches either.
group_by takes one or two dimensions and bucket=hour|day adds a time column in the timezone you name. key stays the first dimension’s value, so an integration written against the single-dimension response keeps working; read group to tell two dimensions apart.
6. Breach handling and period close
When a cap is hit, the request is rejected with402 and machine-readable meta:
budget_scope is the branch your product copy needs: attributed_user means “your allowance ran out” (show the end user an upgrade path), virtual_key means “your organization’s cap ran out” (route to the tenant admin). The same moment emits gateway.budget.breached to your endpoint, so your backend learns about it without polling. On warn budgets the request still passes and the response carries the X-LangWatch-Budget-Warning header instead.
At period close, reset the tenant’s manual budgets:
7. The kill switch
For non-payment or abuse, stop a tenant reversibly:virtual_key_disabled error (403) until you call /enable. Budgets, scopes, key material, and any running rotation grace stay intact, and the change propagates to the gateway in seconds through the change feed. Both transitions emit gateway.virtual_key.disabled and gateway.virtual_key.enabled events. Reserve /revoke for the terminal goodbye; see Virtual keys.
Rendering budget bars in your UI
The pair every tenant dashboard wants, current spend against the cap:- Per tenant:
GET /api/gateway/v1/budgets?scope_type=virtual_keyreturns each budget with livespent_usdandlimit_usd. - Per end user:
GET /api/gateway/v1/end-users/:id/spend(organization key) returns the user’s rolling-window usage and every applicable template cap at its current-period spend, in one call. See per-end-user spend.
Where to go deeper
- Billing & spend events: every payload field, money rules, reconciliation, replay.
- Webhooks: signatures, retries, auto-disable, health, delivery controls.
- Budgets: scopes, windows including
manual, thresholds, the breach error catalog. - Multi-tenant SaaS reseller pattern: the provisioning-side cookbook this billing loop plugs into.
- The demo agent platform: what the reference repo shows and how to run it.