---
title: "Webhooks"
description: "Push delivery of events, with HMAC signatures, retries, and a dead-letter state."
canonical_url: "https://www.agent-identity.dev/docs/concepts/webhooks"
markdown_url: "https://www.agent-identity.dev/docs/concepts/webhooks.md"
last_updated: "2018-10-20"
x_farming_labs_generated_preamble: true
agent:
  tokenBudget: 800
  task: "Receive events over HTTP and verify that a delivery genuinely came from this API."
  outcome: "An endpoint is registered, deliveries arrive signed with x-aid-signature (timestamped), and the receiver verifies them with verifyWebhook before trusting the payload."
  appliesTo:
    package:
      - "@aid/api"
      - "@aid/worker"
  prerequisites:
    - "A publicly reachable HTTPS endpoint."
    - "The signing secret returned once when the endpoint was created."
  files:
    - "apps/worker/src/webhook-delivery.ts"
  sideEffects:
    - "Deliveries are retried with backoff and dead-lettered after the attempts are exhausted."
  verification:
    - description: "Inspect delivery outcomes."
      expect: "GET /v1/webhooks/deliveries lists the attempt with a terminal status rather than staying pending."
  rollback:
    - "Delete the endpoint to stop delivery. Events already queued may still attempt once."
  failureModes:
    - symptom: "Signature verification fails for every delivery."
      resolution: "The HMAC is computed over the raw request body. Verify before any JSON parsing or body-parser middleware rewrites it."
    - symptom: "Deliveries stop and appear dead-lettered."
      resolution: "The endpoint returned non-2xx until retries were exhausted. Fix the receiver, then replay the event with POST /v1/events/{id}/replay."
---

# Webhooks
URL: /docs/concepts/webhooks
LLM index: /llms.txt
Description: Push delivery of events, with HMAC signatures, retries, and a dead-letter state.
Related: /docs/concepts/events, /docs/mail/receiving

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Receive events over HTTP and verify that a delivery genuinely came from this API.
Outcome: An endpoint is registered, deliveries arrive signed with x-aid-signature (timestamped), and the receiver verifies them with verifyWebhook before trusting the payload.

### Applies To

- Package: `@aid/api`, `@aid/worker`

### Prerequisites

- A publicly reachable HTTPS endpoint.
- The signing secret returned once when the endpoint was created.

### Files

- `apps/worker/src/webhook-delivery.ts`

### Side Effects

- Deliveries are retried with backoff and dead-lettered after the attempts are exhausted.

### Verification

- Inspect delivery outcomes.
  - Expected: GET /v1/webhooks/deliveries lists the attempt with a terminal status rather than staying pending.

### Rollback

- Delete the endpoint to stop delivery. Events already queued may still attempt once.

### Failure Modes

- Signature verification fails for every delivery. — Recovery: The HMAC is computed over the raw request body. Verify before any JSON parsing or body-parser middleware rewrites it.
- Deliveries stop and appear dead-lettered. — Recovery: The endpoint returned non-2xx until retries were exhausted. Fix the receiver, then replay the event with POST /v1/events/{id}/replay.
<!-- farming-labs:agent-contract:end -->

# Webhooks

Webhooks are the push side of the [event log](/docs/concepts/events). Register an
endpoint, and matching events are POSTed to it by the worker.

```bash title="terminal"
curl -sS -X POST https://api.agent-identity.dev/v1/webhooks \
  -H "authorization: Bearer $AID_KEY" \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/hooks/aid","event_types":["mail.received","mail.bounced"]}'
```

```json
{
  "id": "c8f0…",
  "identity_id": null,
  "url": "https://example.com/hooks/aid",
  "secret": "whsec_…",
  "event_types": ["mail.received", "mail.bounced"],
  "created_at": "2026-09-27T10:00:00.000Z"
}
```

`secret` is returned once. It is stored encrypted under `AUTH_SIGNING_KEY` and no endpoint
returns it again. Omit `event_types` to receive everything; pass `identity_id` to scope
the endpoint to one agent.

## The request you will receive

```http
POST /hooks/aid
content-type: application/json
x-aid-event-id: b70c…
x-aid-request-id: 5d1e…
x-aid-timestamp: 1790000000
x-aid-signature: v1=3a7b…
x-aid-signature-256: sha256=9f2c…
```

