# API · Webhooks

{/* // /v1/webhooks CRUD + delivery */}

Extrovert can send inbound mail to your endpoint as **HMAC-signed, timestamped** webhooks. Verify each
delivery with Web Crypto in Node or at the edge. All webhook endpoints require `mailbox:read`.

## Register: `POST /v1/webhooks`

### Request

```json
{
  "url": "https://my-agent.example.com/inbound", // required
  "events": ["message.received"],                // optional
  "inbox": "agent7@extrovertmail.com"               // optional; scope to one inbox; omit for all
}
```

`address` is accepted as a legacy alias for `inbox`.

```bash frame="terminal"
curl -sS -X POST "$EXTROVERT_API_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://my-agent.example.com/inbound", "events": ["message.received"] }'
```

### Response `201`

```json
{
  "id": "wh_2Lp",
  "url": "https://my-agent.example.com/inbound",
  "events": ["message.received"],
  "inbox": null,
  "secret": "whsec_…",
  "secret_prefix": "whsec_",
  "active": true,
  "created_at": "2026-06-18T18:04:11Z"
}
```
**secret is shown once:** `secret` is returned only on this create response. Store it immediately; it's needed to verify
  deliveries and is not retrievable afterwards. `inbox` is `null` when the webhook covers every inbox.

## List: `GET /v1/webhooks`

Returns `{ items, total }`. Secrets are redacted (only `secret_prefix` is present).

```bash frame="terminal"
curl -sS "$EXTROVERT_API_BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

## Get one: `GET /v1/webhooks/{id}`

Returns one webhook (secret redacted).

## Delete: `DELETE /v1/webhooks/{id}`

Removes a webhook you own. Returns `204 No Content`.

```bash frame="terminal"
curl -sS -X DELETE "$EXTROVERT_API_BASE_URL/v1/webhooks/wh_2Lp" \
  -H "Authorization: Bearer $EXTROVERT_API_KEY"
