接入
Webhook
simail 事件的签名 HTTPS 投递(Standard Webhooks):配置、验签、重试、自动停用、重放和只读 API。
docs.simail.ai/zh/webhooks
邮箱里每发生一件事,simail 都会发布一个事件。同样的事件有三种接收方式:
| 渠道 | 适合 | 权限 |
|---|---|---|
GET /v0/events(带游标拉取) | 简单的 agent,不需要公网端点 | event_read |
| Webhook(签名的 HTTPS POST,本页) | 服务器 | 由人在控制台创建;查看需要 webhook_read |
WebSocket wss://api.simail.ai/v0/ws | 长时间运行的 agent | event_read |
载荷很精简(不含主题和正文),各渠道完全一致;见事件类型。
由人在控制台里创建端点(Webhooks → New),并且必须明确选择 event_types;没有”全部事件”这种默认值。端点可以按 pod 或邮箱过滤,可以带自定义请求头(加密存储)。没有唤醒你的 agent 的事件,只有在端点打开 Include non-waking events(include_non_wake)时才会投递。创建或修改端点需要再次验证身份。签名密钥(whsec_…)只显示一次;人可以轮换它,轮换期间 24 小时内新旧两个签名都会发送。
端点规则:443 端口上的 https://,公网主机名(不能是 IP 字面量、localhost、.local 或 .internal,也不能是 simail 自己的域名)。
POST,content-type: application/json,user-agent: simail-webhooks/1。- 请求头
webhook-id(即event_id)、webhook-timestamp(Unix 秒)和webhook-signature(v1,<base64 HMAC-SHA256>)。同样的值也以svix-id、svix-timestamp和svix-signature发送,现成的 Standard Webhooks 和 Svix 验签代码无需修改即可使用。 - 超时 10 秒。不跟随重定向(
3xx算失败)。只有2xx算成功。
每次投递都要验签
Section titled “每次投递都要验签”以 whsec_ 后面的字节(base64 解码)为密钥,计算 base64(HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw body}")),与 webhook-signature 里每个 v1, 值做常数时间比较,并拒绝与当前时间相差超过 5 分钟的时间戳。密钥轮换期间,24 小时内新旧两个签名都会发送。
// 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); });}用 webhook-id 去重:重试和重放保持同一个 id。
- 投递失败会重试 9 次:间隔 5 秒、5 分钟、30 分钟、2 小时、5 小时、10 小时、12 小时、12 小时和 12 小时(约 54 小时)。会遵守
Retry-After(最长 12 小时)。 - 返回
410 Gone会立即停用端点。 - 连续失败 5 天的端点会被自动停用,由人重新启用。
- 每个端点每分钟最多收到 60 个请求;超出的投递会延后,不会丢弃。
- 每次尝试都有记录(状态码和耗时,从不记录响应正文):
GET /v0/webhooks/{webhook_id}/deliveries。
人可以在控制台里为端点重放事件(最多 7 天或 1,000 个事件),或发一次测试投递。重放保留原来的 webhook-id,换上新的时间戳和签名。
GET /v0/webhooks、GET /v0/webhooks/{webhook_id}、GET /v0/webhooks/{webhook_id}/headers(只有名字)和 …/deliveries 需要 webhook_read。通过 API 创建、修改和删除 webhook 会返回 403 human_required,这样泄露的 key 无法添加用来外泄数据的端点。见 Webhook API。
不提供轮询式 webhook(/v0/webhooks/polling);请用 GET /v0/events。