API · Webhooks
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
Section titled “Register: POST /v1/webhooks”Request
Section titled “Request”{ "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.
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
Section titled “Response 201”{ "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"}List: GET /v1/webhooks
Section titled “List: GET /v1/webhooks”Returns { items, total }. Secrets are redacted (only secret_prefix is present).
curl -sS "$EXTROVERT_API_BASE_URL/v1/webhooks" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"Get one: GET /v1/webhooks/{id}
Section titled “Get one: GET /v1/webhooks/{id}”Returns one webhook (secret redacted).
Delete: DELETE /v1/webhooks/{id}
Section titled “Delete: DELETE /v1/webhooks/{id}”Removes a webhook you own. Returns 204 No Content.
curl -sS -X DELETE "$EXTROVERT_API_BASE_URL/v1/webhooks/wh_2Lp" \ -H "Authorization: Bearer $EXTROVERT_API_KEY"The delivery
Section titled “The delivery”Each delivery POSTs a JSON body and carries the signature headers:
POST /inbound HTTP/1.1Content-Type: application/jsonX-Extrovert-Signature: t=1749751542,v1=3b2a… // timestamp + HMAC-SHA256 over `t.body`X-Extrovert-Event: 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 }}Event types
Section titled “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.
| Event | When |
|---|---|
message.received | A new inbound message arrived in a subscribed inbox. |
Review request lifecycle
Section titled “Review request lifecycle”These fire as a review request moves through the state machine.
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
Section titled “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
Section titled “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). |
Verifying a delivery
Section titled “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.
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).
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)}- wait_for_email pulls the next message instead of receiving a push.
- Inboxes documents
webhook_urlregistration at inbox creation time. - Errors lists status codes.