Skip to main content
Webhook endpoints stream LangWatch events to your systems as they happen. You register a URL, pick the event types you want, and LangWatch delivers signed batches with at-least-once semantics, a multi-day retry ladder, a per-attempt delivery log, and a health view whose headline number is how stale your feed could be. The first consumers are billing integrations: platforms that rebill their own customers ingest spend events through a webhook endpoint. The platform itself is general: every event family delivers through the same endpoints, envelopes, signatures, and retries.
Webhook endpoints are an Enterprise feature (the webhookEndpointsEnabled plan flag). On other plans the API answers 403 and the settings page shows an upgrade state. Self-hosted enterprise licenses include it.

The model

An endpoint belongs to your organization and carries: Manage endpoints under AI Gateway > Webhooks (/settings/gateway/webhooks) or over REST with an organization API key:
Create an endpoint subscribed to the billing stream:
The 201 response carries the endpoint plus secret, returned only this once. Store it where your receiver can verify signatures.
The webhookEndpoints and gatewaySpend permissions are organization-exclusive: only an ORGANIZATION-scoped role binding can grant them, never a team- or project-scoped one. Endpoints carry signing secrets and stream org-wide events out of the platform, so by default only org admins hold them. See RBAC.

The envelope

Every delivery is one POST with a JSON body of the form {"batch": [envelope, ...]}. Each envelope is one real-world occurrence:
  • id is the idempotency key: stable across retries and replays. Dedup on it. For spend events it is the gateway request id with a type suffix (<gateway_request_id>:completed, <gateway_request_id>:settled), so the settled and completed events for one request never collide in your dedup layer while data.gateway_request_id joins them.
  • created is when the event occurred, never when it was delivered. Late deliveries land in the right period.
  • schema_version versions the data payload per type.
Batches contain up to max_batch_size envelopes, possibly of mixed types within your subscription. Ordering is best-effort: rely on created and your own dedup, not arrival order.

Event catalog

GET /api/webhooks/v1/event-types returns the live catalog. Types are grouped by family (the first dotted segment), which is also what the settings UI renders as checkbox groups and what the gateway.* wildcard matches.

Family: gateway

The spend payloads (gateway.request.*) are documented field by field on Billing and spend events. The governance payloads: gateway.budget.threshold_crossed and gateway.budget.breached (data):
The envelope id embeds the budget, the bucket, the kind, and the period start, which is what makes “once per crossing per period” hold end to end: a re-crossing in the same period dedups away, a crossing in the next period is a new event. For attributed-user budgets, bucket_scope_id is <anchor_id>:<end_user_id> and end_user_id is set, so your platform can tell “this user’s cap” from “the tenant cap”. virtual_key_id and anchor_project_id name the key and the project the budget targets, as their own fields. virtual_key_id is set for a virtual-key budget and for an attributed-user template (where it is the anchor); anchor_project_id is set for a project-scoped budget. Read those rather than splitting bucket_scope_id, which you cannot split reliably when an end-user id contains a colon.
Every enum on these payloads is lower_snake_case (scope_type, window, on_breach), matching the rest of the wire.
gateway.virtual_key.* (data):

Verifying signatures

Every delivery carries:
X-LangWatch-Delivery-Id identifies one delivery, which carries a batch of many events. It is not an event id and must not be used for deduplication: every event in the batch shares it, so deduping on it drops all but one of them.Deduplicate on the envelope id inside the body, which is stable across retries and replays and unique per event.
Verify by recomputing the HMAC over the exact raw bytes you received (parsing and re-serializing the JSON first breaks the digest), and reject timestamps outside a 5-minute tolerance. Compare digests in constant time.
v1 may appear more than once. During a secret rotation the header carries one v1 per currently valid secret, newest first:
Your verifier must accept the delivery when any v1 matches. A verifier that reads only the first v1 (or that treats the header as holding exactly one) rejects every delivery signed during a rotation window. Both snippets below collect all of them.
Both snippets, wired into working receivers, live in the agent-billing-demo reference repo.

Rotating the signing secret

