一、引言
Webhook 是「反向 API」——不是你去调别人,而是别人在事件发生时调你。支付回调、CI 通知、GitHub 事件、消息推送,全靠 Webhook 把第三方世界的事件搬进你的系统。但 Webhook 是出了名的「不可靠 + 不安全」:可能重复送达、可能顺序乱、可能被伪造、丢了还不会重来。
本文拆解 Webhook 集成的五个核心工程问题:签名校验怎么验、重试与退避怎么设计、幂等怎么保证、事件队列怎么兜底、第三方 Webhook 有哪些坑。以 Stripe、GitHub、支付平台三类常见 Webhook 为例,给出可直接落地的模板。
二、签名校验:先验证「谁打的电话」
2.1 为什么必须校验
Webhook 端点暴露在公网,任何人知道你的 URL 就能 POST 假事件。不校验 = 把「用户已付款」这样的关键事件交给任何人伪造。
2.2 HMAC 签名(最常用)
// 服务端:用 Webhook Secret + 请求体算出 HMAC
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifySignature(rawBody: string, signature: string, secret: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const received = Buffer.from(signature)
const expBuf = Buffer.from(expected)
return received.length === expBuf.length && timingSafeEqual(received, expBuf)
}
签名 = HMAC-SHA256(secret, rawBody)
请求头 X-Signature = "t=<timestamp>,v1=<hash>"
验证三步:
1. 取 v1 hash
2. 用「原始请求体」重算(不能是已解析的 JSON!)
3. timingSafeEqual 恒时比较
关键坑:必须用原始 body 计算签名。先 JSON.parse 再 JSON.stringify 会改变字段顺序/空白,导致 hash 对不上。Stripe 的签名格式
t=...,v1=...是最常见范本。
2.3 JWT 签名(第三方直接签发)
部分平台发 JWT 格式的 webhook(header.payload.signature)。校验方式:
import jwt from 'jsonwebtoken'
const payload = jwt.verify(signature, secret, { algorithms: ['HS256'] })
// 校验 iat 时间窗 + 事件类型白名单
| 校验项 | 说明 |
|---|---|
| 签名 | 验签失败一律 401 |
| 时间戳 | 过期(>5min)拒收,防重放 |
| 事件类型 | 只处理白名单内事件 |
| 来源 | 可选校验 IP / UA(弱辅助) |
心法:签名是唯一可靠的身份验证。IP 白名单、UA 判断都只能当辅助,因为 IP 会变、UA 可伪造。
三、接收端重试与退避
3.1 谁来负责重试
Webhook 的重试有两端:
发送端(第三方):平台自带重试——支付平台会按退避重发 N 次
接收端(你) :自己实现——处理失败后自己补偿
第三方平台(Stripe、GitHub)一般自带 2~5 次退避重发,接收端绝不能只依赖发送端重试——你自己处理失败(比如写库挂了)时,需要自己的重试队列。
3.2 接收端失败处理
收到事件 → 处理 → 成功:返回 200
→ 失败:返回 4xx/5xx
第三方看到非 200 → 按退避重发(这是「免费」的重试)
策略:
| 返回码 | 含义 | 第三方行为 |
|---|---|---|
| 200 | 成功 | 不再发 |
| 400 | 事件无效(不该重试) | 可能重发也可能停 |
| 500 | 处理失败(该重试) | 退避重发 |
3.3 自己的重试队列
// 处理失败 → 进本地队列,退避重试
class RetryQueue {
async push(event) {
for (let attempt = 1; attempt <= 5; attempt++) {
try { return await this.handle(event) }
catch (e) {
await sleep(2 ** attempt * 1000) // 指数退避
}
}
await this.deadLetter(event) // 进死信,人工兜底
}
}
铁律:返回 200 前必须确保业务处理成功(幂等后再 200)。如果先 200 再异步处理失败,事件就「丢了」——这是最常见的 webhook 数据丢失来源。
四、幂等:同一事件来了两遍也不出事
4.1 Webhook 一定会重复
重复来源:第三方重试、网络重发、你的重试队列。设计上必须假设「同一事件会到多次」。
4.2 幂等实现
以事件 id 为幂等键:
处理前查去重表(event_id → 已处理)
命中 → 直接返回 200(不重复执行业务)
未命中 → 加锁处理 + 写去重表
// 用 KV / DB 做幂等去重
export async function handleEvent(event) {
const key = `webhook:${event.id}`
const done = await kv.get(key)
if (done) return ok() // 已处理,幂等返回
const lock = await kv.withLock(key, async () => {
const again = await kv.get(key) // 双检
if (again) return
await doBusiness(event) // 真正的业务
await kv.put(key, '1', { ttl: 7d })
})
}
4.3 天然幂等的业务
加余额:金额幂等(同事件加两次 = 双倍)
改状态:状态机幂等(已支付 → 再置支付 = 无副作用)
发通知:通知本身可重复(但要频控)
创建资源:用外部事件 id 做唯一键(DB 唯一约束兜底)
心法:幂等键的最终防线是数据库唯一约束。代码层面的锁会漏,唯一约束不会——把 event_id 做成业务记录的唯一索引,重复插入自然失败。
五、事件队列与丢失兜底
5.1 高频事件别同步处理
支付回调、日志推送这类高频 webhook,同步处理会拖慢响应、拉高失败率。架构上:
Webhook 端点(轻,只验签+落队列) → 队列 → 消费 Worker(重业务)
├── 验签失败直接 400
├── 验签通过 → 写队列 → 立即 200
└── 消费端幂等处理 + 重试
| 方案 | 适用 | 说明 |
|---|---|---|
| 数据库队列表 | 小流量 | INSERT 即队列,Worker 轮询 |
| Redis 队列 | 中流量 | 原子投递 |
| 消息队列(SQS/Stream) | 大流量 | 托管、可重放 |
5.2 丢失兜底:拉取对账
Webhook 是「推」,推会丢。
兜底是「拉」:
定期(每日/每小时)调用第三方「事件列表 API」
对比本地的 event_id 集合,补处理缺失的
# 例:Stripe 支持按时间窗拉事件
GET /v1/events?created[gte]=...&created[lte]=...
# 本地已处理 id 集 vs 远端 id 集 → diff 补处理
心法:推的可靠性 < 拉的可靠性。Webhook 处理快(实时)、对账兜底慢(保底),两者配合才是「既快又不丢」。
六、第三方 Webhook 的坑
6.1 常见坑
| 坑 | 表现 | 应对 |
|---|---|---|
| 顺序不一致 | 事件乱序到达 | 业务按状态机自愈,不依赖顺序 |
| 重复送达 | 同一事件多次 | 幂等(见四) |
| 大 payload | 请求体超大 | 限制大小,超限 413 |
| 时区/时间戳 | 时间格式不一 | 统一转 UTC 再存 |
| 测试环境误发 | 测试 webhook 打生产 | 环境隔离 + 事件来源校验 |
6.2 安全风险清单
- 校验签名(必做)
- 限制 payload 大小
- 不把 webhook 密钥放客户端
- 日志脱敏(不记完整签名、不记敏感字段)
- 事件类型白名单(只处理认识的)
- 对未知事件:200 + 忽略 + 日志(不要 500)
边界:对不认识的、非业务事件的 webhook,返回 200 但忽略并记日志。返回 4xx 会让第三方反复重发,把自己打成雪崩。
七、三平台配置速查
| 平台 | 签名方式 | 重试策略 |
|---|---|---|
| Stripe | t=...,v1=HMAC(原 body) | 自带退避重发 + 可用「事件列表」对账 |
| GitHub | X-Hub-Signature-256(HMAC) | 自带 3 次重试 |
| 通用支付平台 | 多为 HMAC + 时间戳 | 看平台文档,通常自带重试 |
八、总结
Webhook 集成不是「暴露一个 POST 端点」,而是一套可靠性工程:
- 验签:原始 body + HMAC + 恒时比较,先确认「谁在说话」。
- 重试:返回 200 前保证业务成功;失败 5xx 让第三方退避重发。
- 幂等:以 event_id 为幂等键,数据库唯一约束做最后防线。
- 队列:高频事件「验签落队即 200」,消费端慢慢处理。
- 对账:定期拉取事件列表补缺失,推拉双保险。
把 Webhook 当「事件源」而非「回调接口」来设计,配合 边缘缓存 的响应语义与 可观测性 的日志追踪,就能把第三方世界稳定地接进你的系统。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。