线上 Stripe webhook 全 400、重复发货:从签名到幂等的排查顺序
现象
线上告警:Stripe 回调接口的 400 比例达到 100%,事件列表里堆满重试记录,同时业务侧出现重复发货。应用日志里只有同一句报错:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
这句话的含义只有一个:请求头里的 v1 签名和用 secret 算出来的签名不一致。但“不一致”可能是 secret 错、body 被改写、时间戳超出容差,也可能是根本没读到原始 body。下面按排查代价从低到高逐层检查。
第一步:先确认 secret 属于哪条投递路径
Stripe 的 webhook secret 至少有三套:
- Dashboard 上每个 endpoint 一个
whsec_...; - 同一个 URL 在 test 与 live 模式下 secret 不同;
stripe listen本地转发会打印第三套whsec_...,只对 CLI 转发的事件有效。
官方明确说,CLI 转发的端点 secret 和 Dashboard 管理的端点 secret 并不相同,不要混用,并把“用错 secret”列为验签失败最常见的原因(Webhooks 文档)。
一个典型的部署事故是:同一个 STRIPE_WEBHOOK_SECRET 变量被复制到 local / staging / prod 三份配置,三套 secret 里必然有两套是错的。社区里的诊断办法是打印 sha256(secret) 的前 12 位指纹(不打明文),再与 Dashboard 上 Reveal secret 后本地算出的指纹比对。这个方法未实测,方向没问题:secret 是密钥,不要打进日志。
还有一个容易漏的地方:env 文件或 secret manager 里的值经常带尾随换行、空格或引号,那已经是另一个 HMAC key 了。把 secret 读出来后检查 len() 和首尾字符,比反复读代码快。
分诊结论可以直接从现象读出来:
- 本地
stripe listen能过、线上全 400 → secret 串了环境或路径,不是代码问题; - 所有事件都是同一条报错 → 更像 body 被改写,因为 secret 错通常只影响某一个环境。
第二步:body 必须是原始字节
Stripe-Signature 头形如 t=xxx,v1=yyy,测试事件里还可能带 v0。头里偶尔会出现 secret、destination、payload 之类的调试元数据,这些字段没有参与签名,不能用来验签。
签名串是 {timestamp}.{raw_body},算法 HMAC-SHA256,密钥是端点的 whsec_。关键点:必须使用原始 body。任何 JSON.parse 再 JSON.stringify、任何框架的 body parser 重排字段,都会让签名对不上。Node 的 Express 要在这条路由上挂 express.raw({type: 'application/json'});Go 里则是 io.ReadAll(r.Body) 之后不要再解码回写。
时间容差官方库默认 5 分钟。官方明确要求不要把它设成 0:0 不是“最严格”,而是彻底关掉时效检查,与直觉相反。
secret 轮换时一个端点可以同时有多个 active secret,每个都生成一个 v1。验签代码必须遍历所有 v1,任意一个匹配就放行。
Go 版手写验签:
func VerifyStripeSignature(payload []byte, header, secret string, tolerance time.Duration) error {
var ts int64
var sigs []string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
sigs = append(sigs, kv[1])
}
}
if ts == 0 || len(sigs) == 0 {
return errors.New("missing timestamp or v1 signature")
}
if tolerance > 0 && time.Since(time.Unix(ts, 0)) > tolerance {
return errors.New("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", ts)
mac.Write(payload)
expected := mac.Sum(nil)
for _, s := range sigs {
got, err := hex.DecodeString(s)
if err != nil {
continue
}
if subtle.ConstantTimeCompare(got, expected) == 1 {
return nil
}
}
return errors.New("no matching v1 signature")
}
另外,Stripe 每次重投都会重新生成时间戳和签名。所以三天后的重试仍然能通过 5 分钟容差,不会因为“太旧”被拒。
第三步:302 是最隐蔽的一类
如果应用日志里一条请求都没有,先查链路,不要查代码。apex 到 www、http 到 https、尾斜杠重定向,都会让 Stripe 把请求判为失败并重试三天。官方表格里 3xx 写得很直白:“We consider redirect responses to webhook requests as failures”(管理 webhook endpoints)。
这类故障的特征是:Stripe 侧显示投递失败,应用侧完全没有访问日志,请求根本没到应用。
第四步:重复和乱序
投递语义是至少一次。同一个 evt_ id 可能来两次——不只是失败重试,200 在传输中丢了也会重发。
去重按 event id。但业务状态判断不能只看 event id:一次底层变更可能产生两个不同的 Event 对象,所以要用 data.object.id 加 event.type 组合判断。
顺序官方不保证。建一个订阅会生成 customer.subscription.created、invoice.created、invoice.paid、charge.created,可任意顺序到达。官方还明确说 created 只有秒级精度,不同事件可以共用同一个时间戳,不要用它判断先后,也不要用它判断是否已处理。
官方的处理顺序要求:先验签,再查这个事件是否已处理,已处理就返回 200 停止重试;任何可能超时的逻辑之前要先返回 2xx,推荐先把事件写入数据库,再异步处理。
MySQL 侧一张最小幂等表:
CREATE TABLE stripe_event (
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
object_id VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO stripe_event (event_id, event_type, object_id, payload)
VALUES (?, ?, ?, ?);
INSERT IGNORE 的 affected rows 为 0 就说明重复事件,直接返回 200。Postgres 用 ON CONFLICT (event_id) DO NOTHING。
业务状态以 API 回查为准。收到 invoice.paid 但本地还没有 invoice 行时,回查 Stripe API 补对象,而不是把事件排队等乱序的另一半。
复现和重发
本地起一条转发:
stripe listen --print-secret
stripe listen --forward-to localhost:4242/webhook
stripe trigger payment_intent.succeeded
stripe listen 启动时会打印 Ready! Your webhook signing secret is 'whsec_...',--print-secret 可以直接把它注入环境变量。
手工构造一个带签名的请求,也能验证验签链路本身:
BODY='{"id":"evt_test","type":"payment_intent.succeeded"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -i -X POST http://localhost:4242/webhook \
-H "Stripe-Signature: t=$TS,v1=$SIG" \
--data-raw "$BODY"
手动重发的窗口:Dashboard 内 15 天,stripe events resend --webhook-endpoint= 30 天。手动重发不会取消自动重试。社区资料称手动重发沿用原签名与时间戳,可能触发时效失败——这一点未实测。
其他边界
- live 模式必须 HTTPS,只支持 TLS 1.2/1.3。来源 IP 可以加白名单,官方建议签名加 IP 白名单双保险。
- 重试策略:live 模式最多 3 天、指数退避;sandbox 是几小时内重试 3 次。3xx、4xx、5xx、超时都算失败。
- 常见错误码
Unable to connect、302、400、500、TLS error、Timed out 在官方文档里有一张对照表,先按表定位再改代码。