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

# Webhook Event Payloads

> Anatomy of an Orbit webhook delivery — envelope, headers, captured request/response metadata

# Webhook Event Payloads

This page documents the wire-level shape of an Orbit webhook delivery — the request envelope, the headers Orbit sends, and the metadata Orbit captures about every delivery attempt (including the request headers sent and the response headers received from your endpoint).

For the full catalog of event types (`message.delivered`, `call.completed`, …) see [Webhook Events](/webhooks/events). For HMAC signature verification see [Webhook Security](/webhooks/security).

***

## Request envelope

Every delivery is a single HTTP `POST` with a JSON body that follows the canonical envelope:

```json theme={null}
{
  "id": "evt_3f1c8b2a9d4e5f7a8b6c1d2e3f4a5b6c",
  "type": "message.delivered",
  "created_at": "2026-05-24T12:00:00Z",
  "data": {
    "message_id": "msg_xyz789",
    "channel": "sms",
    "timestamp": "2026-05-24T12:00:00Z",
    "status": "delivered",
    "state_class": "terminal",
    "is_terminal": true
  }
}
```

<Note>
  The `data` shape above is a successful delivery. Terminal-failure payloads
  (`message.failed`, which also covers the `expired` / `submitted_no_receipt`
  transitions) additionally carry optional `error_code` and `error_message`
  strings when the message has a provider-supplied failure reason — the raw
  code and text the carrier / channel returned. They are absent (not `null`)
  when no provider error was captured, and successful transitions clear them.
  See the [webhook events reference](/reference/webhook-events) for the full
  failure payload shape.
</Note>

