The runnable reference: agent-billing-demo
An open-source agent-platform SaaS that implements every step below in both TypeScript and Python: sign-up provisions a real tenant key and real caps, chat goes through the gateway, and the meters on screen come from the signed events its own receiver ingests. The webhook receiver, ledger, reconciliation loop and provisioning are written to be copied from.
What you need
- An Enterprise plan or license. Webhook endpoints and the spend APIs are Enterprise features.
- Two credentials, because the two surfaces need different permissions. A LangWatch API key with
virtualKeys:manageandgatewayBudgets:managefor provisioning, and an organization API key withwebhookEndpoints:manageandgatewaySpend:viewfor the billing and webhook surfaces. The commands below show which one each call takes. See RBAC. - A provider credential configured once at organization scope under Settings > Model Providers. Tenants never see it.
The shape
The rest of this page uses one key per customer.
principal budgets target LangWatch member accounts, which your external customers are not, so for a reseller the per-customer unit is the virtual key itself.
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 on every budget, and send anIdempotency-Keyheader on each create. A signup that times out halfway and replays with the same key and body gets the original response back instead of a second key, andGET /api/gateway/v1/virtual-keys?external_id=acme-4171finds the tenant without a mapping table. manualwindows for tenant caps put period close in your hands: the budget accrues until your billing cycle resets it (step 6). Use a calendar window (month, aligned to UTC) if you bill on calendar months, or setcycle_anchor_atto the signup moment so the period rolls on the customer’s anniversary.- The hard and soft caps are two budgets.
blockat the real ceiling,warnbelow it. Crossing 80 percent of either emitsgateway.budget.threshold_crossed; reaching a limit emitsgateway.budget.breached. See Budgets. - The attributed-user template is one rule: each distinct end user on this key gets $25 per month. A bucket appears on the user’s first spend; you create and delete no per-user budgets.
modelsAllowedandrateLimitson the key are the only two fields ofconfiga tenant key usually needs.rateLimits.rpmandrateLimits.rpdare enforced at the gateway; a tenant over the limit gets429 rate_limited. See Rate limits.
2. Register your webhook endpoint (once)
Your billing ledger is fed by webhook endpoints. Register your receiver once for the organization, subscribed to the spend stream and the budget signals:201 response includes the signing secret, shown once. Give it to your receiver, then call POST /api/webhooks/v1/endpoints/{id}/test to prove the path before real traffic. The test delivery carries X-LangWatch-Test-Fire: true.
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.
The gateway, on every request: authenticates the key, checks the tenant’s and the end user’s budgets, applies modelsAllowed, sends the request to the provider on your credential, records the spend against both budgets, and writes a trace into the key’s trace project.
4. Receive, verify, ingest
Your receiver does three things, in order:- Verify the signature. The
X-LangWatch-Signatureheader ist=<unix seconds>,v1=<hex>; the HMAC-SHA256 is computed over<t>.<raw body>with your endpoint secret. Reject a timestamp older than 300 seconds. For 24 hours after you roll the secret, the header carries twov1=values and either one is valid. Copy the verifier from the webhooks reference or from the demo repository. - Dedup by envelope
idand ingest. Delivery is at least once and ids are stable, so an upsert keyed onidis enough; retries and replays become no-ops. Each delivery also carriesX-LangWatch-Delivery-IdandX-LangWatch-Delivery-Attempt. - Handle the settled pair. A
gateway.request.settledevent books a request whose outcome never arrived, 30 minutes after admission, withneeds_reconciliation: true. 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.
The usage object on a delivered event has eight fields: input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, reasoning_tokens, input_image_tokens, output_image_tokens and image_count. Every field is always present, and a request that did not use a bucket reports it as 0. Reasoning tokens are reported for display and are never priced separately.
The token buckets are disjoint. An image generation reports output_tokens 0 and its render under output_image_tokens, so reading output_tokens alone sees none of the image traffic. Reconcile every field, but price only the billable ones: each is charged once at its own rate, and reasoning_tokens and image_count carry no rate at all.
A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then every 12 hours, 11 attempts in total. A Retry-After header on your response is honoured as a floor.
5. Reconcile
Nightly, or at period close, reconcile in two grains against the ledger (details: Reconcile with the SDKs):gateway_request_id, because a different set can sum to the same amount, 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 the count per gateway_request_id. 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, compare cost.nano_usd per id. Both reads take the same filters, so you can narrow the walk to what the checksum covered.
For a delivery gap (receiver outage, endpoint auto-disabled), replay the gap window to your endpoint and let your dedup absorb the duplicates.
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. Repeating a key widens that key: metadata=tier:gold&metadata=tier:silver matches either.
group_by takes one or two of virtual_key, end_user, project, model, provider, principal and request_type, 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 reached, the gateway rejects the request with402 and names the budget in meta:
meta.budget_scope is the branch your product copy needs: attributed_user means the end user’s allowance ran out (show them an upgrade path), virtual_key means the tenant’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.
Test the 402 path in your app before go-live. An unhandled 402 is the most common way a tenant’s flow breaks on the day a cap is reached.
At period close, reset the tenant’s manual budgets:
reason is written to the audit log with the reset.
To change a tenant’s cap when their plan changes, PATCH /api/gateway/v1/budgets/{id} with the new limit_usd. To raise the per-user allowance, patch the template; every bucket follows.
7. Suspend, rotate, revoke
Non-payment or abuse. Stop a tenant reversibly:403 virtual_key_disabled until you call /enable. Budgets, scopes, key material and any running rotation grace stay intact. The gateway picks the change up on its next change-feed poll, a long poll of about 10 seconds. Both transitions emit gateway.virtual_key.disabled and gateway.virtual_key.enabled events.
The customer resets their API key in your UI. POST /api/gateway/v1/virtual-keys/{id}/rotate returns a new secret; send it to the customer once. The old secret keeps authenticating for 24 hours, so a client you have not reached yet does not fail mid-rollout. The event is gateway.virtual_key.rotated.
The customer is gone for good. POST /api/gateway/v1/virtual-keys/{id}/revoke. The next request returns 403 virtual_key_revoked. Revoke is one-way; for a cancellation grace period, disable now and revoke after the grace.
Audit. Every write through your backend credential is recorded with the action, the target and the before and after values, attributed to the key’s owning member. Filter the audit log on the virtual_key target for a per-customer provisioning history. See Audit.
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_key&external_id=acme-4171returns each budget with livespent_usdandlimit_usd.spend_available: falsemeans spend could not be totalled server-side; hold billing until it recovers rather than invoicing a zero. - Per tenant, one number:
GET /api/gateway/v1/virtual-keys/{id}/spendreturns{virtual_key_id, spent_usd, requests, window: {from, to}}for the current UTC month by default, or thefromandtoyou pass. The same value is shown on the key in the Virtual Keys page, so what you invoice matches what you see. - 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.
rate_version so cost is re-derivable.
Gotchas
- Never let a tenant see your backend API key. It holds
virtualKeys:manage; they could provision keys charged to you. - Name keys after your tenant id (
customer-<id>) and setexternal_id. Those are your join keys for spend read-back and audit history;principal_user_idis only for LangWatch members. - Rotate the key when an end user leaves the tenant’s organization. Until then the ex-user keeps spend access until the budget resets.
- Budgets scoped to
virtual_keyare the right level for per-customer enforcement;principalandteamscopes are for members and teams of your own LangWatch organization.