```

## The delivery

Each delivery `POST`s a JSON body and carries the signature headers:

```http
POST /inbound HTTP/1.1
Content-Type: application/json
X-Extrovert-Signature: t=1749751542,v1=3b2a…   // timestamp + HMAC-SHA256 over `t.body`
X-Extrovert-Event: message.received
```

```json title="message.received"
{
  "event": "message.received",
  "id": "evt_4kP",
  "created_at": "2026-06-18T18:05:42Z",
  "inbox": "agent7@extrovertmail.com",
  "message": {
    "id": "msg_8Tz",
    "thread_id": "thread_9aB",
    "inbox": "agent7@extrovertmail.com",
    "from": "no-reply@acme.test",
    "to": "agent7@extrovertmail.com",
    "subject": "Verify your email",
    "text": "Your code is 492013…",
    "date": "Wed, 18 Jun 2026 18:05:42 +0000",
    "message_id": "<abc@acme.test>",
    "folder": "INBOX",
    "seen": false
  }
}
```
**Delivery message uses raw header strings:** In a webhook delivery, `message.from` and `message.to` are the raw header strings (not the structured
  `{ name, email }` you get from the REST read endpoints). Everything else matches the
  [message shape](https://docs.extrovert.dev/api/messages-and-threads/#the-message-shape).

## Event types

Subscribe by listing event types in `events` on registration (omit it to default to
`message.received` only). The same event names flow on the live SSE stream
(`event: <type>`) and, for the Review Loop, on the durable nudge queue.

### Mail

| Event | When |
|---|---|
| `message.received` | A new inbound message arrived in a subscribed inbox. |

### Review request lifecycle

These fire as a review request moves through the [state machine](https://docs.extrovert.dev/concepts/agents/).
The payload carries `review_id`, `state`, `from_state`, `category_id` (when set),
`revision`, and `effective_mode`. Identity is always opaque typed ids (`rr_…`, `cat_…`).

| Event | When |
|---|---|
| `review.created` | A message was submitted for review (queued). |
| `review.needs_review` | A draft (re)entered the queue (redraft / escalation back). |
| `review.in_review` | A reviewer opened the draft. |
| `review.chat` | A multi-turn review chat began. |
| `review.approved` | A reviewer approved the draft (transient, before send). |
| `review.rejected` | A reviewer rejected the draft (optionally with feedback). |
| `review.edited` | A reviewer edited the body (the diff is captured). |
| `review.stale` | **Reserved; no producer today.** The inbound-reply correlation detector is not built. |
| `review.reconfirmed` | **Reserved; no producer today.** Depends on `review.stale`. |
| `review.sent` | Delivered on a human-approved path (terminal success). |
| `review.auto_sent` | Delivered without human review (graduated / direct) (terminal success). |
| `review.failed` | Delivery failed after approval. **Absorbing:** nothing re-approves a failed review. Do not wait for a retry. The composer's one legal close-out is `cancel_review`. |
| `review.stalled` | **Reserved; no producer today.** The composer response-deadline sweep is not built. |
| `review.cancelled` | The draft was withdrawn; by the composing agent, by a human, or as the close-out of a failed send. |
| `review.front_run_next` | A late agent action against a review a human already closed became a front-run signal. |
| `review.recheck_category` | A category graduated; re-submit to re-evaluate the gates. |
| `review.rule_changed` | A writing rule changed. Affected drafts may need a redraft. |

### Category graduation

| Event | When |
|---|---|
| `category.graduated` | A category advanced a graduation rung (e.g. supervised → auto_notify). |
| `category.demoted` | A category was auto-demoted on drift, or demoted by a human. |

### Writing rules

| Event | When |
|---|---|
| `rule.created` | A new writing rule (house-style or category) was created. |
| `rule.superseded` | A rule was superseded by a newer revision (append-only lineage). |
| `rule.undone` | A rule change was undone (restored to the prior version). |
**Durable nudge queue is authoritative:** For the Review Loop, webhooks and the SSE stream are best-effort *fast paths*. The
  authoritative liveness source is the durable nudge queue: drain it with
  `GET /v1/reviews/events`, long-poll with `GET /v1/reviews/events/wait`, and
  advance your cursor with `POST /v1/reviews/events/ack`
  (`extrovert.reviews.events.list/wait/ack` in the SDK;
  `list_review_events` / `wait_for_review_event` / `ack_review_event` in MCP). An
  event is "handled" only when you ack it; never because a webhook returned 200.

## Verifying a delivery

The signature is `HMAC-SHA256(secret, "{t}.{raw_body}")`, hex-encoded, with a timestamp to defeat
replay. Verify the **raw** body; don't re-serialize the JSON first.

```ts title="inbound-handler.ts"
import { verifyWebhookSignature } from "@extrovert.dev/sdk";

export async function POST(req: Request) {
  const payload = await req.text();           // raw body, do not re-serialize
  const ok = await verifyWebhookSignature({
    payload,
    signature: req.headers.get("x-extrovert-signature")!,
    secret: process.env.EXTROVERT_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("bad signature", { status: 400 });
  // ... handle the verified message.received event ...
  return new Response("ok");
}
```

Or `parseWebhook({ ... })` to verify and JSON-parse in one step (returns `null` on a bad signature).

  ```ts title="verify.ts"
async function verify(raw: string, header: string, secret: string) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const data = `${parts.t}.${raw}`;
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return hex === parts.v1; // also reject if `t` is too old (replay window)
}
```
**Reject stale timestamps:** Treat a delivery whose `t` is outside a small window (e.g. ±5 minutes) as invalid, even if the HMAC
  matches. The timestamp is signed precisely so you can bound replay.

## Next

- [wait_for_email](https://docs.extrovert.dev/api/wait/) pulls the next message instead of receiving a push.
- [Inboxes](https://docs.extrovert.dev/api/inboxes/) documents `webhook_url` registration at inbox creation time.
- [Errors](https://docs.extrovert.dev/api/errors/) lists status codes.