| Field        | Type   | Notes                                                                                                                                                                    |
| ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`         | string | Stable event id. Use this for **idempotency / dedup** — at-least-once delivery means the same event may arrive twice (e.g. when your endpoint timed out the first send). |
| `type`       | string | Event-type name from the [event catalog](/webhooks/events).                                                                                                              |
| `created_at` | string | ISO-8601 timestamp when the event was emitted on Orbit's side.                                                                                                           |
| `data`       | object | Event-specific payload. The exact shape per event type is documented in the [webhook events reference](/reference/webhook-events).                                       |

***

## Request headers

Orbit sends every delivery with the following headers:

| Header                   | Value                          | Notes                                                                                                                                                                                             |
| ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`           | `application/json`             | No `charset` parameter is sent.                                                                                                                                                                   |
| `User-Agent`             | `Devotel-Webhook/1.0`          | Static (note: **singular** `Webhook`). Use this for ACL allowlisting if your receiver gates by UA.                                                                                                |
| `X-Devotel-Signature`    | `t=<unix>,v1=<hex>[,v1=<hex>]` | HMAC-SHA256 (legacy back-compat header; carries dual `v1=new,v1=old` during a key-rotation grace window). See [Webhook Security](/webhooks/security).                                             |
| `X-Devotel-Timestamp`    | `<unix seconds>`               | Signing timestamp — the same `t=` value embedded in `X-Devotel-Signature`.                                                                                                                        |
| `X-Devotel-Event`        | `<event type>`                 | Mirrors `body.type`.                                                                                                                                                                              |
| `X-Devotel-Delivery`     | `<event id>`                   | Mirrors `body.id`. Some receivers (e.g. Cloudflare Workers) prefer to deduplicate from a header.                                                                                                  |
| `Idempotency-Key`        | `<event id>`                   | Equals `body.id`. Stable across **every retry** of the same event — use it for safe dedup when Orbit retries after an ambiguous outcome (our socket timed out but your endpoint already `2xx`'d). |
| `X-Devotel-Attempt`      | `<0-based counter>`            | `0` on the first delivery, incremented by one on each retry.                                                                                                                                      |
| `X-Orbit-Signature`      | `t=<unix>,v1=<hex>`            | Canonical signature (single matched key). Present whenever a canonical signature is available. See [Webhook Security](/webhooks/security).                                                        |
| `X-Orbit-Signature-Next` | `t=<unix>,v1=<hex>`            | Rotation-candidate signature. Sent **only during a key-rotation grace window**, so verifiers that want to discriminate which key matched can do so cleanly.                                       |

***

## Inspecting delivery attempts

Orbit persists metadata about every delivery attempt (request headers sent, response status, response body, response headers received). Operators can inspect these via the dashboard or the API:

<Note>
  `GET /api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}`
</Note>

**Authentication:** Clerk session or API key with `webhooks:read` scope.

**200 OK**

```json theme={null}
{
  "data": {
    "id": "wdl_3f1c8b2a9d4e5f7a8b6c",
    "endpoint_id": "wh_abc123",
    "event_type": "message.delivered",
    "event_id": "evt_xyz789",
    "status": "delivered",
    "http_code": 200,
    "latency_ms": 142,
    "attempts": 1,
    "next_retry_at": null,
    "last_response_status": 200,
    "last_response_body": "{\"ok\":true}",
    "request_headers": {
      "Content-Type": "application/json",
      "User-Agent": "Devotel-Webhook/1.0",
      "X-Devotel-Signature": "t=1715357600,v1=4f9c2e6b...",
      "X-Devotel-Timestamp": "1715357600",
      "X-Devotel-Event": "message.delivered",
      "X-Devotel-Delivery": "evt_xyz789",
      "Idempotency-Key": "evt_xyz789",
      "X-Devotel-Attempt": "0",
      "X-Orbit-Signature": "t=1715357600,v1=4f9c2e6b...",
      "Authorization": "<redacted>"
    },
    "response_headers": {
      "Content-Type": "application/json",
      "X-Request-Id": "your-trace-id-xyz",
      "Cache-Control": "no-store"
    },
    "payload": {
      "id": "evt_xyz789",
      "type": "message.delivered",
      "created_at": "2026-05-24T12:00:00Z",
      "data": { "message_id": "msg_xyz789", "channel": "sms", "timestamp": "2026-05-24T12:00:00Z", "status": "delivered", "state_class": "terminal", "is_terminal": true }
    },
    "created_at": "2026-05-24T12:00:00Z",
    "updated_at": "2026-05-24T12:00:00Z",
    "completed_at": "2026-05-24T12:00:00Z"
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

### `request_headers` (captured since 2026-04, migration 217)

The exact header set Orbit sent on this delivery attempt. Useful for self-serve HMAC debugging — operators can inspect the `X-Devotel-Signature` value Orbit signed against and compare it to what their receiver computed.

**Redaction.** Orbit redacts the value of any request header matching its secret denylist before persisting (the header name is preserved; only the value becomes `<redacted>`). Matching is case-insensitive. The full denylist is:

* `Authorization`
* `Proxy-Authorization`
* `Cookie`
* `X-Api-Key`
* `X-Auth-Token`
* `X-CSRF-Token`
* `X-Amz-Security-Token`
* Any header whose name matches `X-*-Secret` (starts with `X-`, ends with `-Secret`)
* Any header whose name matches `X-*-Token` (starts with `X-`, ends with `-Token`)
* Any header whose name matches `X-*-Key` (starts with `X-`, ends with `-Key`)

`X-Devotel-Signature` is deliberately **not** redacted — you need its plaintext HMAC digest to debug signature reconstruction.

The redaction is one-way — once persisted, the original values are unrecoverable from the delivery row. This is intentional: the deliveries API is read-only and operator-facing; raw secrets must not surface in dashboard render paths even via inspect.

### `response_headers` (captured since 2026-07-01)

The header set returned by your endpoint on this delivery attempt. Useful for:

* Debugging your receiver's CDN / WAF chain (e.g. confirming Cloudflare didn't strip your body).
* Correlating with your own logging — many receivers echo a request id (`X-Request-Id` / `Traceparent`) that the deliveries view can surface.
* Validating that your receiver returned the expected `Cache-Control: no-store` for webhook responses.

Only headers your endpoint actually returned are captured, up to the first 100 header names (each value truncated to 1,024 characters). `Set-Cookie` is never persisted — a session cookie your server issues is a credential, so it is dropped before the delivery row is stored.

**Backwards compatibility.** Deliveries recorded before 2026-07-01 (when Orbit began capturing response headers) carry `response_headers: null`. The dashboard renders a "headers not captured for this delivery" empty-state in that case — it does NOT block render of the delivery row itself.

### `last_response_body`

Truncated to the first 500 bytes of the response body. Use for debugging your receiver's error messages. No truncation marker is appended, so treat the value as potentially clipped whenever it reaches 500 bytes.

***

## Listing recent deliveries

<Note>
  `GET /api/v1/webhooks/{endpoint_id}/deliveries`
</Note>

Cursor-paginated. The same `request_headers` / `response_headers` fields are returned per row so the dashboard can render the headers panels inline without an extra round-trip per row.

**Query parameters**

| Name         | Type            | Notes                                             |
| ------------ | --------------- | ------------------------------------------------- |
| `limit`      | integer (1–100) | Default 25.                                       |
| `cursor`     | string          | Opaque cursor from the previous response.         |
| `status`     | enum            | Filter — `success`, `failed`, `pending`.          |
| `event_type` | string          | Filter by event type, e.g. `message.delivered`.   |
| `from_date`  | string          | ISO-8601 lower bound on `created_at`.             |
| `to_date`    | string          | ISO-8601 upper bound on `created_at` (exclusive). |

***

## Replay + retry

If your endpoint was down or returned a non-2xx, Orbit retries on the [delivery schedule](/webhooks/overview#retry-schedule). To force a replay of an already-completed delivery (e.g. after a downstream bug fix):

<Note>
  `POST /api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/replay`
</Note>

Creates a new delivery row with the same `event_id` and `payload` and queues it for immediate dispatch. The original row is preserved as audit trail.

To re-queue a **failed** delivery (without changing the payload):

<Note>
  `POST /api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/retry`
</Note>

Resets the delivery's status to `pending` and sets `next_retry_at = now()` so the worker picks it up on the next tick.

***

## See also

* [Webhooks Overview](/webhooks/overview) — registration, retry schedule, delivery guarantees
* [Webhook Security](/webhooks/security) — HMAC signature verification
* [Webhook Events catalog](/webhooks/events) — full list of `type` values Orbit emits
* [Webhook Events reference](/reference/webhook-events) — per-event-type payload schemas
