Webhook 是任何支付平台调试得第二多的集成面(仅次于"为什么我的出款没结算?")。它看起来简单——出事时往客户给的 URL POST 一个 JSON——但生产现实充满讨厌的边界 case。
下面是经过实战检验的清单,能帮你交付一个能扛住真实客户集成的 webhook 系统。
为什么 webhook 难
基本问题:你在调用你不掌控的代码,跑在你不掌控的基础设施上,经过别人的网络。每个假设都得做防御性设计。
会出错的事情:
- 客户的端点会在某次发布时挂 4 小时
- 客户的端点会返回 200 但消息被丢了
- 客户的端点响应太慢,你的客户端超时,你不知道对方收到没
- 客户的端点先返回 503、重试时返回 200,客户端处理了同一个事件两次
- 客户的端点被换掉,新端点不认你旧的签名密钥
- 客户的 WAF 静默丢掉你的 IP
- 客户的 TLS 在某个周六过期
朴素的"发出去就完事"webhook 实现,对上述大多数情况都束手无策。生产级实现能处理。
不可妥协的功能清单
任何支付级 webhook 系统都需要:
- at-least-once 投递,带明确重试策略
- 每个请求体都做 HMAC 签名
- 稳定的 event ID,让接收方能去重
- 永久失败投递的 死信队列(DLQ)
- 手动回放 UI
- 客户能看到的 每个端点健康指标
- 真实可用的 IP 白名单文档
- 合理的超时(我们用 5s,其他人用 10-30s)
- 零停机的密钥轮换
- 版本化的事件 schema,能演进
如果你的设计缺了任何一项,你会过得很糟。
重试策略
最容易出错的地方。两种失败模式:
- 太激进。 每 5 秒重试一次持续 24 小时。客户端点恢复时被你的重试雷击式 DDoS。
- 太温和。 1 分钟内重试 3 次就放弃。客户的发布要 10 分钟;事件丢了。
走得通的模式:带 jitter 的截断指数退避,限定保留时长。
我们的时间表:
- T+0s — 首次尝试
- T+10s — 第一次重试(如首次失败)
- T+30s
- T+1m
- T+5m
- T+15m
- T+1h
- T+6h
- T+24h — 最终尝试
24 小时内总共 9 次。每次重试有 ±25% 的 jitter。9 次失败后事件进 DLQ。
这能抓住大约 97% 可恢复的客户端点,并把对损坏端点的负载控制在 9 次 / 24 小时。
什么算"成功"?
我们把 2xx 范围内的任意 HTTP 状态码 视为成功。具体:
- 200 — 显式成功
- 201、202、204 — 同样视为成功
- 任何 3xx — 我们最多跟 2 跳重定向,再之后视为失败
- 4xx — 失败,重试(也许端点配置错了,但能修)
- 5xx — 失败,重试
- 连接错误 / 超时 — 失败,重试
传统说法是"4xx 是客户端的错,别重试"。现实是:401 可能意味着客户在轮换密钥并修复 bug;404 可能意味着代理配错;422 可能意味着 schema 校验有问题。把 4xx 当作终态,会因为本来几分钟就能修的瞬时错误惩罚客户。重试,但要打响亮的日志。
HMAC 签名
每一个 webhook 请求体 必须 签名。模式:
- 客户注册端点,我们生成一个密钥(32 字节随机,hex 编码)
- 每次投递,我们计算
HMAC-SHA256(secret, timestamp + "." + body),作为X-Kxp-Signature: t=<ts>,v1=<hex>头发出 - 客户处理函数:
- 读出 timestamp 与 signature 头
- 验证 timestamp 在 ±5 分钟内(防重放)
- 重新计算
timestamp + "." + body的 HMAC - 用常时间比较
我们在文档里发布了主流语言的验证示例。
我们见过的两个反模式:
- 只签 body,不签 timestamp。 易被重放攻击。
- 把 secret 写进源码。 客户会 commit 进仓库。文档里要响亮地说明放在 env 或 vault。
接收方的幂等性
和我们支付 API 幂等那篇同样的理念,镜像应用:
- 我们对每个业务事件发布唯一
event_id(UUIDv4) - 不管投递尝试多少次,重试时都用 同一个
event_id - 接收方按
event_id去重
如果你正在集成任何支付服务商的 webhook,处理函数骨架应是:
async function handleWebhook(req) {
const sig = req.headers['x-kxp-signature'];
const body = await req.text();
if (!verifySig(sig, body, process.env.KXP_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(body);
// Dedup on event_id
const existing = await db.events.findOne({ event_id: event.id });
if (existing) return res.status(200).send('Already processed');
await db.events.insert({ event_id: event.id, status: 'processing' });
try {
await processEvent(event);
await db.events.update(event.id, { status: 'done' });
return res.status(200).send('OK');
} catch (e) {
await db.events.update(event.id, { status: 'failed', error: e.message });
return res.status(500).send('Retry me');
}
}
处理前的去重检查至关重要。少了它,慢处理函数在仍在运行时被重试,就会双倍处理。
死信队列与回放
24 小时内 9 次失败之后,事件进 DLQ。DLQ 应该:
- 能在商户控制台里查看 — 他们需要看到卡在哪
- 可以一键手动回放
- 可以批量回放:场景是"我们刚修好端点,把过去 6 小时全部重发"
- 可按事件类型、时间范围、错误原因过滤
我们的 DLQ 还有 自动回放 功能:当客户端点从"down"恢复到"up"(我们用周期性 1px ping 监测),我们自动回放排队事件。这能覆盖最常见的场景:他们发布完成,我们就接回正轨,不需要人工介入。
端点级健康
我们投递到的每个端点,都在商户控制台有一个健康页面:
商户看到的和我们看到的一样。当他们说"你们的 webhook 坏了",我们能立刻指出:"实际上你的端点过去 22 分钟一直返回 503——这是 body。"
仅这一项功能,就让我们的 webhook 相关支持工单减少了约 60%。
IP 白名单的准确性
客户会把我们加到防火墙白名单里。他们需要一个稳定、准确、文档完善的源 IP 列表。
如果你的 webhook 投递跑在 Kubernetes 动态 IP 的 pod 上,那是个问题。修法:
- 把所有出站 webhook 流量经过 专用 NAT 网关,使用预留 IP
- 在控制台和 API 文档里显著地公布这些 IP
- 绝不静默更换。 提前 90 天公告,发部署前邮件,新旧 IP 并行运行 30 天
我们公布 4 个 IP(覆盖 2 个区域,A/B 高可用)。自上线起就稳定,未来可见时间内也会保持。
密钥轮换
客户会轮换 webhook 密钥。可能因为泄漏,也可能只是卫生习惯。没有优雅的轮换流程,每次轮换都会中断投递。
模式:
- 端点配置同时支持 两个有效密钥(
current和previous) - 我们用
current签名。接收方对两个都验证。 - 客户轮换:让我们把新值设为
current,旧值移到previous;他们部署能接受两者的代码;等部署稳定后,让我们清空previous。
按正确顺序操作,停机时间为零。不按这个流程,轮换窗口期就是全量故障。
版本化
我们发布的每个事件都带 schema_version。客户在端点注册时选择一个 webhook schema 版本。我们永远按那个版本投递(除非他们迁移)。
当我们新增字段时,我们把它加到新 schema 版本中,对仍在旧版本的客户保持原样投递,让他们按自己的节奏迁移。
这本质上是 API 版本化的"邮寄地址"形式。它能跑。少这套机制,你不小心一次"加字段式"变更就会让客户 grumpy。
该监控什么
我们告警的五个 SLI:
- 投递成功率(24h 内应 > 99%)
- P99 投递延迟:从事件创建到首次投递尝试(应 < 30s)
- DLQ 累积速率(> 50 事件 / h 进 DLQ 时告警——多半是系统性问题)
- 每客户 5xx 率(告警发给客户,不是我们)
- 签名密钥年龄(密钥超过 6 个月未轮换告警客户——温和的卫生提醒)
如果重来一次会怎么做
事后回看,如果让我们从零开始:
- 在上线前就把回放 UI 做出来,而不是事故之后。我们没这么做,结果三周里都在做"把丢失的 event_id 发我,我手动重投"这种支持工作。
- 第一天就让密钥轮换是自助的。 手动轮换是个 footgun。
- SDK 默认开启详细的接收方日志。 客户代码里的静默失败模式是最糟糕的。
TL;DR
把无聊的基础设施做扎实。带退避地重试。每个 body 都签名。用稳定的 event ID。有 DLQ。有回放按钮。把健康指标暴露给客户。不要静默换 IP。优雅支持轮换。给 schema 做版本化。
如果你正在集成我们,看看开发者文档的 webhook 章节,那是上面所有内容的生产版本。
来自 Kaadxpay 工程团队的文章,覆盖 API 设计、webhook 可靠性、对账模式,以及运营一家跨境支付平台的真实工程现实。