> ## Documentation Index
> Fetch the complete documentation index at: https://langwatch.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Signed, retried, replayable event delivery to your endpoints, with per-endpoint subscriptions, a delivery log, health, and automatic disabling of dead receivers.

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](/docs/ai-gateway/billing-events) through a webhook endpoint. The platform itself is general: every event family delivers through the same endpoints, envelopes, signatures, and retries.

<Note>
  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.
</Note>

## The model

An endpoint belongs to your **organization** and carries:

| Field              | Meaning                                                                                                                                                                          |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `destination_kind` | Where batches go: `http` (the default) or `sqs`. See [Delivering to an Amazon SQS queue](#delivering-to-an-amazon-sqs-queue). Set at creation and fixed for the endpoint's life. |
| `url`              | Where batches are POSTed, on an `http` endpoint. Null on the others. See [what a receiver URL must look like](#what-a-receiver-url-must-look-like).                              |
| `sqs`              | The queue, on an `sqs` endpoint. Null on the others.                                                                                                                             |
| `enabled_events`   | The subscription: exact types (`gateway.request.completed`), a family wildcard (`gateway.*`), or `*` for everything. An endpoint receives only what it subscribed to.            |
| `status`           | `active` or `disabled`. Disabled endpoints receive nothing; sources keep accruing.                                                                                               |
| Signing secret     | Shown **once** at creation. Roll it to get a new one. Every delivery is signed with it.                                                                                          |
| Delivery controls  | `max_batch_size`, `max_batch_delay_ms`, `max_in_flight` (bounds below).                                                                                                          |

Manage endpoints under **AI Gateway > Webhooks** (`/settings/gateway/webhooks`) or over REST with an organization API key:

```
POST   /api/webhooks/v1/endpoints                    webhookEndpoints:manage
GET    /api/webhooks/v1/endpoints                    webhookEndpoints:view
GET    /api/webhooks/v1/endpoints/:id                webhookEndpoints:view
PATCH  /api/webhooks/v1/endpoints/:id                webhookEndpoints:manage
DELETE /api/webhooks/v1/endpoints/:id                webhookEndpoints:manage   (archive)
POST   /api/webhooks/v1/endpoints/:id/roll-secret    webhookEndpoints:manage
POST   /api/webhooks/v1/endpoints/:id/test           webhookEndpoints:manage
GET    /api/webhooks/v1/endpoints/:id/deliveries     webhookEndpoints:view
GET    /api/webhooks/v1/endpoints/:id/health         webhookEndpoints:view
GET    /api/webhooks/v1/event-types                  webhookEndpoints:view
GET    /api/webhooks/v1/events                       webhookEndpoints:view
GET    /api/webhooks/v1/events/:id                   webhookEndpoints:view
```

Create an endpoint subscribed to the billing stream:

```bash theme={null}
curl -sS https://app.langwatch.ai/api/webhooks/v1/endpoints \
  -H "Authorization: Bearer $LANGWATCH_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://billing.example.com/webhooks/langwatch",
    "enabled_events": ["gateway.request.completed", "gateway.request.settled"]
  }'
```

The `201` response carries the endpoint plus `secret`, returned only this once. Store it where your receiver can verify signatures. Send an [`Idempotency-Key`](/docs/ai-gateway/api/management#retrying-a-create-safely) and a retry that lost its response returns the original secret instead of stranding it.

### What a receiver URL must look like

A webhook URL is a destination our workers dial on your behalf, so it is held to one policy across the product:

| Rule                      | Why                                                                                                                              |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `https` only              | The batch carries your data and your signature; plaintext exposes both.                                                          |
| The default port, 443     | A URL pointing at another port is how an outbound webhook gets used to probe internal services.                                  |
| No credentials in the URL | `https://user:pass@host/...` puts a secret in every log line that records the destination. Send credentials in a header instead. |
| A public host             | Private, loopback, and link-local addresses are refused, so an endpoint cannot reach inside our network.                         |

Anything else is refused at create and update time with `400` and `error.code = "webhook_endpoint_invalid"`, naming the rule it broke:

```json theme={null}
{ "error": { "type": "bad_request", "code": "webhook_endpoint_invalid",
             "message": "url must use the default https port (443)" } }
```

<Warning>
  The port and credential rules apply to endpoints you already have. If an existing endpoint uses a non-default port or embeds credentials in its URL, deliveries keep flowing, but the next update to that endpoint is refused until the URL is fixed. Move the port behind a proxy on 443, and move credentials into a custom header.
</Warning>

Self-hosted installs whose receivers live on internal hosts can relax the scheme, port, and private-address rules with a single operator setting; see [self-hosted egress](/docs/self-hosting/webhooks). Nothing relaxes the credential rule, and redirects are never followed on any deployment.

<Warning>
  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](/docs/ai-gateway/rbac).
</Warning>

## The envelope

Every delivery is one POST with a JSON body of the form `{"batch": [envelope, ...]}`. Each envelope is one real-world occurrence:

```json theme={null}
{
  "id": "01K1D3H8ZQ4M9X2C7V5B1N6P8T:completed",
  "type": "gateway.request.completed",
  "created": "2026-07-27T14:03:11.482Z",
  "schema_version": "1",
  "data": { /* typed payload for this event type */ }
}
```

* `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`

| Type                               | When it fires                                                                                                                                                                                       |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gateway.request.completed`        | One event per gateway request that finished, success or provider error. The money stream: token classes, rated cost, attribution.                                                                   |
| `gateway.request.settled`          | A request that was admitted but whose confirmation never arrived inside the settlement window. Quantities and cost are `null`. A later `completed` for the same `gateway_request_id` supersedes it. |
| `gateway.budget.threshold_crossed` | A budget crossed its 80 percent warn threshold inside the current period. Once per crossing per period.                                                                                             |
| `gateway.budget.breached`          | A budget reached its cap. Fires on `warn` budgets too; only `block` budgets also reject requests. Once per crossing per period.                                                                     |
| `gateway.virtual_key.created`      | A virtual key was created.                                                                                                                                                                          |
| `gateway.virtual_key.rotated`      | A virtual key's secret was rotated. The previous secret keeps working for its grace window, so a provisioning backend can mirror the change before the old one stops.                               |
| `gateway.virtual_key.disabled`     | A virtual key was disabled (reversible).                                                                                                                                                            |
| `gateway.virtual_key.enabled`      | A previously disabled virtual key was re-enabled.                                                                                                                                                   |
| `gateway.virtual_key.revoked`      | A virtual key was revoked (terminal).                                                                                                                                                               |

The spend payloads (`gateway.request.*`) are documented field by field on [Billing and spend events](/docs/ai-gateway/billing-events). The governance payloads:

**`gateway.budget.threshold_crossed` and `gateway.budget.breached`** (`data`):

```json theme={null}
{
  "event_id": "bgt_01H...:vk_01H...:breached:1753574400000",
  "event_type": "gateway.budget.breached",
  "organization_id": "org_01H...",
  "budget_id": "bgt_01H...",
  "scope_type": "virtual_key",
  "bucket_scope_id": "vk_01H...",
  "virtual_key_id": "vk_01H...",
  "anchor_project_id": null,
  "end_user_id": null,
  "window": "month",
  "period_started_at": "2026-07-01T00:00:00.000Z",
  "limit_usd": "25",
  "spent_usd": "25.1042",
  "on_breach": "block",
  "occurred_at": "2026-07-27T14:03:11.482Z"
}
```

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.

<Note>
  Every enum on these payloads is `lower_snake_case` (`scope_type`, `window`, `on_breach`), matching the rest of the wire.
</Note>

**`gateway.virtual_key.*`** (`data`):

```json theme={null}
{
  "event_id": "vk_01H...:disabled:1753574591482",
  "event_type": "gateway.virtual_key.disabled",
  "organization_id": "org_01H...",
  "virtual_key_id": "vk_01H...",
  "name": "customer-acme",
  "display_prefix": "vk-lw-01HZX9",
  "reason": "payment overdue",
  "occurred_at": "2026-07-27T14:03:11.482Z"
}
```

## Verifying signatures

Every delivery carries:

```
Content-Type: application/json
X-LangWatch-Delivery-Id: <delivery id, stable across retries of this batch>
X-LangWatch-Signature: t=<unix seconds>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>
X-LangWatch-Delivery-Attempt: <n, 1-based>
```

Both SDKs ship a verifier, so you do not have to get the digest, the timestamp tolerance, the constant-time compare, and the rotation window right yourself. Pass the **raw bytes** you received: parsing and re-serializing the JSON first breaks the digest.

<CodeGroup>
  ```typescript verify.ts theme={null}
  import {
    verifyWebhookSignature,
    WebhookSignatureVerificationError,
  } from "langwatch";

  // Pass both secrets while a rotation is in flight: the header carries one
  // v1 per valid secret, and any match accepts the delivery.
  const secrets = [
    process.env.LANGWATCH_WEBHOOK_SECRET!,
    process.env.LANGWATCH_WEBHOOK_SECRET_PREVIOUS,
  ].filter(Boolean) as string[];

  export function handleDelivery(rawBody: Buffer, signatureHeader: string) {
    try {
      verifyWebhookSignature({
        body: rawBody,
        header: signatureHeader,
        secret: secrets,
      });
    } catch (error) {
      if (error instanceof WebhookSignatureVerificationError) {
        // error.code is "malformed_header", "stale_timestamp",
        // or "invalid_signature". Reject, do not retry.
        return { status: 400, reason: error.code };
      }
      throw error;
    }
    return { status: 200, batch: JSON.parse(rawBody.toString("utf8")).batch };
  }
  ```

  ```python verify.py theme={null}
  import json
  import os

  from langwatch import (
      WebhookSignatureVerificationError,
      verify_webhook_signature,
  )

  # Pass both secrets while a rotation is in flight: the header carries one
  # v1 per valid secret, and any match accepts the delivery.
  secrets = [
      s
      for s in (
          os.environ["LANGWATCH_WEBHOOK_SECRET"],
          os.environ.get("LANGWATCH_WEBHOOK_SECRET_PREVIOUS"),
      )
      if s
  ]


  def handle_delivery(raw_body: bytes, signature_header: str):
      try:
          verify_webhook_signature(
              body=raw_body,
              header=signature_header,
              secret=secrets,
          )
      except WebhookSignatureVerificationError as error:
          # error.code is "malformed_header", "stale_timestamp",
          # or "invalid_signature". Reject, do not retry.
          return 400, error.code
      return 200, json.loads(raw_body)["batch"]
  ```
</CodeGroup>

The verifier throws (Python raises) rather than returning a boolean, so a receiver cannot accept a delivery by ignoring a return value. Branch on `.code` when you want to tell a clock-skew problem from a wrong secret. Timestamps outside a 5-minute tolerance are rejected; pass `toleranceSeconds` / `tolerance_seconds` if your receiver needs a different window.

<Warning>
  `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.
</Warning>

Working receivers built on both snippets live in the [agent-billing-demo](https://github.com/langwatch/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.

## Signing automation webhooks

An [automation trigger](/docs/features/automations) can also call a webhook when it fires. Those requests go out unsigned unless you give the trigger a signing secret, in the webhook action's **Signing secret** field.

Set one and every fire from that trigger carries the same `X-LangWatch-Signature: t=...,v1=...` header, computed the same way, so the verifier above validates it without a single change. The trigger's own id header is `X-LangWatch-Event-Id` rather than `X-LangWatch-Delivery-Id`, and it groups the attempts of one fire.

* **Opt-in per trigger.** A trigger with no secret keeps sending exactly what it sent before, so adding this breaks no existing receiver.
* **Rotation works the same way.** Replacing the secret keeps the previous one signing for 24 hours, so you deploy the new value on your own schedule. During the window the header carries a `v1` for each, which is why a verifier has to accept any match.
* Secrets are encrypted at rest, and, like an endpoint's, are not readable again after you save them.

## 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`](/docs/ai-gateway/billing-events#replay) with the gap window and this endpoint's id.

```mermaid theme={null}
stateDiagram-v2
    [*] --> Pending: batch queued for endpoint
    Pending --> Delivered: 2xx
    Pending --> Retrying: 5xx / 429 / 408 / timeout
    Retrying --> Delivered: 2xx on a later attempt
    Retrying --> DeadLettered: 11 attempts exhausted
    Retrying --> Disabled: 72h of consecutive failures
    Disabled --> Pending: re-enable, then replay the gap
```

## Delivering to an Amazon SQS queue

An endpoint can put each batch on your own Amazon SQS queue instead of POSTing it to a URL. Only the last hop changes. The batching, the retry ladder, the delivery log, and the signature over the same bytes all work as they do for an HTTPS endpoint.

<Warning>
  **`ReceiveMessage` returns no message attributes unless you ask for them by name.** Pass `MessageAttributeNames: ["All"]`. Without it your consumer sees a body with no signature beside it and rejects every delivery, even though the body itself is correct. Check this first when a receiver that worked over HTTPS rejects everything on the queue.
</Warning>

**The body is byte-identical to the HTTP body.** `MessageBody` is the exact same `{"batch": [...]}` JSON an HTTPS receiver would be POSTed. There is no outer wrapper. The signature, the delivery id and the attempt ride as **message attributes** under the same names they use as HTTP headers:

| Message attribute              | Same as the HTTP header                                                  |
| ------------------------------ | ------------------------------------------------------------------------ |
| `X-LangWatch-Signature`        | `t=<unix>,v1=<hmac>`, one `v1` per valid secret                          |
| `X-LangWatch-Delivery-Id`      | Groups the retries of one delivery; never the dedup key                  |
| `X-LangWatch-Delivery-Attempt` | 1-based attempt number                                                   |
| `X-LangWatch-Test-Fire`        | `"true"`, and present only on a test fire. Absent on every real delivery |

So verification is the same call over the same bytes, and moving an integration from HTTP to a queue does not change your verification code.

```bash theme={null}
curl -X POST https://app.langwatch.ai/api/webhooks/v1/endpoints \
  -H "Authorization: Bearer $LANGWATCH_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination_kind": "sqs",
    "sqs": {
      "queue_url": "https://sqs.eu-central-1.amazonaws.com/123456789012/billing-events",
      "role_arn": "arn:aws:iam::123456789012:role/langwatch-webhook-producer"
    },
    "enabled_events": ["gateway.request.completed"]
  }'
```

**Granting access.** Three modes, in the order to prefer them:

| Mode          | How                                                                                                                                                                    | When                                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `assume_role` | Give `sqs.role_arn`. The response carries a generated `external_id`; put it in that role's trust policy as `sts:ExternalId`, and allow `sqs:SendMessage` on the queue. | The recommended mode. Nothing long-lived is stored, and you revoke us by editing your own trust policy. |
| `static`      | Give `sqs.access_key_id` and `sqs.secret_access_key`. The secret is encrypted at rest and never returned by any read.                                                  | When you cannot create a cross-account role.                                                            |
| `ambient`     | Give neither, so LangWatch's own AWS identity writes to the queue. Refused unless the operator set `WEBHOOKS_UNSAFE_ALLOW_AMBIENT_CREDENTIALS=1`.                      | Self-hosted, single-tenant installs only.                                                               |

The trust policy on your role, with the `external_id` the create response returned:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "<the LangWatch principal for your deployment>" },
    "Action": "sts:AssumeRole",
    "Condition": { "StringEquals": { "sts:ExternalId": "lw-<the external id from the response>" } }
  }]
}
```

<Note>
  On LangWatch Cloud, ask support for the principal to trust; it is a property of the deployment, not of your endpoint. On a self-hosted install it is whatever identity your workers run as.
</Note>

**Rules the queue itself must satisfy:**

* **Standard queues only.** A `.fifo` queue is refused at save time. Delivery is at-least-once and consumers deduplicate on the envelope `id`, which is what a standard queue asks of them; ordering is not part of the delivery contract, and a FIFO queue would add a throughput ceiling for a guarantee this path does not need.
* **A canonical Amazon SQS queue URL**, `https://sqs.<region>.amazonaws.com/<account id>/<queue name>`. The region and the owning account are read from it, so they can never disagree with the queue. Anything else is refused.
* **One batch is one message, and LangWatch caps it at 256 KiB** including the attributes. Amazon SQS itself accepts up to 1 MiB; the lower cap is ours, and it keeps one slow consumer from having to hold a megabyte in memory per message. A batch over the cap fails terminally and says so; lower `max_batch_size` so each delivery carries fewer events.

