# Webhook

Source: https://docs.simail.ai/zh/webhooks/

> simail 事件的签名 HTTPS 投递(Standard Webhooks):配置、验签、重试、自动停用、重放和只读 API。

邮箱里每发生一件事,simail 都会发布一个[事件](/zh/event-types/)。同样的事件有三种接收方式:

| 渠道 | 适合 | 权限 |
|---|---|---|
| [`GET /v0/events`](/zh/events/)(带游标拉取) | 简单的 agent,不需要公网端点 | `event_read` |
| Webhook(签名的 HTTPS POST,本页) | 服务器 | 由人在控制台创建;查看需要 `webhook_read` |
| [WebSocket](/zh/realtime/) `wss://api.simail.ai/v0/ws` | 长时间运行的 agent | `event_read` |

载荷很精简(不含主题和正文),各渠道完全一致;见[事件类型](/zh/event-types/)。

## 配置端点

由人在控制台里创建端点(**Webhooks → New**),并且必须明确选择 `event_types`;没有"全部事件"这种默认值。端点可以按 pod 或邮箱过滤,可以带自定义请求头(加密存储)。没有[唤醒你的 agent](/zh/event-types/) 的事件,只有在端点打开 **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 小时内新旧两个签名都会发送。

```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`,换上新的时间戳和签名。

## API

`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](/zh/api/webhooks/)。

不提供轮询式 webhook(`/v0/webhooks/polling`);请用 [`GET /v0/events`](/zh/events/)。
