Webhooks are scoped to a single agent. Two events are emitted:
| Event | Fires when |
|---|---|
message.sent | After at least one envelope recipient succeeded on POST /agents/:id/messages/send. |
message.received | After an inbound email is parsed, deduplicated by Message-ID, and persisted. |
The Worker fans out webhooks in parallel across subscribers. A slow or failing subscriber cannot delay other subscribers on the same event.
POST/agents/:id/webhooks{
"url": "https://your-app.com/sendook/webhook",
"events": ["message.received", "message.sent"],
"secret": "optional-32-byte-secret-or-omit-to-generate"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS URL ≤ 2048 chars. See URL restrictions. |
events | string | Yes | 1–16 entries from message.sent, message.received. |
secret | string | No | 16–256 chars. If omitted, a 32-byte cryptographically-random base64url secret is generated. |
Body cap: 4 KiB UTF-8 bytes.
The Worker rejects URLs that resolve to:
127/8, RFC1918 10/8 172.16/12 192.168/16, link-local + cloud metadata 169.254/16, CGNAT 100.64/10, multicast 224/4, 0/8, reserved 240/4.::1, link-local fe80::/10, ULA fc00::/7, multicast ff::/8, IPv4-mapped variants of any of the above.localhost, *.localhost, *.local, metadata, metadata.google.internal.http:// is also rejected — only https://. The same check runs at delivery time so webhooks created before this guard cannot be exploited.
201 Created{
"id": "8d7c...uuid",
"url": "https://your-app.com/sendook/webhook",
"events": ["message.received", "message.sent"],
"secret": "rA9-...long-base64url",
"created_at": 1730000000
}
secret is shown exactly once (parallel to the per-agent api_key). Treat it like a credential.
400 — invalid JSON, body too large, bad URL (non-https, oversized, or SSRF target), unknown event, secret outside length range.401 / 403 / 404 per the standard auth rules.GET/agents/:id/webhooks200 OK{
"webhooks": [
{
"id": "8d7c...uuid",
"url": "https://your-app.com/sendook/webhook",
"events": ["message.received", "message.sent"],
"created_at": 1730000000
}
]
}
secret is never returned by the list endpoint.
DELETE/agents/:id/webhooks/:webhookId204 No Content404 — webhook doesn't exist or doesn't belong to the requested agent.GET/agents/:id/webhooks/:webhookId/attemptsReturns up to the last 100 delivery attempts for one webhook. Older rows are evicted on every new insert; older history is not preserved.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Clamped to 1..100. |
offset | integer | 0 | Pagination offset. |
200 OK{
"attempts": [
{
"id": "uuid",
"webhook_id": "8d7c...uuid",
"event_type": "message.received",
"payload_size": 1842,
"status_code": 200,
"ok": true,
"attempt_count": 1,
"next_retry_at": null,
"created_at": 1730000000
},
{
"id": "uuid-2",
"webhook_id": "8d7c...uuid",
"event_type": "message.received",
"payload_size": 1842,
"status_code": 503,
"ok": false,
"attempt_count": 1,
"next_retry_at": null,
"created_at": 1729999900
}
],
"total": 2,
"limit": 50,
"offset": 0
}
| Field | Type | Description |
|---|---|---|
status_code | integer | null | HTTP status from the subscriber, or null for network failures (DNS, TCP reset, abort). |
ok | boolean | Whether the subscriber returned 2xx. |
attempt_count | integer | Which retry this row represents (1..5). |
payload_size | integer | UTF-8 byte length of the JSON body. |
created_at | integer | Unix seconds. |
Every fanout request is POST <subscriber.url> with the following JSON body:
{
"event": "message.received",
"agent_id": "abc123def456",
"message_id": "uuid",
"message_id_header": "<msg@external.example>",
"delivered_at": 1730000000,
"data": {
"id": "uuid",
"direction": "inbound",
"from_addr": "alice@example.com",
"to_addr": "abc123def456@agents.yourdomain.com",
"subject": "Hi",
"message_id_header": "<msg@external.example>",
"in_reply_to": null,
"body_text": "Plain text body.",
"body_html": null,
"raw_size": 1234,
"status": "received",
"created_at": 1730000000,
"thread_id": "uuid"
}
}
Headers on every fanout request:
| Header | Value |
|---|---|
Content-Type | application/json |
x-sendook-event | message.sent or message.received |
x-sendook-signature | sha256=<hex> — HMAC-SHA-256 of the raw JSON body, keyed on the webhook's secret. |
x-sendook-webhook-id | The webhook's id. |
x-sendook-attempt | 1..5 — the attempt number. |
If data.body_text and/or data.body_html would push the JSON payload over 256 KiB, both fields are truncated (with a binary-search on UTF-8 byte length so multi-byte chars stay intact) and the truncated value ends with the literal marker:
...truncated; GET /agents/:id/messages/:id for full body
Consumers that need the full body should refetch via GET /agents/:id/threads/:threadId.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const expected = "sha256=" +
createHmac("sha256", secret).update(rawBody).digest("hex");
if (header.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
Use the raw request body for HMAC input — JSON-parsing then re-stringifying will change byte ordering and break the signature.
4xx (everything except 408, 425, 429) ends retries — the row is recorded once and never retried.408, 425, 429, every 5xx, and network failures are retried.AbortController timeout — a hung TCP connection cannot lock up the agent's queue.Inbound mail is deduplicated on Message-ID per agent, so a Cloudflare Email Routing retry will not produce a second persisted row, a second thread link, or a second message.received webhook. Outbound message.sent is not deduplicated; client-side retries against POST .../messages/send will fire one webhook per successful call.