**Deduplicate on the envelope `id`.** A standard queue is at-least-once and can redeliver a message on its own, and it has no queue-level deduplication of its own to lean on. `X-LangWatch-Delivery-Id` names the batch delivery, not an event, so deduplicating on it would drop every envelope in the batch but one. The `id` inside each envelope is the idempotency key.

**Asking for a retry.** Delete the message only after the batch is durably ingested. Leaving it alone is the retry: it reappears after the visibility timeout, and after your queue's `maxReceiveCount` it lands in your dead letter queue. Set a redrive policy, so a message your consumer can never accept lands somewhere you can inspect it.

**Changing where an endpoint delivers** is not an update. `destination_kind` is fixed once created, because batches already planned against the old transport are in flight. Create a second endpoint, let the first drain, then archive it.

## 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:

| Control              | Bounds     | What it does                                                                                                                         |
| -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `max_batch_size`     | 1 to 100   | Ceiling on envelopes per POST. 100 is the wire contract's batch cap.                                                                 |
| `max_batch_delay_ms` | 0 to 60000 | How long to coalesce before flushing a partial batch. It is added feed lag; past a minute, scale your receiver instead of buffering. |
| `max_in_flight`      | 1 to 8     | Parallel POSTs to your endpoint. Your receiver must tolerate concurrent deliveries either way.                                       |

