把支付 Webhook 做对:签名、幂等,以及没人提醒你的那些坑

·6 分钟

测试环境跑通的 webhook 处理器,在生产依然可能漏钱。这是上线前值得专门设防的四种失效模式。

本文目录
  1. 1一、先解析再验签
  2. 2二、不安全的签名比较
  3. 3三、没有去重
  4. 4四、在错误的事件上发货
  5. 5上线前怎么把这些都测一遍

Webhook 处理器有个特点:坏掉的那些往往看起来很正常。返回 200、日志安静,bug 只会以「顾客说付了但没收到货」或者「对账差了一笔谁也说不清」的形式浮出来。

绝大多数问题出自四种失效模式。

一、先解析再验签

签名覆盖的是发送过来的确切字节。如果框架先把 JSON 解析成对象,你再序列化回去验签,键顺序或空白字符可能已经变了,签名必然对不上。更糟的是,有些团队为此干脆关掉验签。

正确做法是在任何解析中间件碰到它之前,先拿到原始 body。

Python / Flask —— 对原始字节验签
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 参考 →
相关文章