跳到主要内容
Sursa Developers

接收 Webhook

Webhook 会在 Sursa 中发生变更时立即通知您的系统:卡片已发布、已核实或已归档,某个问题没有得到回答。Sursa 把事件通过 HTTPS POST 发送到您登记的地址。本指南构建一个在重试、乱序和重复投递下都能正确工作的接收端。

每个事件的含义和内容,参见 Webhook 概览和 API 参考中的事件目录。

1. 登记 Webhook

管理员在 Sursa 的 Administration → Integrations → Webhooks and API(管理 → 集成 → Webhook)中登记 Webhook,也可以使用具有 admin 角色和 webhooks:manage 权限范围的密钥调用 POST /v1/webhooks:

curl -X POST https://api.sursa.com/v1/webhooks \
  -H "Authorization: Bearer $SURSA_ADMIN_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/sursa",
    "description": "Sync verified cards to the help center",
    "events": ["card.published", "card.updated", "card.verified", "card.archived"]
  }'

响应的 secret 中包含签名密钥,以 whsec_ 开头。它只显示一次。请把它与接收端放在一起保存,例如放在环境变量 SURSA_WEBHOOK_SECRET 中。

接收地址必须使用 https,并解析为公网 IP 地址:私有网络、环回地址和其他保留网络中的地址,在登记时和每次投递时都会被拒绝。要在自己的电脑上开发,请通过内网穿透服务为本地接收端提供一个公网 HTTPS 地址。

随时可以用 POST /v1/webhooks/{webhook_id}/test 发送测试事件。响应会显示接收端返回了什么。

2. 投递的格式

POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042

{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
请求头含义
webhook-id事件的 ID。每次重试都相同。用它去除重复投递。
webhook-timestamp本次投递尝试的签名时间,以 Unix 秒表示。
webhook-signatureStandard Webhooks 格式的签名。
X-KB-Signature同一次投递以旧版 Sursa 格式生成的签名。
X-KB-Event事件类型。
X-KB-Attempt投递尝试的序号,从 1 开始。
X-KB-Sequence本次投递在该 Webhook 投递顺序中的位置。

请求头名称不区分大小写;您使用的框架可能显示为 X-Kb-Signature 或 x-kb-signature。

请求体就是事件。其 data 取决于事件类型。设置为 thin 有效负载的 Webhook 收到的事件不带 data;请改用您自己的密钥通过 API 读取对象。

3. 验证签名

在信任每次投递之前,先检查其签名。两种格式都对请求体的原始字节签名,因此请在做任何 JSON 解析之前读取原始请求体:重新序列化 JSON 会改变字节,导致验证失败。

格式签名字符串密钥签名
Standard Webhooks(webhook-signature)<webhook-id>.<webhook-timestamp>.<body>签名密钥中 whsec_ 之后的部分,经 base64 解码v1, + HMAC-SHA256 的 base64
旧版格式(X-KB-Signature)<t>.<body>整个签名密钥字符串,作为文本使用v1= + HMAC-SHA256 的十六进制

验证其中任意一个即可。建议优先使用 Standard Webhooks 头:许多现成的库都提供同样的验证。轮换签名密钥期间,每个头中为每个有效的签名密钥各带一个签名(webhook-signature 中以空格分隔,X-KB-Signature 中重复 v1=)。只要其中任意一个匹配,投递就是有效的。

还要拒绝与您的时钟相差超过 5 分钟的时间戳。这样可以防止有人事后重放录下的投递。

# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"

# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A

# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

测试签名验证代码

在把代码接入真实投递之前,先用下面的值运行它。把时钟设为该时间戳时,两种验证都必须通过;请求体改动一个字节时,两种验证都必须失败。

输入值
签名密钥whsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestamp 和 t1791194400
请求体{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}}
webhook-signaturev1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg=
X-KB-Signaturet=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc

4. 快速响应,稍后处理

Sursa 最多等待 10 秒。任何 2xx 状态都表示投递成功;其他任何结果,包括重定向、超时或连接关闭,都表示投递失败,之后会重试。

因此,接收端只应验证签名、记录事件并返回响应。实际的处理放到之后,从您自己的队列中进行。

// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";

const SECRET = process.env.SURSA_WEBHOOK_SECRET;

