跳转到内容
simail文档v0

接入

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长时间运行的 agentevent_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 算成功。

以 whsec_ 后面的字节(base64 解码)为密钥,计算 base64(HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw body}")),与 webhook-signature 里每个 v1, 值做常数时间比较,并拒绝与当前时间相差超过 5 分钟的时间戳。密钥轮换期间,24 小时内新旧两个签名都会发送。

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

用 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。