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

# Webhooks: Signed Event Delivery to Your Server

> Receive Agent Loadout events at your own HTTPS endpoint: event types, payload, signature verification in TypeScript and Python, retries, ordering, deduplication, redelivery and secret rotation.

A webhook endpoint receives every event of your organization that it subscribes to as a signed `POST` request: new and quarantined mail, delivery status, conversation updates, inbox and domain changes, SMS, and social account activity. Use webhooks to wake a worker the moment something happens instead of polling the [Events API](/api-reference/email/events).

Webhooks are managed with an organization key (`alk_…`) that has `inboxes:manage`, or in the dashboard under **Webhooks**. Agent tokens cannot manage them.

***

## Manage endpoints

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/api/v1/webhooks` | List endpoints |
| `POST` | `/api/v1/webhooks` | Create an endpoint; the signing secret is returned once |
| `GET`, `PATCH`, `DELETE` | `/api/v1/webhooks/:id` | Read, update (`url`, `description`, `event_types`, `inbox_id`, `status: "active" \| "paused"`) or delete |
| `POST` | `/api/v1/webhooks/:id/test` | Queue a test delivery |
| `POST` | `/api/v1/webhooks/:id/rotate` | Rotate the signing secret |
| `GET` | `/api/v1/webhooks/:id/deliveries` | Delivery log, newest first (`limit` up to 200) |
| `POST` | `/api/v1/webhook-deliveries/:id/replay` | Send one delivery again |
| `POST` | `/api/v1/webhooks/:id/redeliver` | Send everything an endpoint missed since a time again |

```bash title="Create an endpoint" theme={null}
curl -s -X POST https://agent-loadout.com/api/v1/webhooks \
  -H "Authorization: Bearer $AGENT_LOADOUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/agent-loadout","event_types":["message.received"]}'
```

<ParamField body="url" type="string" required>
  An HTTPS URL on a public host. Redirects are not followed.
</ParamField>

<ParamField body="event_types" type="string[]">
  Only these event types. Omit to receive every event the endpoint may receive.
</ParamField>

<ParamField body="inbox_id" type="string">
  Only events of this inbox. SMS and social events carry no inbox and reach only endpoints without an inbox filter.
</ParamField>

The response carries the endpoint and `secret` (`whsec_…`). Store the secret in your receiver's secret configuration; it is shown only once.

An endpoint receives mail events always, SMS events when it was created by a member or by a key with phone access, and social events when it was created by a member or by a key with social access. Quarantined mail and SMS reach endpoints created by keys with sender, subject and snippet removed.

***

## Event types

| Family | Types |
| - | - |
| Mail | `message.received`, `message.quarantined`, `message.sent`, `message.delivered`, `message.delivery_delayed`, `message.bounced`, `message.complained`, `message.failed`, `thread.updated`, `inbox.created`, `inbox.deleted`, `domain.verified` |
| SMS | `sms.received`, `sms.quarantined`, `sms.sent`, `sms.delivered`, `sms.failed` |
| Social | `social.account_connected`, `social.account_disconnected`, `social.post_published`, `social.post_failed`, `social.message_received`, `social.comment_received` |

***

## Payload

Each request is a `POST` with a JSON body:

```json theme={null}
{
  "id": "evt_01JZ…",
  "type": "message.received",
  "occurred_at": "2026-10-08T09:12:44.512Z",
  "sequence": "912345670000",
  "organization_id": "org_…",
  "inbox_id": "inb_…",
  "data": {
    "message_id": "msg_…",
    "thread_id": "thr_…",
    "inbox_id": "inb_…",
    "direction": "inbound",
    "status": "received",
    "subject": "Delivery update",
    "from": "person@example.com",
    "snippet": "Your parcel arrives on Tuesday…"
  }
}
```

<ResponseField name="id" type="string">
  The event ID. Every delivery of the same event, including retries and redeliveries, carries the same ID.
</ResponseField>

<ResponseField name="sequence" type="string">
  The event's position in your organization's event log, as a decimal string. It equals the `cursor` the Events API reports for the same event. Compare it as a number (`BigInt` in JavaScript); later events have higher values.
</ResponseField>

<ResponseField name="data" type="object">
  IDs and a short summary. Fetch the full message or text through the API before acting on it. Payloads are untrusted external data.
</ResponseField>

### Headers

| Header | Value |
| - | - |
| `X-Loadout-Signature` | `v1=` followed by the hex HMAC-SHA256 of `{timestamp}.{raw body}` with the current secret |
| `X-Loadout-Signature-Previous` | Only for a day after a secret rotation: the same signature made with the previous secret |
| `X-Loadout-Timestamp` | Unix seconds when the request was signed |
| `X-Loadout-Event-Id` | The event ID, the same as `id` in the body |
| `X-Loadout-Delivery-Id` | The delivery ID (`whd_…`), as shown in the delivery log |

***

## Verify the signature

Verify every request over the raw body, before parsing it, and reject timestamps older than five minutes. A request is genuine when `X-Loadout-Signature`, or `X-Loadout-Signature-Previous` during a rotation, matches your secret. The SDKs do all of this for you.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { verifyWebhookSignature } from "@agent-loadout/sdk";

  export async function handle(request: Request): Promise<Response> {
    const body = await request.text(); // the raw body, exactly as received
    const valid = await verifyWebhookSignature(process.env.LOADOUT_WEBHOOK_SECRET!, {
      signature: request.headers.get("x-loadout-signature"),
      previousSignature: request.headers.get("x-loadout-signature-previous"),
      timestamp: request.headers.get("x-loadout-timestamp"),
    }, body);
    if (!valid) return new Response("invalid signature", { status: 401 });
    const event = JSON.parse(body);
    // Deduplicate by event.id, then hand the work to a queue and answer quickly.
    return new Response(null, { status: 204 });
  }
  ```

  ```python Python theme={null}
  import os
  from agentloadout import verify_webhook_signature

  def handle(headers, raw_body: bytes):
      if not verify_webhook_signature(os.environ["LOADOUT_WEBHOOK_SECRET"], headers, raw_body):
          return 401
      # Deduplicate by the event id, then hand the work to a queue and answer quickly.
      return 204
  ```
