Stripe webhook 事故的第一目标不是让某个请求立刻成功,而是让订单、订阅、发票和用户权益不会被重复开通或漏开通。Stripe 官方会在 live mode 最多重试 3 天,事件本身可通过 Events API 访问 30 天;这给了团队恢复窗口,也要求你先把本地事件账本做对。
确认是不是 Stripe webhook 失败?
把客服口径里的「客户付了钱没开通」拆成 4 个状态:付款没成功、Checkout 成功但 webhook 没到、webhook 到了但处理失败、处理成功但产品权益没刷新。只有后 3 种才归 webhook SOP。
Workbench 里看 Webhooks 的 Event deliveries。Stripe 文档里的投递状态包括 Delivered、Pending、Failed;点进单个事件可以看到 HTTP status code,以及未来是否还有 pending deliveries。
| 现象 | 看哪里 | 判断口径 | 下一步 |
|---|---|---|---|
| 用户付款后没权益 | Stripe payment / checkout 事件 | 是否有 checkout.session.completed 或 invoice 事件 | 查 Event deliveries |
Event deliveries 是 Pending | Workbench | Stripe 还会继续投递 | 不要手工连点 resend,修 endpoint |
Event deliveries 是 Failed | HTTP status code | 3xx/4xx/5xx/timed out 都不是成功 | 按状态码分流 |
| 后端日志有事件但订单没变 | 本地事件表和队列 | handler 收到但业务失败 | 跑队列重放 |
| 同一订单开通两次 | 本地订单表 | 幂等缺失或唯一键太弱 | 冻结重放脚本,先补约束 |
302 也算失败。Stripe 文档明确把 redirect responses 当作 webhook request failures,所以不要让 /api/stripe/webhook 走登录中间件、区域跳转或 trailing slash 重定向。
最短止血路径怎么走?
第一步,把 webhook endpoint 恢复成可公开访问的 HTTPS URL。生产 endpoint 要支持 TLS v1.2 或更高版本;本地开发可以用 Stripe CLI 转发到 HTTP,但不要把这个例外带到 live mode。
第二步,让 handler 尽快返回 2xx。它只做 4 件事:读取 raw body、验证 Stripe-Signature、写入事件表、把任务丢进队列。发邮件、开会员、同步 CRM、生成发票 PDF,都放到 worker。
第三步,暂停人工大规模改订单。客服可以先给单个高价值客户临时开通,但每一次人工动作都要写 manual_grant_id、操作者、原因、Stripe event id 或 payment id。否则 2 小时后自动重试回来,系统会把同一笔钱再处理一次。
第四步,给所有相关事件打标签:received、processing、processed、failed、manual_resolved。Stripe 官方处理 undelivered events 的示例也强调先判断事件是否 processing 或 processed,再决定是否处理。
签名验证失败先查什么?
签名失败不要先换 secret。Stripe 签名验证依赖 3 个参数:原始请求体、Stripe-Signature header、endpoint signing secret。三者任意一个不对,都会出现类似 No signatures found matching the expected signature for payload 的错误。
最常见的坑是框架先把 JSON 解析了。Express、Next.js、API Gateway、Serverless 平台都有自己的 body parser;只要它改了空格、编码或 JSON key 顺序,constructEvent() 看到的就不是 Stripe 发来的原始 UTF-8 字符串。
| 错误信号 | 常见根因 | 处理动作 |
|---|---|---|
No signatures found matching... | raw body 被解析或改写 | webhook route 前置 raw body,中间件后移 |
| test 能过、live 不过 | 混用 endpoint secret | test/live 分别保存 whsec_... |
| CLI 能过、Dashboard 不过 | 用了 stripe listen 打印的 secret | 本地和生产 secret 分环境命名 |
| 偶发 400 | 反向代理改 body 或压缩 | 检查网关、WAF、函数平台 body 设置 |
| 所有事件都 401/403 | 鉴权中间件拦截 | webhook route 排除登录态和 CSRF |
签名通过后再解析 JSON。不要为了省几行代码先 JSON.parse(),再把对象重新 stringify 给 Stripe SDK;这种代码在本地小样本可能碰巧过,生产遇到不同编码和换行就会炸。
幂等账本应该怎么设计?
Stripe webhook 不是「收到一次就处理一次」的队列。官方文档提醒事件可能重复、乱序到达;自动重试、Dashboard resend、CLI resend 还会让同一个事件在不同时间再次出现。
最小账本用两张表就够:一张 stripe_events 记录事件生命周期,一张 billing_entitlements 或 orders 记录业务结果。前者按 event.id 唯一,后者按业务对象唯一,比如 stripe_checkout_session_id、stripe_invoice_id、stripe_subscription_id + period。
create table stripe_events (
event_id text primary key,
event_type text not null,
stripe_created_at timestamptz not null,
received_at timestamptz not null default now(),
status text not null,
attempts int not null default 0,
last_error text,
payload jsonb not null
);
订单幂等键不要只用用户 id。一个用户可以买月付、年付、加购、补差价,也可能退款后重新购买。更稳的键是 Stripe 对象 id 加业务周期,例如 invoice.id、checkout.session.id、payment_intent.id,再映射到本地 order id。
乱序也要接受。customer.subscription.updated 可能比 invoice.paid,到,某些订阅创建动作还会触发多个事件。worker 不能假设前一个事件已经处理完;缺依赖时,把事件标成 blocked_waiting_dependency,由定时任务重试。
队列重放和 30 天窗口怎么做?
如果 endpoint 已经挂了几十分钟,不要靠人工逐个点 Dashboard resend。Stripe Dashboard 的 Resend 在事件创建后 15 天内可用,CLI stripe events resend <event_id> --webhook-endpoint=<endpoint_id> 在 30 天内可用;但手动 resend 不会取消自动重试。
更可控的做法是用 List Events 拉一批未成功投递事件。Stripe 的 undelivered events 文档给出的关键参数是 ending_before、types[]、delivery_success=false;配合 auto-pagination 时,可以按创建顺序处理。
stripe events list \
--type checkout.session.completed \
--delivery-success=false \
--ending-before evt_123
真正执行重放时,不要让脚本直接改订单。脚本应该把事件放回同一条内部队列,让生产 worker 走相同幂等逻辑。这样自动重试、人工 resend、脚本补跑都进入同一个闸口。
重放前先做 3 个限制:只选必要事件类型,只选事故时间窗,只在 dry-run 输出将处理的 event id 和订单 id。dry-run 数量不对时停下,别带着猜测跑生产。
本地隧道、staging 和 production 怎么隔离?
本地测试用 Stripe CLI:stripe listen --forward-to localhost:4242/webhook,再用 stripe trigger payment_intent.succeeded 或对应事件触发。CLI 会打印一个本地 whsec_...,它只适合本次本地转发,不要复制到生产环境变量。
Staging 要有自己的 Stripe endpoint、自己的 signing secret、自己的数据库和队列。最差的配置是 staging 监听 live mode 事件,又把权益写到测试库;看起来没有影响生产,其实 Stripe 已经把 live event 投递到了错误环境。
生产变更前跑一张小检查表:
| 检查项 | Staging | Production |
|---|---|---|
| Endpoint URL | 独立域名或路径 | 公开 HTTPS,无重定向 |
| Signing secret | staging 专用 whsec_... | live endpoint 专用 whsec_... |
| Event types | 覆盖 checkout、invoice、subscription | 只订阅业务需要的类型 |
| Queue | 可清空重跑 | 有死信队列和告警 |
| Database | 测试数据 | event_id 和订单唯一键已上线 |
| Replay script | 默认 dry-run | 需要人工确认参数 |
团队分布在不同国家、临时旅行或远程值班时,最怕的是事故期间一边切后台、一边换设备、一边复制 secret。可以把 Stripe、Vercel、Cloudflare、队列监控这些核心后台固定到同一套运维资料夹,并用Stripe Dashboard 稳定访问承载事故处理时的后台操作;重点是减少会话中断和操作者环境漂移,不是替代 Stripe 的安全规则。
告警和事故证据要留哪些?
告警不要只看 endpoint 是否 200。更有用的是「事件从 Stripe 创建到本地 processed 的延迟」。一笔 checkout.session.completed 5 秒内入库、30 秒内开通,和 3 分钟后才处理,用户感知完全不同。
建议至少做 6 个指标:webhook 2xx 比例、签名失败数、入库失败数、队列堆积长度、dead letter 数、事件处理 P95 延迟。支付业务再加一个「付款成功但权益未开通超过 5 分钟」的业务告警。
事故证据包按时间线整理,不要只贴截图。保留这些字段:Stripe event id、event type、created time、delivery status、HTTP status code、Stripe request id、endpoint URL、部署版本、worker job id、订单 id、用户 id、处理人、人工补偿记录。
如果要联系 Stripe Support,把问题缩小到 Stripe 侧可判断的范围:某个 event id 多次投递失败、Dashboard 显示 TLS 错误、签名 header 异常、某个 endpoint 无法收到任何事件。不要只写「我们的 webhook 坏了」。
什么时候算恢复?
恢复标准不是「最新一条 webhook 200 了」。至少满足 4 条:事故窗口内的关键事件都进入本地事件表;所有 failed 和 dead letter 有处理结论;订单和订阅权益对账无差异;告警在 30 分钟内没有复发。
对账顺序按钱走,查 paid invoice / successful checkout,再查本地订单,再查用户权益。多开通比漏开通更隐蔽,因为用户不会投诉;所以对账脚本要同时找「Stripe 有付款但本地没权益」和「本地有权益但 Stripe 没付款」。
事后复盘只改 1-3 个系统性问题。常见高收益修复是:raw body route 独立、事件表唯一键、worker 幂等、dead letter 告警、replay dry-run、secret 命名规范。一次事故后重写整套支付系统,反而容易引入新问题。