Stripe Webhook 验签失败:请求体、密钥与环境三类原因的排查顺序
典型报错是这一条:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
官方文档(Resolve webhook signature verification errors)的说法很直接:constructEvent(requestBody, signature, endpointSecret) 三个参数任意一个不对,都会抛出同一个错误。所以别盯着报错文本猜,要按参数逐个排除。
下文标注 官方口径 的来自 docs.stripe.com,标注 社区经验 的来自第三方整理(fixerror.dev、errormedic.com、axonbuild.com、cesarayala.dev),这些框架写法本次未逐框架实测。
验签在做什么
官方口径:Stripe 用 Stripe-Signature 请求头传递签名,头里的值形如 t=xxx,v1=yyy,v0=zzz;如果不是这个形状,说明从 header 里取签名的代码就有问题。
社区经验:签名是 HMAC-SHA256(时间戳 + '.' + 原始 body),密钥为端点签名密钥。SDK 用收到的 body 重算一遍,逐字节相等才算通过。也就是说三种输入——原始 body、Stripe-Signature 头、endpoint secret——任何一个字节有偏差都会失败。默认时间容差 300 秒(5 分钟),超出报 Timestamp outside the tolerance zone,常见于没有 NTP 同步的容器时钟漂移;这个容差本身是防重放用的。
原因一:endpoint secret 用错(最常见)
官方口径:每个 webhook 端点有自己的签名密钥,前缀都是 whsec_。Dashboard 里创建的端点和 Stripe CLI stripe listen 打印出来的密钥不是同一个——CLI 转发的请求要用 CLI 打印的密钥,Dashboard 端点的请求要用 Dashboard 的密钥。同理,test 与 live 也是各一套密钥。
社区经验:因为前缀都一样,光看 whsec_ 区分不出来源。最常见的坑是一个 STRIPE_WEBHOOK_SECRET 在 local/staging/prod 三处共用,而三个环境各有一个正确值,注定有两个环境失败。
排查方式:把代码里实际传入的 endpointSecret 打印出来,和 Dashboard 上该端点的「Reveal secret」逐字符比对。另外两个快速判断——本地 stripe listen 通过、生产失败,多半是密钥不匹配;所有事件都以同一种方式失败,则更该怀疑原始 body 的处理而不是密钥。
原因二:原始请求体被中间件解析后重序列化
官方口径:body 必须是 Stripe 发来的 UTF-8 字符串原样。框架加删空白、改键顺序、转成 JSON 对象、改编码,都会导致验签失败。
Express 的做法是给 webhook 路由单独挂 express.raw,且 express.json() 必须注册在 webhook 路由之后(中间件按注册顺序执行,json() 在前会先把 body 解析掉):
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
// 必须在 webhook 路由之后
app.use(express.json());
其余框架取原始 body 的写法(官方口径 覆盖 Express、Next.js、AWS;其余为 社区经验):
| 运行时 | 取原始 body |
|---|---|
| Next.js App Router | const body = await req.text(),不要用 request.json() |
| Next.js Pages Router | export const config = { api: { bodyParser: false } },再用 buffer 读 |
| FastAPI | payload = await request.body(),返回 bytes,该端点别定义 Pydantic 模型 |
| Django | payload = request.body |
| Go net/http | body, err := io.ReadAll(r.Body) |
| Astro | 同 App Router,await request.text() |
| AWS API Gateway + Lambda | 为 application/json 配 Body Mapping Template,把 rawBody 传进函数 |
社区经验 的分诊动作:在 constructEvent 之前打印 typeof body / Buffer.isBuffer(body),应该是 Buffer 或 string,而不是 object。
原因三:代理/CDN 改写 body,或时钟超出容差
社区经验:Cloudflare、nginx、AWS API Gateway、service worker 都可能给 body 加删结尾换行、统一换行符、动 UTF-8 BOM,这些都会让签名失效。如果上游有网关或请求日志中间件,确认它没有对 body 做重写。
时钟问题则看报错:Timestamp outside the tolerance zone 说明服务端时间漂移超过 300 秒。另外,如果验证逻辑是手写的,记得自己处理容差,SDK 里已经内置。
多密钥轮换
社区经验:constructEvent 可以接收密钥数组,轮换期间逐个尝试,第一个验过即用,这样不必在切换密钥时停机。
报错串对照
官方/库源码口径:
- Node:
No signatures found matching the expected signature for payload.;如果传进去的是已解析的对象,会报Webhook payload must be provided as a string or a Buffer ... Payload was provided as a parsed JavaScript object instead.;body 没传则报No webhook payload was provided. - Go 库:
webhook had no valid signature、webhook has no Stripe-Signature header、webhook has invalid Stripe-Signature header、timestamp wasn't within tolerance。
先用 Dashboard 的 Recent deliveries 分诊
社区经验:Endpoints → 某个端点 → Recent deliveries,三种情况指向不同的地方:
- 已送达且返回 200,但业务侧没反应:不是验签问题,问题在 200 之后的处理链路。
- 非 2xx 且反复重试:端点拒绝、超时或处理出错;单看失败记录分不清是验签还是下游错误。
- 日志里什么都没有:事件根本没发到这个端点,或者发了没到——模式不匹配(test/live)、没订阅该事件类型、DNS、防火墙、URL 写错、CLI 没转发,都有可能。
验签通过之后仍要做的事
社区经验:Stripe 是至少一次投递,验签通过不代表只收到一次,仍需按 event.id 幂等去重;处理逻辑要尽快返回 2xx,重活丢给异步 worker。Stripe 会对失败投递重试,持续验签失败意味着支付/订阅事件在重试耗尽后被静默丢弃,建议监控 signature_verification_failed 指标。
本地调试:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger payment_intent.succeeded
把 stripe listen 打印出来的 whsec_ 写进本地 .env。
官方文档
- 验签排障:https://docs.stripe.com/webhooks/signature
- Webhooks 总览:https://docs.stripe.com/webhooks