http
  .createServer(async (req, res) => {
    const parts = [];
    for await (const part of req) parts.push(part);
    const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing

    const valid = req.headers["webhook-signature"]
      ? verifyStandard(SECRET, req.headers, body)
      : verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
    if (!valid) {
      res.writeHead(400).end();
      return;
    }

    const event = JSON.parse(body);
    await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
    res.writeHead(204).end();
  })
  .listen(3000);

只有在事件安全保存之后才返回 2xx。如果先返回响应再崩溃,这个事件对您来说就丢失了,因为 Sursa 已经把它计为投递成功。

5. 重试

投递失败时,按逐渐拉长的间隔重试:大约 1 分钟、5 分钟、30 分钟、2 小时、5 小时、10 小时、24 小时和 24 小时之后,在将近三天内共尝试九次。每次间隔最多浮动 20%,以免许多 Webhook 的重试在同一时刻到达。

  • 带有 Retry-After 的 429 或 503 响应会把下一次尝试推迟到至少该时间之后,最多推迟 24 小时。系统过载或维护时可以使用。
  • 410 Gone 响应会停用该 Webhook:只有在不希望再向该地址投递时才返回它。
  • 连续 10 次投递尝试失败后,该 Webhook 会被暂停,组织管理员会收到通知。事件会继续累积,在管理后台中或用 POST /v1/webhooks/{webhook_id}/resume 恢复 Webhook 后,这些事件按顺序投递。
  • 最后一次尝试之后,投递被标记为 exhausted。可以手动重新投递。

GET /v1/webhooks/{webhook_id}/deliveries 和管理后台列出某个 Webhook 的投递,包括每次投递尝试、您返回的状态码及其耗时。POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver 重新投递其中一次。重新投递的 webhook-id 相同。

6. 顺序与重复

  • 顺序。投递到同一个 Webhook 的事件严格按 sequence 的顺序逐个进行。某次投递正在重试时,后面的投递会等待。不同的 Webhook 之间互不等待。
  • 重复。同一个事件可能不止一次到达,例如您的响应在返回途中丢失时。请保存您处理过的每个事件的 webhook-id(它也是请求体中的 id),跳过已经见过的事件。
  • 按状态思考。对于同一对象的变更,把 created_at 或 sequence 与已保存的内容进行比较,忽略比现有状态更旧的事件。拿不准时,通过 API 读取对象的当前状态:事件告诉您发生了变化,API 告诉您现在是什么。

7. 与事件记录比对

无论是否有 Webhook 订阅,每个事件都会在事件记录中保留 30 天。如果接收端停机的时间超出了重试覆盖的范围,或者想检查是否有遗漏,请用具有 admin 角色和 webhooks:manage 权限范围的密钥读取事件记录:

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.*" \
  --data-urlencode "created_since=2026-10-20T00:00:00Z" \
  --data-urlencode "limit=200"

事件记录中的事件与投递中的事件具有相同的 id 和结构,只是没有投递字段 sequence、payload 和 test。由于接收端会跳过已经见过的事件,比对可以随意频繁地运行,例如每小时一次。

GET /v1/webhook-events/{event_id} 按 ID 读取一个事件。

8. 轮换签名密钥

POST /v1/webhooks/{webhook_id}/rotate-secret 生成新的签名密钥,并让之前的签名密钥在过渡期内继续有效,过渡期默认 24 小时,最长 7 天。在过渡期内,每次投递都用两个签名密钥签名。

  1. 执行轮换,把新的签名密钥与旧的一起保存在接收端。
  2. 用任意一个签名密钥验证通过的投递都予以接受。
  3. 过渡期结束后,删除旧的签名密钥。

如果旧的签名密钥已经泄露,请不要等待,立即用 POST /v1/webhooks/{webhook_id}/revoke-previous-secret 结束过渡期,或者带 "overlap_hours": 0 进行轮换。

检查清单

  • 基于原始请求体验证其中一个签名,并检查时间戳。
  • 以 webhook-id 为键保存事件,然后在 10 秒内返回 2xx。
  • 跳过重复和过时的事件;重要时通过 API 读取对象的当前状态。
  • 需要 Sursa 降低投递频率时,返回带有 Retry-After 的 429 或 503;绝不要意外返回 410。
  • 跳过不认识的事件类型和字段。
  • 定期与事件记录比对。