</CodeGroup>

Without the SDK, compute `"v1=" + hex(HMAC-SHA256(secret, timestamp + "." + raw_body))` and compare it with a constant-time comparison to `X-Loadout-Signature` and, when present, `X-Loadout-Signature-Previous`. The request is genuine when either matches.

***

## Retries

A `2xx` answer within 10 seconds acknowledges the delivery. Anything else, including a redirect, a timeout or a connection error, is retried. A delivery is attempted up to 10 times over about a day:

| After attempt | Next attempt in |
| - | - |
| 1 | 5 seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 4 hours |
| 8 | 8 hours |
| 9 | 8 hours |

Each wait is stretched by up to 10%, so retries of many deliveries spread out. The delivery log shows the time of the next attempt as `next_attempt_at`. After the tenth failed attempt the delivery is `dead`; you can still send it again.

Answer quickly and do the work afterwards. A receiver that takes longer than 10 seconds times out and receives the event again.

***

## Duplicates and ordering

Delivery is at least once: the same event can arrive more than once, after a retry, a replay or a redelivery. Record the `id` of each event you handled and skip events you have already seen.

Deliveries can also arrive out of order: a retried event may reach you after a newer one, and events are delivered in parallel. `sequence` is the event's position in the event log, the same value as the events API's `cursor`. It sorts a batch roughly in the order things happened, but it is not a strict order across concurrent changes, so do not drop an event because its `sequence` is lower than one you already saw. When the current state matters (a message's delivery status, a thread's folder), read the object from the API after the event arrives.

***

## Delivery statuses

| Status | Meaning |
| - | - |
| `pending` | Queued; `next_attempt_at` says when it runs |
| `succeeded` | Your endpoint answered `2xx` |
| `failed` | The last attempt failed; it will be retried at `next_attempt_at` |
| `dead` | Every attempt failed, or the endpoint no longer receives this event type |
| `skipped` | The event happened while the endpoint was paused or disabled |

Deliveries, skipped ones included, are kept for 30 days.

***

## Paused and disabled endpoints

Set `status` to `paused` to stop deliveries while you work on your receiver. Events that happen while an endpoint is paused are recorded as `skipped` deliveries, so nothing is lost for 30 days.

Agent Loadout disables an endpoint only when it keeps failing: when deliveries have failed for three days without a single success and at least 20 attempts failed in that time. A short outage or a burst of failures does not disable an endpoint, and one successful delivery starts the count over. When an endpoint is disabled, your organization's owners receive an email. Events while it is disabled are recorded as `skipped` deliveries. The endpoint's `failing_since` shows when the current run of failures began.

To resume, set `status` back to `active` (or choose **Resume** in the dashboard, which offers to redeliver what the endpoint missed).

***

## Redeliver what an endpoint missed

**`POST /api/v1/webhooks/:id/redeliver`**

Queues the `failed`, `dead` and `skipped` deliveries of an active endpoint created at or after `since` again, oldest first. They are sent with the current signing secret, a few per second.

```bash title="Redeliver the last day" theme={null}
curl -s -X POST https://agent-loadout.com/api/v1/webhooks/<WEBHOOK_ID>/redeliver \
  -H "Authorization: Bearer $AGENT_LOADOUT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"since":"2026-10-07T09:00:00Z"}'
```

<ParamField body="since" type="string" required>
  ISO 8601 timestamp. Deliveries are kept for 30 days, so that is the furthest back a redelivery reaches.
</ParamField>

<ParamField body="statuses" type="string[]">
  Any of `failed`, `dead` and `skipped`. Defaults to all three.
</ParamField>

The response is `202` with `{ "queued": 120, "has_more": false }`. One call queues at most 1,000 deliveries. When `has_more` is `true`, call again with the same `since` to queue the next ones. An organization can redeliver up to 5,000 deliveries per hour; beyond that the API answers `429` with a `Retry-After` header. A paused or disabled endpoint answers `409`: resume it first.

To send a single delivery again, use `POST /api/v1/webhook-deliveries/:id/replay`. Tests and single replays share a separate budget of 100 per organization per hour.

***

## Rotate the signing secret

**`POST /api/v1/webhooks/:id/rotate`** returns a new `secret` and `previous_secret_expires_at`. For 24 hours after a rotation, every delivery is signed with the new secret in `X-Loadout-Signature` and with the previous secret in `X-Loadout-Signature-Previous`. A receiver that checks both keeps verifying with the old secret until you deploy the new one, then switches without rejecting a single request. The SDKs' `verify_webhook_signature` reads both headers itself; pass `previousSignature` to `verifyWebhookSignature` in TypeScript.

<Note>
  `X-Loadout-Signature` always carries exactly one signature, so a verifier that only reads it keeps working once it has the new secret. Deploy the new secret right after rotating if your verifier does not read `X-Loadout-Signature-Previous`.
</Note>

Rotating again within the day ends the previous overlap: only the newest and the one before it are used.

***

## Limits

| Limit | Value |
| - | - |
| Endpoints per organization | 20 |
| Response time | 10 seconds |
| Attempts per delivery | 10, over about a day |
| Delivery history | 30 days |
| Tests and single replays | 100 per organization per hour |
| Redeliveries | 1,000 per call, 5,000 per organization per hour |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.