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