Webhooks and events
SiMail publishes an event whenever something happens in an inbox. You can consume the same events in three ways:
| Channel | Best for | Permission |
|---|---|---|
GET /v0/events (polling with a cursor) |
simple agents, no public endpoint needed | event_read |
| Webhooks (signed HTTPS POST) | servers | created by a human in the console; webhook_read to inspect |
WebSocket wss://api.simail.ai/v0/ws |
long-running agents | event_read |
Payload
Section titled “Payload”Payloads are thin and identical on every channel. They never include the subject, body or attachment names; fetch the message with your key when you need content (held messages stay metadata-only).
{ "event_id": "evt_…", "event_type": "message.received", "occurred_at": "2026-10-18T17:02:12Z", "organization_id": "org_…", "pod_id": "pod_…", "inbox_id": "shop-helper@simail.email", "thread_id": "thr_…", "message_id": "msg_…", "data": { "from": "ada@example.com", "labels": ["received", "unread"], "held": false, "classification": "normal", "wake": true }}Event types
Section titled “Event types”| Type | When |
|---|---|
message.received |
a message was stored |
message.received.spam |
a message classified as spam was stored |
message.received.blocked |
a message was stored with the blocked label |
message.held |
a message was held for human review (metadata only) |
message.released |
a human released a held message |
message.sent |
an outbound message was handed to the mail network |
message.delivered |
the recipient’s server accepted it |
message.bounced |
it bounced (data.recipient, data.bounce_type) |
message.complained |
the recipient marked it as spam |
message.rejected |
the sending network rejected it |
draft.submitted |
a draft was submitted for sending |
approval.requested |
a send is waiting for approval |
approval.decided |
an approver approved or rejected it |
send.blocked |
a send was blocked (data.reasons) |
send.cancelled |
a scheduled or pending send was cancelled |
send.deferred |
a send is waiting (pause, lock or daily limit) and will be retried |
thread.locked |
a human took over a thread |
thread.unlocked |
the human released it |
label.added |
a label was added |
label.removed |
a label was removed |
inbox.digest |
daily counts of events that did not wake your agent |
inbox.created |
an inbox was created |
inbox.deleted |
an inbox was deleted |
org.suspended |
sending was paused for the organization (data.reason) |
org.resumed |
sending resumed |
Polling: GET /v0/events
Section titled “Polling: GET /v0/events”curl -s "https://api.simail.ai/v0/events?limit=100&types=message.received" \ -H "Authorization: Bearer $SIMAIL_API_KEY"Parameters: after (cursor from the previous response), types, inbox_ids, limit. Store the
returned cursor and pass it as after next time. Cursors are bound to your organization and key scope;
using one elsewhere returns 400. Events are kept for 30 days; a cursor older than that returns
410 gone. GET /v0/inboxes/{inbox_id}/events lists label events for one inbox.
Webhooks
Section titled “Webhooks”A human creates an endpoint in the console (Webhooks → New) and must pick the event_types
explicitly; there is no “all events” default. Endpoints can be filtered by pod or inbox and can carry
custom headers (stored encrypted). The signing secret (whsec_…) is shown once.
Endpoint rules: https:// on port 443, a public host name (no IP literals, localhost, .local or
.internal, and no SiMail-owned domains).
Request
Section titled “Request”POST,content-type: application/json,user-agent: simail-webhooks/1.- Headers
webhook-id(theevent_id),webhook-timestamp(Unix seconds) andwebhook-signature(v1,<base64 HMAC-SHA256>). The same values are also sent assvix-id,svix-timestampandsvix-signature, so existing Standard Webhooks and Svix verifiers work unchanged. - 10-second timeout. Redirects are not followed (a
3xxcounts as a failure). Only a2xxis success.
Verify every delivery
Section titled “Verify every delivery”Compute base64(HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw body}")) with the bytes
after whsec_ (base64-decoded) as the key, compare it in constant time to each v1, value in
webhook-signature, and reject timestamps more than 5 minutes away. During secret rotation, both the
old and new signatures are sent for 24 hours.
// Verify a SiMail webhook (Standard Webhooks). Works in Node 20+, Cloudflare Workers, Deno and Bun.// This file is shown verbatim on docs.simail.ai/webhooks and is exercised by the docs tests.
const TOLERANCE_SECONDS = 300;
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out;}
function bytesToBase64(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin);}
function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0;}
/** * @param secret the endpoint secret shown once in the console, e.g. "whsec_…" * @param headers the request headers * @param body the raw request body, exactly as received (do not re-serialize JSON) * @returns true only for an authentic, fresh delivery */export async function verifySimailWebhook( secret: string, headers: Headers, body: string, nowSeconds: number = Math.floor(Date.now() / 1000),): Promise<boolean> { const id = headers.get("webhook-id"); const timestamp = headers.get("webhook-timestamp"); const signatures = headers.get("webhook-signature"); if (!id || !timestamp || !signatures) return false;
const ts = Number(timestamp); if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return false;
const key = await crypto.subtle.importKey( "raw", base64ToBytes(secret.replace(/^whsec_/, "")), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`)); const expected = bytesToBase64(new Uint8Array(mac));
// During secret rotation the header carries two space-separated "v1,<signature>" values. return signatures.split(" ").some((part) => { const [version, signature] = part.split(",", 2); return version === "v1" && signature !== undefined && constantTimeEqual(signature, expected); });}Use webhook-id to de-duplicate: retries and replays keep the same id.
Retries and disabling
Section titled “Retries and disabling”- Failed deliveries are retried 9 times: after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 12 h, 12 h and
12 h (about 54 hours).
Retry-Afteris respected (up to 12 hours). - A
410 Goneresponse disables the endpoint immediately. - An endpoint that keeps failing for 5 days is disabled automatically. A human re-enables it.
- Each endpoint receives at most 60 requests per minute; extra deliveries are delayed, not dropped.
- Every attempt is logged (status code and timing, never the response body):
GET /v0/webhooks/{webhook_id}/deliveries.
Replay and test
Section titled “Replay and test”From the console, a human can replay events for an endpoint (up to 7 days or 1,000 events) or send a
test delivery. Replays keep the original webhook-id with a fresh timestamp and signature.
GET /v0/webhooks, GET /v0/webhooks/{webhook_id}, GET /v0/webhooks/{webhook_id}/headers (names
only) and …/deliveries need webhook_read. Creating, changing and deleting webhooks is
403 human_required through the API.
WebSocket
Section titled “WebSocket”GET wss://api.simail.ai/v0/wsAuthorization: Bearer <key>For server-side agents only. After connecting you can narrow the stream:
{ "type": "subscribe", "inbox_ids": ["shop-helper@simail.email"], "event_types": ["message.received"] }The server sends { "type": "event", "event": { … } } with the same payload as above.
- A connection lives at most 1 hour (or until the key expires); reconnect to re-authenticate.
- Revoking or narrowing the key, or pausing the organization, closes the connection with code
4401. - Up to 50 connections per organization, 10 per key, and 10 handshakes per minute. A client that falls more than 1 MiB behind is disconnected.
- Like webhooks, events that did not wake your agent are skipped unless you subscribe with
include_non_wake: true.
Polling webhooks (/v0/webhooks/polling) are not offered; use GET /v0/events.