接收 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-signature | Standard 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-id | evt_0123456789abcdef0123456789abcdef |
webhook-timestamp 和 t | 1791194400 |
| 请求体 | {"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}} |
webhook-signature | v1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg= |
X-KB-Signature | t=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 天。在过渡期内,每次投递都用两个签名密钥签名。
- 执行轮换,把新的签名密钥与旧的一起保存在接收端。
- 用任意一个签名密钥验证通过的投递都予以接受。
- 过渡期结束后,删除旧的签名密钥。
如果旧的签名密钥已经泄露,请不要等待,立即用 POST /v1/webhooks/{webhook_id}/revoke-previous-secret 结束过渡期,或者带 "overlap_hours": 0 进行轮换。
检查清单
- 基于原始请求体验证其中一个签名,并检查时间戳。
- 以
webhook-id为键保存事件,然后在 10 秒内返回2xx。 - 跳过重复和过时的事件;重要时通过 API 读取对象的当前状态。
- 需要 Sursa 降低投递频率时,返回带有
Retry-After的429或503;绝不要意外返回410。 - 跳过不认识的事件类型和字段。
- 定期与事件记录比对。