```json
{
  "id": "b70c…",
  "type": "mail.received",
  "version": 1,
  "created_at": "2026-09-27T10:04:11.221Z",
  "organization_id": "0d6e…",
  "aggregate": { "type": "mail_message", "id": "e133…" },
  "data": {
    "messageId": "e133…",
    "threadId": "9a04…",
    "message": { "id": "e133…", "direction": "inbound", "from": [{ "address": "customer@example.com" }], "subject": "Order 1142", "snippet": "Where is my order?", "has_attachments": false },
    "agent_identities": [{ "id": "6f21…", "handle": "support", "display_name": "Support" }]
  }
}
```

`aggregate` is present only when the event has one. `data` is the event payload; for mail events and
events that name agents it also carries `message` (who, subject, a short snippet, whether there are
attachments — never a bcc) and `agent_identities` (handle and name of each agent involved), so you
rarely need to call back. They are added beside the payload, never in place of it.

## Verifying the signature

Every delivery is stamped with `x-aid-request-id` (the delivery's own id, the same on every retry),
`x-aid-timestamp` (Unix seconds, fresh on every attempt) and `x-aid-signature`: `v1=` and the hex
HMAC-SHA256 of `<request id>.<timestamp>.<raw body>` with your endpoint secret. Because the timestamp
is signed, a captured delivery cannot be replayed later: refuse anything more than five minutes old.
Verify against the **raw** bytes, before parsing — re-serializing the JSON changes the bytes.

```ts title="verify.ts"
import { verifyWebhook } from "@agentidentity/sdk";

// rawBody: the request body exactly as received. Throws WebhookVerificationError if anything is wrong.
const { event, requestId } = await verifyWebhook({
  secret: process.env.AID_WEBHOOK_SECRET!,
  body: rawBody,
  headers: req.headers,
  // toleranceSeconds: 300 (default)
});
```

`verifyWebhook` compares in constant time, accepts either secret while one is being rotated, and
throws with a `code`: `missing_headers`, `bad_timestamp`, `timestamp_out_of_tolerance`,
`signature_mismatch` or `bad_body`. The older `x-aid-signature-256` header (`sha256=` and the HMAC of
the body alone) is still sent for receivers written before this.

## Rotating the secret

```ts
const { secret, previousSecretValidUntil } = await client.webhooks.rotateSecret(endpoint.id);
```

`POST /v1/webhooks/{id}/rotate-secret` returns a new secret, once. For the next 24 hours the old one
signs too and both signatures are sent (`v1=<new>,v1=<old>`), so you can switch your receiver over
without a dropped delivery. An agent key can rotate only its own endpoints. An agent, or the
organization itself, may have at most 60 active webhooks.

## Retries and dead-lettering

A delivery counts as successful on any 2xx. Anything else — a non-2xx status or a network
error — is recorded as an attempt and retried. The request times out after 10 seconds.
After the configured maximum attempts the delivery moves to `dead_letter` and stops.

`GET /v1/webhooks/deliveries` lists attempts with their status, attempt count, and last
error. Once the endpoint is healthy again, use
[replay](/docs/concepts/events#replay) to re-send the events it missed.

<Callout type="info">
  Deliveries are retried and can be replayed, so the same `x-aid-event-id` may arrive more
  than once. Deduplicate on it and make handlers idempotent.
</Callout>

- The signature input is `<x-aid-request-id>.<x-aid-timestamp>.<raw body>`, HMAC-SHA256 hex, sent as
  `x-aid-signature: v1=<hex>[,v1=<hex>]`. The body is the exact bytes the worker sent, which is
  `JSON.stringify({id, type, version, created_at, organization_id, aggregate?, data})` with keys in
  that order. Verify before parsing; do not reconstruct the body. Use `verifyWebhook` from the SDK:
  it checks the timestamp (default tolerance 300s), compares in constant time and tries every `v1=`.
- `aggregate` is omitted entirely when the event has no `aggregate_type`/`aggregate_id`, so
  the key may be absent rather than null.
- Endpoint `secret` is only in the `POST /v1/webhooks` response. `GET /v1/webhooks` returns
  `webhookEndpointRecordSchema`, which has no `secret` field. If it is lost, call
  `POST /v1/webhooks/:id/rotate-secret`: it returns a new secret once, and the old one keeps signing
  for 24 hours.
- Delivery status values are `delivered`, `retrying`, and `dead_letter`. There is no
  automatic re-drive out of `dead_letter`; use `POST /v1/events/:id/replay` per event.
- The delivery fetch uses `AbortSignal.timeout(10_000)`. A handler that needs longer must
  acknowledge with a 2xx immediately and do the work asynchronously, or every delivery will
  be recorded as failed and eventually dead-lettered.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