## Health and the lag number

`GET /endpoints/:id/health`:

```json theme={null}
{
  "data": {
    "status": "active",
    "disabled_reason": null,
    "failing_since": null,
    "last_success_at": "2026-07-27T14:02:58.000Z",
    "last_failure_at": null,
    "oldest_undelivered_age_ms": 1840,
    "dlq_depth": 0,
    "sends_per_minute": 12.4,
    "success_rate": 1,
    "p95_latency_ms": 220
  }
}
```

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.

Endpoint deliveries and the [webhooks fired by your automation triggers](#signing-automation-webhooks) share one log, each row tagged with which of the two sent it. This endpoint's list shows only its own rows; the trigger drawer shows a trigger's. Nothing about our request is stored, only the outcome: no URL, no headers, no body, plus your receiver's status, the latency, and a truncated copy of a failure response.

## 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

<Warning>
  **Do not reconcile billing against this log.** It holds only the request families, it is a delivery-recovery aid rather than the money record, and it carries no retention contract. Reconcile against the spend ledger: [`spend-summaries` and `spend-events`](/docs/ai-gateway/billing-events#reconciliation-two-grains-one-ledger), which include settled rows and keep 13 months.
</Warning>

`GET /api/webhooks/v1/events?from=&to=&type=&cursor=&limit=` is the organization's emitted-events log: cursor-paged, newest first, filterable by type. 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.

`from` and `to` bound the `created` range in unix milliseconds and are **required**, and `from` must be less than or equal to `to`. The log reads the same 13-month spend table the reconciliation pull reads, so an unbounded listing sorts every month the organization has, cold storage included, to serve one page. A read with no range, with only one of the two bounds, or with a range that ends before it starts is rejected with the canonical `400` this surface answers every validation failure with, naming the offending parameter.

`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:

| Type                        | In the events log |
| --------------------------- | ----------------- |
| `gateway.request.completed` | Yes               |
| `gateway.request.settled`   | Yes               |
| `gateway.budget.*`          | **No**            |
| `gateway.virtual_key.*`     | **No**            |

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.

## 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

* [Billing and spend events](/docs/ai-gateway/billing-events): the spend payloads, the money contract, reconciliation, replay.
* [Metering and rebilling your customers](/docs/ai-gateway/cookbooks/metering-and-rebilling): the end-to-end integration cookbook.
* [Self-hosting webhooks](/docs/self-hosting/webhooks): egress policy, local receivers, license flag.
* [Budgets](/docs/ai-gateway/budgets): what threshold\_crossed and breached mean and when they fire.
