把支付 Webhook 做对:签名、幂等,以及没人提醒你的那些坑
测试环境跑通的 webhook 处理器,在生产依然可能漏钱。这是上线前值得专门设防的四种失效模式。
Webhook 处理器有个特点:坏掉的那些往往看起来很正常。返回 200、日志安静,bug 只会以「顾客说付了但没收到货」或者「对账差了一笔谁也说不清」的形式浮出来。
绝大多数问题出自四种失效模式。
一、先解析再验签
签名覆盖的是发送过来的确切字节。如果框架先把 JSON 解析成对象,你再序列化回去验签,键顺序或空白字符可能已经变了,签名必然对不上。更糟的是,有些团队为此干脆关掉验签。
正确做法是在任何解析中间件碰到它之前,先拿到原始 body。
import hmac, hashlib
@app.post("/webhooks/finality")
def finality_webhook():
# request.data 是原始 body。这里千万别用 request.get_json()。
expected = hmac.new(
WEBHOOK_SECRET.encode(), request.data, hashlib.sha256
).hexdigest()
sig = request.headers.get("X-Finality-Signature", "")
if not hmac.compare_digest(expected, sig):
abort(401)
evt = request.get_json() # 到这一步再解析才安全
...二、不安全的签名比较
普通字符串比较一遇到不同字节就返回,这会泄露「猜对了多少」。用常数时间比较——每种语言都有现成的。
Node 里还有一个额外的坑:crypto.timingSafeEqual 在两个 buffer 长度不等时会抛 RangeError,而不是返回 false。攻击者发一个很短的签名头就能让这个端点 500,除非你先比长度。
三、没有去重
投递是「至少一次」,这是设计使然。任何会在超时后重试的系统,都会偶尔投递两次——包括你的服务器其实已经处理完、只是那个 200 没送回去的情况。
用事件 ID 头做幂等键,并且和副作用放在同一个事务里记录。「先查集合再执行」在并发下不够——两个 worker 可能同时通过检查。
BEGIN;
-- event_id 上的唯一约束让第二次投递变成空操作。
INSERT INTO processed_webhooks (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- 只有这次 INSERT 真的插进去了才发货。
-- 如果 ON CONFLICT 触发, rows_affected 为 0, 跳过副作用。
COMMIT;四、在错误的事件上发货
这是最贵的一个。一笔付款的生命周期会发出多个事件,只有其中一部分代表钱真的是你的了。
| 事件 | 含义 | 能发货吗 |
|---|---|---|
| payment.detected | 链上看到一笔转账,尚未结算。 | 不能 |
| payment.confirmed | 确认数增加,仍未结算。 | 不能 |
| payment.partial | 顾客少付了。 | 不能 |
| payment.settled | 结算完成,钱已在你的钱包。 | 能 |
| payment.blocked | 制裁筛查命中。 | 不能——处理前先人工审查 |
| payment.late | 订单已终态之后才到账。 | 不能——需人工对账 |
请显式判断你要的那个事件。写成「事件名以 payment. 开头就发货」的处理器,迟早会为一笔少付或一笔被阻断的转账发出货。
上线前怎么把这些都测一遍
这四种失效模式在「顺利路径」测试里一个都不会出现。你需要真的往自己的处理器投递事件,而且最好不花钱。
用沙盒密钥建单并强制结算,就会以生产格式发出一条真 webhook,把验签、去重、事件路由端到端跑一遍。再刻意重放同一条事件确认去重生效——如果第二次投递又发了一单货,那你在顾客之前发现了这个 bug。
Webhook 参考里列了全部 17 种事件、重试策略,以及怎么重放失败的投递。
阅读 Webhook 参考 →