代码 Stripe Webhook 本地调试:验签失败多半是 body 被提前 parse 了

2026-09-26 21:31:42

Stripe Webhook 本地调试:验签失败多半是 body 被提前 parse 了

结论

Stripe 只能向公网可达 URL 投递 Webhook,localhost 不行,填了会静默失败或连接错误。本地调试要先把请求引到公网端点,再转发到本机。

验签失败绝大多数与端点 URL 无关:stripe.webhooks.constructEvent 拿到的是被解析或变换过的 body,而不是原始请求体(raw body)。Stripe 的签名基于 raw body 计算,任何中间件在验签前解析正文,都会导致签名不匹配。Express 里最常见的是全局 express.json() 先跑了。

Stripe 签名机制

每次投递,Stripe 在 Stripe-Signature 头里放签名信息:

  • t=:时间戳
  • v1=:一个或多个签名;测试事件还会额外带 v0
  • 签名算法:HMAC-SHA256,key 是端点签名密钥(whsec_ 开头)
  • 待签字符串:signed_payload = timestamp + "." + body
  • 验签时只接受 v1,忽略非 v1 的 scheme,防止降级攻击
  • 防重放:校验时间戳与当前时间差在容差内,官方库默认容差 5 分钟;服务器时钟建议用 NTP 保持准确
  • 比较签名用 hmac.Equal 之类常量时间比较,防时序攻击

官方库常见报错:

  • "No signatures found matching the expected signature"
  • "Timestamp too old"

处理逻辑超过 5 秒,Stripe 会显示 Timed out。建议先返回 200,再异步处理。

本地测试不需要单独签名密钥:无论请求直接来自 Stripe 还是经转发,都用控制台端点配置的同一个 whsec_。详见 Stripe Webhooks 文档。

本地调试完整流程

准备:测试模式 Stripe 账户、Webhook 签名密钥(whsec_ 开头)、Node.js 18+、本地服务监听端口。

  1. 创建公网端点。示例:https://api.hooknexus.com/h/abc123。
  2. Stripe 控制台 Developers > Webhooks > Add endpoint,粘贴公网 URL,选择具体事件:checkout.session.completed、invoice.paid、invoice.payment_failed、customer.subscription.updated、customer.subscription.deleted。避免全选。点 Reveal 复制 whsec_。
  3. 用测试卡 4242 4242 4242 4242 完成支付,触发事件。
  4. 在捕获端查看请求:状态码应为 200;Headers 有 Stripe-Signature、Content-Type、Stripe-Event-Id;Body JSON 里有 type 与 data.object。同一事件出现多条记录,说明此前返回了非 2xx,触发了重试。
  5. 把捕获到的请求转发到 localhost。
  6. 本地应用处理。

也可以用 Stripe CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Stripe CLI 对纯 Stripe 场景够用,但它不持久保存历史、不能从历史重放,且只支持 Stripe。

Express 与 Next.js 关键代码

Express

Webhook 路由必须使用 raw body 中间件:

app.post(
  "/api/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    const event = stripe.webhooks.constructEvent(
      req.body, // 原始 Buffer
      sig,
      endpointSecret
    );
    // ...
  }
);

如果全局挂了 express.json(),正文会先被解析,验签永远失败。

Next.js App Router

对 webhook 路由用 await request.text(),不要用 request.json(),通常无需额外配置。

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  // ...
}

测试与正式模式隔离

测试模式与正式模式完全隔离:各自 API key、Webhook 签名密钥、事件流。在正式模式注册端点却在测试模式触发,永远收不到。whsec_ 也区分模式。排查“事件没到达”时,先确认两边模式一致。

重试策略与幂等

Stripe 在收到非 2xx 后会按指数回退重试,最长持续约 3 天,共约 16-17 次。这比支付宝/微信最长 24 小时长得多,所以幂等必须做扎实。单条订单一周被重发 10+ 次是常态。

实践清单:

  • 先返回 200,再异步处理
  • 所有事件处理幂等
  • 记录所有接收事件的审计日志
  • 用官方 SDK 验签
  • 区分 Test/Live endpoint
  • 只订阅需要的事件类型

幂等键与接口语义见 Idempotent requests。

不要在前端做履约:用户可能在支付完成后、履约前离开页面。用 webhook 监听 payment_intent.succeeded 异步履约。轮询不如 webhook 可靠,且会触发限流。

常用事件:

  • payment_intent.succeeded:支付成功
  • payment_intent.payment_failed:支付失败,看 last_payment_error
  • payment_intent.processing:延迟确认支付方式
  • payment_intent.amount_capturable_updated:已授权待捕获,用 amount_to_capture 捕获
  • charge.refunded:退款

事件类型列表见 Event types。

重放与重新触发

改代码后要重放完全相同的事件。重新触发 checkout.session.completed 要新建 Checkout Session 并走完流程,迭代开销大。

捕获请求后可以重放相同的头和正文,适合触发成本高、迭代错误处理、测试幂等。

常见错误对照

现象原因处理
签名失败几乎总是正文解析中间件先跑了验签拿到的是解析后的 body用 raw body,Express 用 express.raw({ type: "application/json" }),Next.js 用 await request.text()
事件没到达模式不一致、URL 笔误、事件筛选、Stripe 投递日志检查 Test/Live、端点 URL、订阅事件、投递日志
事件类型不对仅创建 Session 触发 checkout.session.created,completed 只在支付完成后走完支付流程再观察
测试/正式模式混淆whsec_ 也区分模式两边模式与签名密钥对齐
官方库报 Timestamp too old服务器时钟偏差超过容差用 NTP 校时,官方库默认容差 5 分钟
官方库报 No signatures found matching the expected signature签名不匹配,常见于 raw body 被改动或密钥不对核对 raw body、whsec_ 与模式
Stripe 显示 Timed out处理超过 5 秒先返回 200,再异步处理

参考

复制全文 生成海报 Stripe Webhook 签名验证 接口对接 幂等

推荐文章

程序员茄子在线接单