Skip to content

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.

{
"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.

Terminal window
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"] }'
{
"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"
}

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

Terminal window
curl -sS "$EXTROVERT_API_BASE_URL/v1/webhooks" \
-H "Authorization: Bearer $EXTROVERT_API_KEY"

Returns one webhook (secret redacted).

Removes a webhook you own. Returns 204 No Content.

Terminal window
curl -sS -X DELETE "$EXTROVERT_API_BASE_URL/v1/webhooks/wh_2Lp" \
-H "Authorization: Bearer $EXTROVERT_API_KEY"

Each delivery POSTs a JSON body and carries the signature headers:

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
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
}
}

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.

EventWhen
message.receivedA new inbound message arrived in a subscribed inbox.

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_…).

EventWhen
review.createdA message was submitted for review (queued).
review.needs_reviewA draft (re)entered the queue (redraft / escalation back).
review.in_reviewA reviewer opened the draft.
review.chatA multi-turn review chat began.
review.approvedA reviewer approved the draft (transient, before send).
review.rejectedA reviewer rejected the draft (optionally with feedback).
review.editedA reviewer edited the body (the diff is captured).
review.staleReserved; no producer today. The inbound-reply correlation detector is not built.
review.reconfirmedReserved; no producer today. Depends on review.stale.
review.sentDelivered on a human-approved path (terminal success).
review.auto_sentDelivered without human review (graduated / direct) (terminal success).
review.failedDelivery 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.stalledReserved; no producer today. The composer response-deadline sweep is not built.
review.cancelledThe draft was withdrawn; by the composing agent, by a human, or as the close-out of a failed send.
review.front_run_nextA late agent action against a review a human already closed became a front-run signal.
review.recheck_categoryA category graduated; re-submit to re-evaluate the gates.
review.rule_changedA writing rule changed. Affected drafts may need a redraft.
EventWhen
category.graduatedA category advanced a graduation rung (e.g. supervised → auto_notify).
category.demotedA category was auto-demoted on drift, or demoted by a human.
EventWhen
rule.createdA new writing rule (house-style or category) was created.
rule.supersededA rule was superseded by a newer revision (append-only lineage).
rule.undoneA rule change was undone (restored to the prior version).

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.

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).

  • wait_for_email pulls the next message instead of receiving a push.
  • Inboxes documents webhook_url registration at inbox creation time.
  • Errors lists status codes.