Skip to content

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

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 }
}
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
Terminal window
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.

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

  • POST, content-type: application/json, user-agent: simail-webhooks/1.
  • Headers webhook-id (the event_id), webhook-timestamp (Unix seconds) and webhook-signature (v1,<base64 HMAC-SHA256>). The same values are also sent as svix-id, svix-timestamp and svix-signature, so existing Standard Webhooks and Svix verifiers work unchanged.
  • 10-second timeout. Redirects are not followed (a 3xx counts as a failure). Only a 2xx is success.

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-webhook.ts
// 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.

  • 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-After is respected (up to 12 hours).
  • A 410 Gone response 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.

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.

GET wss://api.simail.ai/v0/ws
Authorization: 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.