roll-secret returns a new secret and keeps the old one valid for 24 hours. For that window every delivery is signed with both, so there is no coordinated deploy and no gap:
  1. Call roll-secret and store the new value.
  2. Deploy it to your receiver any time in the next 24 hours. Deliveries keep verifying under the old secret until you do.
  3. After the window the old secret stops being signed with and stops verifying.
Rolling twice inside a window discards the secret that was already rolling off, so the oldest one stops working immediately. That is what you want when you are rolling because a secret leaked.

Delivery, retries, and auto-disable

Your receiver acks with any 2xx. The delivery classifier:
  • 5xx, 429, and 408 are retryable. A Retry-After header on those is honored as a floor on the next attempt.
  • Any other non-2xx status is terminal for that batch: retrying a misconfigured endpoint just spams it. Redirects are never followed, so a 3xx is terminal too.
Retries follow a multi-day ladder: 1m, 5m, 30m, 2h, 6h, 12h, then every 12h, 11 attempts in total, keeping the last retry inside 72 hours of the first failure. Batches retry independently per endpoint, so one dead endpoint never delays another. After 72 hours of unbroken failures the endpoint flips to disabled with disabled_reason: "auto_failures_72h" and delivery stops. Sources keep accruing the whole time: disabling delivery never loses events. To recover:
  1. Fix the receiver.
  2. Re-enable: PATCH /endpoints/:id {"status": "active"}.
  3. Re-enabling does not re-send the gap. Replay it explicitly: for spend events, POST /api/gateway/v1/spend-events/replay with the gap window and this endpoint’s id.

Delivery controls

Three per-endpoint knobs, validated server-side against fixed bounds; out-of-range values are rejected with the bound named in the error:

Health and the lag number

GET /endpoints/:id/health:
The headline is oldest_undelivered_age_ms: the age of the oldest envelope still buffered or retrying for this endpoint. It is your feed’s staleness, the number that tells a billing operator how far behind their invoice data could be. Zero or small is healthy; growing means your receiver is failing or slow. sends_per_minute and success_rate aggregate the last hour; p95_latency_ms is sampled over the same window. dlq_depth counts dead-lettered batches awaiting manual attention.

The delivery log

GET /endpoints/:id/deliveries lists recent attempts, newest first: the attempt number, envelope count, outcome, your endpoint’s HTTP status, latency, and the error text when transport failed. Your receiver’s responses are recorded, so debugging a rejecting endpoint starts here rather than in your own logs. The list is cursor-paginated on (fired_at, id) at 25 rows by default (max 200), and the response carries next_cursor when more remain. Cursors rather than offsets because a busy endpoint writes new attempts while you page: an offset walk would show you the same row twice and skip another. Delivery log rows are retained for 30 days.

Test fire

POST /endpoints/:id/test sends one signed test.ping envelope through the full delivery path, including SSRF checks and signing, plus an X-LangWatch-Test-Fire: true header so your receiver can tell it apart. The route answers 200 whenever the test itself ran; read data.delivered and data.response_status for what your receiver did. Test fires appear in the delivery log too.

The events log

GET /api/webhooks/v1/events?type=&from=&to=&cursor=&limit= is the organization’s emitted-events log: cursor-paged, newest first, filterable by type and created range (unix milliseconds). Webhooks are push over this log, never the only copy of it, so a consumer that missed deliveries can list what was emitted and recover. GET /api/webhooks/v1/events/{id} reads one event back by the id its envelope carried. A 404 means the log cannot answer for that id, and deliberately does not say which reason applies.

Which families the log holds

The log serves the request families only: The governance families are delivered to your endpoints exactly like the request families, but they are not retained in a queryable log, so they cannot be listed, read by id, or replayed. If you need a durable record of budget and virtual-key events, persist them from your receiver when they arrive. A request for a type this log does not hold returns an empty page rather than an error, so a client can probe for new families without breaking. For billing reconciliation, do not reconcile against this log: read the spend ledger surfaces instead (spend-summaries and spend-events), which include settled rows and carry the 13-month retention contract. The events log is for delivery recovery and debugging.

What events never carry

No prompts, no completions, no conversation content, ever. Envelopes carry ids, quantities, classes, states, and your own metadata echo. If you need content downstream, that is the traces API, a separate surface with its own access control.

See also