Skip to main content
A platform whose customers consume LLMs through it can meter and rebill that usage. This cookbook wires that end to end on LangWatch: one virtual key per tenant, caps for the tenant and for each of their end users, signed spend events into your billing ledger, reconciliation, breach handling, and period close. Your product keeps zero metering code of its own.

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.
The vocabulary used throughout: a tenant is your customer (one virtual key each); an end user is a person inside a tenant (no provisioning, attributed per request); a spend event is the metering record; an envelope is how events arrive at your webhook receiver.

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.
Choices worth making deliberately:
  • Stamp your own id and retry safely. Put your customer id in external_id on the key and every budget, and send an Idempotency-Key on 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, and external_id lets 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-4171 instead of keeping a mapping table in sync. See retrying a create safely.
  • manual windows 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 set cycle_anchor_at to 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: block at the real ceiling, warn below it. Crossing 80 percent of either emits gateway.budget.threshold_crossed; hitting a limit emits gateway.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):
The 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:
That is the whole integration on the request side. The 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.
With an attributed-user template active, a request that carries no end-user id is rejected with error.code = "end_user_required" rather than passing uncapped. A cap evadable by omitting a field is not a cap. Send the id from day one.

4. Receive, verify, ingest

Your receiver does three things, in order:
  1. 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).
  2. Dedup by envelope id and ingest. At-least-once delivery plus stable ids means an upsert keyed on id is enough; retries and replays become no-ops.
  3. Handle the settled pair: a gateway.request.settled event books the request with unknown cost and needs_reconciliation; if a gateway.request.completed later arrives for the same gateway_request_id, replace the settled row with it. Never sum the pair. Field-by-field payload docs: Billing & spend events.
Money discipline: store 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):
Matching totals mean no aggregate divergence. They are not proof that both systems hold the same 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 accept project_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.
Grouping by model or provider, or into time buckets, is refused with gateway_spend_group_by_unstable on a window recent enough that outcomes can still arrive. Until a request settles, the model and provider recorded against it are the ones that were asked for, and they are replaced by the ones that actually served it. A page walk over a group that can move counts some requests twice and misses others, which is the one failure a reconciliation cannot detect on its own.Reconcile closed periods and this never fires. For a live dashboard where an approximate shape is enough, send allow_unstable=true. The refusal carries meta.settles_at, so a scheduler can wait rather than guess.

6. Breach handling and period close

When a cap is hit, the request is rejected with 402 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:
Reset moves the period boundary and recomputes the next reset. It never mutates recorded spend: the ledger and every emitted event are immutable, so reconciliation is unaffected by resets. On calendar windows a mid-period reset truncates the running period and the next boundary stays calendar.

7. The kill switch

For non-payment or abuse, stop a tenant reversibly:
Requests on the key are rejected with the distinct 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_key returns each budget with live spent_usd and limit_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