Stripe webhook 验签 400:raw body、whsec_ 与 300 秒容差逐项排查
服务端日志报出:
Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
接口返回 400。Stripe Dashboard 的 Event deliveries 里出现大量 400 记录,随后 Stripe 开始按退避策略重试。
先区分两种情况。“没收到 webhook”通常指两类问题:a) 请求根本没有到达(防火墙拦截、endpoint 未启用、事件类型未订阅);b) 请求到达了,但验签返回 400,handler 里的业务逻辑没有执行。判断依据是 Event deliveries 的状态码:没有投递记录,说明属于 a;有记录且状态码为 400,说明属于 b。下面只讨论 b。
签名到底签了什么
官方文档给出的 Stripe-Signature 头格式是:
t=xxx,v1=yyy,v0=zzz
签名原文是 {t}.{raw_body},用 endpoint 的 whsec_ 密钥做 HMAC-SHA256,输出 hex。头里可能有多个 v1,这是 secret 轮换期的候选签名,任意一个匹配即通过。
不依赖 SDK 的 Node 手写验证:
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyStripe(secret, header, rawBody) {
let timestamp = null; const sigs = [];
for (const item of String(header ?? '').split(',')) {
const [k, v] = item.trim().split('=', 2);
if (k === 't') timestamp = Number(v);
if (k === 'v1' && v) sigs.push(v);
}
if (!Number.isInteger(timestamp) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
return sigs.some((s) => { const b = Buffer.from(s, 'hex'); return a.length === b.length && timingSafeEqual(a, b); });
}
SDK 一行等价:stripe.webhooks.constructEvent(rawBody, sigHeader, endpointSecret)。Go 侧是 stripe.Webhook.ConstructEvent,PHP 是 \Stripe\Webhook::constructEvent。新版 SDK 另有 parseEventNotification 处理 thin event。
坑 1:raw body 被框架改过
官方文档明确要求:必须传入 Stripe 发来的原始 UTF-8 字节;任何解析、重排、去空格、改编码都会导致验签失败。这是实际排查中最常见的一类。
- Express:
app.use(express.json())若注册在 webhook 路由之前,body 已经被解析成对象。webhook 路由必须放在 JSON 中间件之前,或用express.raw({ type: 'application/json' })单独拿 buffer。 - Next.js Pages Router:需要
export const config = { api: { bodyParser: false } },再用buffer(req)读取。 - Next.js App Router:用
await req.text(),不要用req.json()。 - Laravel/PHP:必须用
$request->getContent()。如果对$request->all()的结果再json_encode,一定会失败,因为键序、转义和 unicode 都可能变化。 - Go:
io.ReadAll(r.Body)只能读一次,读完不能重读;Gin 用c.GetRawData();如果中间件先把 body 绑定到结构体,Body 已经被消费。
可验证的做法:在 handler 里对 raw body 打 sha256,与 Event deliveries 详情里的 payload 长度和内容对照;或先把 raw body 原样写入磁盘或日志,再用同一 secret 离线计算一遍比对。
坑 2:secret 用错
两套 secret 的前缀都是 whsec_,肉眼很难分辨:
stripe listen转发到本地时打印的 secret,与 Dashboard/Workbench 里 endpoint 的 secret 不同;- 测试模式与现网模式的 endpoint secret 不同;
- 同一个账户可以有多个 endpoint(上限 16),每个 endpoint 各有自己的 secret。
排查时,把代码实际使用的 endpointSecret 前 8 位和长度打进日志,与 Workbench 里 Reveal secret 或 stripe listen 的输出对照。不要打印全量密钥。
坑 3:时钟偏移与容差
官方 SDK 默认容差 5 分钟(300 秒)。签名里的 t 参与验签,所以改时间戳会直接破坏签名;但服务器时钟偏移超过容差,合法请求也会被拒。官方明确不建议把 tolerance 设为 0,那相当于直接关掉新鲜度检查。
可验证:用 date -u 与 NTP 源比对;systemd 系统看 timedatectl。容器环境容易继承宿主机的时钟漂移。
坑 4:代理/网关重新编码
Nginx、CDN、WAF、API 网关只要对 body 做了解压重压、字符集转换、大小写改写,验签就会失败。判断方法是:把同一事件在 Event deliveries 里的 payload 与写入日志的 body 做 diff,比较长度和首尾字节。处理方式是让 webhook 路径绕过所有 body 处理逻辑。
验签通过之后:重复、顺序、重试
- 幂等键是事件
id(evt_开头)。重试、手动 resend、同一 endpoint 多次投递,带的都是同一个 id。 - 幂等写法:
INSERT ... ON DUPLICATE KEY UPDATE或ON CONFLICT DO NOTHING,affected rows=0 就跳过并返回 200。不要先 SELECT 再 INSERT,并发下会双发。 - 顺序不保证。
created只有秒级精度,两个事件可能同值,别用时间排序推断先后;业务状态机用终态判断(例如已 paid 就不再回退到 pending),或读data.object里的状态字段。 - 重试:现网最多 3 天,指数退避;沙箱 3 次、几小时内。3xx 也算失败,2xx 才算成功。重试次数不可配置。
- 每次重试都会用新的
t和签名,所以第三天到达的合法重试仍能通过 5 分钟容差。 - 手动重投:Dashboard 的 Resend 支持事件创建后 15 天内;
stripe events resend --webhook-endpoint=支持 30 天内。手动重投成功后不会取消自动重试,必须按id去重并返回 200,才能停止重复投递。 - secret 轮换:两个 secret 可同时生效最长 24 小时,所以头里会出现多个
v1。
处理顺序建议
- 验签。失败直接返回 400,不要把事件写进业务表。
- 幂等插入事件表。插入失败或判定为重复,返回 200。
- 立即返回 200,业务逻辑放到队列异步处理;不要在 webhook 里同步调用慢接口。
- 业务失败不要指望“返回 500 让 Stripe 重试”兜底超过 3 天的场景,要自己做重放或对账。
不适用场景
- 国内微信支付/支付宝:验签是平台证书加 SHA256-RSA,回调用 XML 或 AES-GCM 加密 resource,重试节奏 15s 起步、累计 24h4m。不要照搬 Stripe 的 5 分钟容差与 3 天窗口。
- 只做前端支付确认、不发 webhook 的场景:前端 success 永远不能作为发货依据。
未实测说明
上面的 Node 验签代码是按官方文档的签名规则手写的,只用构造的合法/非法 header 做过单测思路验证,没有在真实 Stripe 账户跑通全链路;重试窗口的数字来自官方文档与公开汇总,未逐条实测。
参考链接: