Stripe 的 Workbench 显示 “Succeeded”,你的 server 日志里却什么都没有;GitHub 的 “Recent Deliveries” 是一整列红 X;Shopify 一直在 retry,然后订阅干脆消失了。当业务逻辑依赖 webhook(支付成功后给用户加权限、PR 合并后触发部署、同步订单)时,webhook 挂了就等于产品挂了。
最快的排查: 打开 provider 的投递日志,看最后一次尝试返回的 HTTP 状态码。这一个数字就能告诉你属于下面七种原因里的哪一种。200 代表 provider 已经到达你这里、bug 在你的 handler 内部;404 代表 URL 写错;5xx 或 Timed out 代表你的代码抛异常或太慢。别上来先翻自己的日志猜——provider 已经把发生了什么完整记录下来了。
还有一个跟时间相关、最容易踩的点:响应超时窗口很短。Stripe 要求几秒内返回 2xx,Shopify 每次尝试等 5 秒。如果你的 handler 在响应之前同步做了真正的活(调 LLM、发邮件、跑慢 query),就会超时,于是即便你的代码”跑成功了”,provider 也会报失败。
常见原因
按”真正是元凶”的概率从高到低排列。
1. 端点返回非 2xx,重试耗尽
provider 看到 4xx/5xx 会按指数退避 retry,然后放弃。Stripe 在 live mode 下最长 retry 3 天(sandbox 里几小时内 retry 3 次);Shopify 在约 4 小时内 retry 8 次,如果失败持续,会直接删掉订阅,于是 webhook 就彻底、悄无声息地不再触发了。
如何判断:投递日志显示 “Failed” 并附带 HTTP 状态码,或者订阅干脆从 dashboard 里消失了。
2. webhook URL 拼错
✅ https://api.yourdomain.com/webhooks/stripe
❌ https://api.yourdomain.com/webhook/stripe # 少了 s
❌ https://yourdomain.com/webhooks/stripe # 缺了 api 子域
如何判断:把 dashboard 里的 URL 复制到浏览器或 curl,看是命中你的路由还是 404。
3. 端点只本地可达(localhost / 内网)
dev 时配了 http://localhost:3000,但 webhook 来自公网,根本到不了你的电脑。配私有 IP 或服务只绑在 127.0.0.1 也是同一个问题。
如何判断:URL 含 localhost、127.0.0.1,或私有段(10.x、192.168.x、172.16.x)。
4. handler 太慢,被判超时
Stripe 要求几秒内返回 2xx,Shopify 是每次尝试 5 秒,很多其他 provider 在 5-30 秒之间。handler 在响应前调 LLM、发邮件或跑长 query,就会超过这个窗口、被 retry。
如何判断:投递日志状态是 Timed out,或你端点自己的计时显示响应耗时超过了 provider 的截止时间。
5. 签名验证把合法请求拦了
provider 会给请求签名(Stripe 的 Stripe-Signature、GitHub 的 X-Hub-Signature-256、Shopify 的 X-Shopify-Hmac-Sha256)。你的端点算自己的 HMAC,对不上就拒。最经典的原因是拿 JSON parse 后的 body 去验签、而不是原始字节,或者用的 secret 跟 dashboard 里的对不上。
如何判断:投递日志里出现 400/401/403,body 里常带 “signature” 字样。
6. 防火墙 / CDN 把 provider 拦了
Cloudflare、WAF 或 bot-fight 规则看到 Stripe/1.0 这种非浏览器 user-agent(或不认识的 IP),就发起 challenge 或直接 block。provider 拿到的是 403 或一个挑战页,根本到不了你的 app。
如何判断:CDN/WAF 事件日志在那个尝试时间点上,对 provider 的 IP 或 user-agent 显示了 block 或 challenge。
7. handler 抛了未捕获异常 → 500
代码 bug 让 handler 崩了,框架返回 500,provider 一直 retry 直到放弃。
如何判断:server 日志里有跟投递时间戳对得上的 stack trace。
诊断:先看状态码
| 投递日志里的状态 | 含义 | 跳到 |
|---|---|---|
200-299 | provider 到达了你这里;bug 在 handler 内部 | Step 7(日志) |
404 | URL 错或路由没部署 | Step 2 |
400/401/403 | 签名或 auth 被拒 | Step 5 |
5xx | handler 抛异常 | Step 4 + Step 7 |
Timed out | handler 太慢 | Step 4 |
| 完全没有尝试 | webhook 没启用,或没订阅这个 event 类型 | Step 1 |
最短修复路径
Step 1:读 provider 的投递日志
这是最有用的一步。所有主流 provider 都会记录每一次尝试。
Stripe: Workbench → Webhooks → 点 endpoint → "Event deliveries"
(老账号:Developers → Webhooks → Recent events)
GitHub: Repo/Org Settings → Webhooks → 点这个 hook → "Recent Deliveries"
Shopify: Notifications / webhook 设置 → 看 delivery attempts
(app webhook:看你 app 的日志 + eventbridge/PubSub sink)
Linear: Settings → API → Webhooks → Deliveries
Slack: api.slack.com/apps → 你的 app → Event Subscriptions
每条尝试会显示时间戳、HTTP 状态、response 头/body(前几 KB)和 request payload。GitHub 和 Stripe 上还有 Redeliver 按钮——你修好之后可以重放完全相同的 payload,不用干等真实 event 再来。
Step 2:用 curl 测端点可达性
# 从公网(手机热点或一台服务器)跑,别用公司 wifi/VPN
curl -i -X POST 'https://api.yourdomain.com/webhooks/stripe' \
-H 'Content-Type: application/json' \
-d '{"test": true}'
# 几秒内应该返回 2xx,或一个带具体含义的 4xx
curl 失败或卡住 → 可达性/DNS/防火墙问题(Step 3、6)。curl 命中你的路由 → 问题在 provider 那边的配置(URL、secret、event 订阅)。
Step 3:用稳定的隧道把 dev 端点暴露到公网
现在 ngrok 免费版给每个账号一个静态 dev 域名,所以你不用每次重启都把随机 URL 重新粘到 dashboard 里了。在 ngrok dashboard 上找到分配给你的域名,然后绑定它:
# 启动 server
npm run dev # http://localhost:3000
# 另一个 terminal,绑定分配给你的静态域名
ngrok http --url=your-domain.ngrok-free.dev 3000
# Forwarding https://your-domain.ngrok-free.dev -> http://localhost:3000
# 把 https://your-domain.ngrok-free.dev/webhooks/stripe 配进 provider
如果是 Stripe,干脆跳过 dashboard 这一圈,用 Stripe CLI——它会转发真实 event,并打印本地用的 signing secret:
stripe listen --forward-to localhost:3000/webhooks/stripe
# > Ready! Your webhook signing secret is whsec_...(设成 STRIPE_WEBHOOK_SECRET)
stripe trigger payment_intent.succeeded # 按需触发一个测试 event
Step 4:立刻 ack,异步处理
这是修掉所有超时和大部分 5xx 的关键。验签、把活丢进队列、在远不到一秒内返回 200。Stripe、Shopify、GitHub 的文档都把这个模式列为推荐做法。
// ❌ 同步:真正的活在响应前跑 → provider 超时
app.post('/webhooks/stripe', async (req, res) => {
await processEvent(req.body); // 可能要 30 秒
res.json({ received: true });
});
// ✅ 验签、enqueue、ack —— 全在毫秒级完成
app.post('/webhooks/stripe', async (req, res) => {
const valid = verifySignature(req.headers['stripe-signature'], req.rawBody);
if (!valid) return res.status(400).end(); // 入队前先拒掉
await enqueue({ task: 'process-stripe-event', payload: req.body });
res.json({ received: true }); // < 100ms
});
provider 看到成功,你的 worker 再慢慢处理。
Step 5:正确实现签名验证
两条规则覆盖几乎所有验签失败:用原始 request body(不是重新序列化的 JSON),以及确保 secret 跟 dashboard 里这个端点显示的那个对得上。
// Stripe
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // 验签必须用 raw body
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // 原始 Buffer,不是 parse 后的 JSON
req.headers['stripe-signature']!,
endpointSecret
);
} catch (err) {
console.error('Signature verify failed:', err);
return res.status(400).send('Webhook signature verification failed');
}
res.json({ received: true });
// enqueue(event) 走异步处理
}
);
GitHub 和 Shopify 是同样的结构,只是手动比对 HMAC。GitHub 常见的坑:用了 SHA-1 的 X-Hub-Signature header 而不是 X-Hub-Signature-256、hash 前先 parse 了 body、secret 对不上,或者 hook 设置里 Content-Type 没设成 application/json。
// GitHub:把你的 HMAC-SHA256 跟 X-Hub-Signature-256 比对
import crypto from 'crypto';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.GH_WEBHOOK_SECRET!)
.update(req.rawBody) // 原始字节,不是 JSON.stringify(req.body)
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers['x-hub-signature-256'] as string)
);
Step 6:给 CDN / WAF 加 allow rule
如果投递日志显示 403 或 challenge,就针对 provider 公布的 IP 段,把 webhook 路径放行。
Cloudflare → Security → WAF → Custom Rules → Skip:
When URI Path equals /webhooks/stripe
And (IP Source Address is in [Stripe IP range])
Then Skip: All remaining custom rules, Bot Fight Mode
Stripe、GitHub、Shopify 都公布过它们的出站 IP 段;加白名单这些,而不是把整条路径的防护都关掉。
Step 7:每个 webhook 一进来就 log
如果 provider 说 200 而你啥也没看到,这就是你拿证据的方式。在 handler 的第一行就 log,赶在任何逻辑可能抛异常之前:
app.post('/webhooks/stripe', (req, res) => {
console.log('webhook received', {
type: req.body?.type,
id: req.body?.id,
at: new Date().toISOString(),
});
// ...
});
provider 说投递了但没出现这行 log → 请求根本没到你的代码。卡点在你的 app 前面:CDN、负载均衡、路由,或者 URL 指向的根本不是你正在改的那个部署。
如何确认已修好
- 在 provider dashboard 上对一条最近失败的尝试点 Redeliver(Stripe Workbench 和 GitHub 都有),或跑
stripe trigger payment_intent.succeeded。 - 投递日志现在应该显示
200,并带上你真实的 response body。 - server 日志应该在尝试后一秒内出现 Step 7 那行 “webhook received”。
- 下游效果(加了权限、排了部署、同步了订单)确实发生了——检查这个副作用,而不只是状态码。
四项全绿,才说明整条链路是通的,而不只是往一个黑洞里返回 200。
预防建议
- 上线前用 webhook.site 验一下 URL,确认这条路径公网可达。
- 永远”验签、enqueue、ack”——provider 的截止时间很短(Shopify 每次尝试只有 5 秒),handler 里做超过约 2 秒的活就不安全。
- 验签放在 enqueue 之前、也在响应之前;千万别先返
200再验签(那样攻击者就能用任意 payload 入队)。 - 让 handler idempotent——用 event id(
event.id、GitHub 的X-GitHub-Delivery、Shopify 的X-Shopify-Event-Id)去重,retry 就不会重复处理。 - 每个端点都加监控:投递成功率、handler 延迟、
4xx/5xx/timeout 计数。timeout 缓慢爬升通常预示着故障。 - 让 dashboard 的 signing secret 跟你生产 env 变量保持一致,rotate 时同步改。
- 留意 Shopify 在连续失败后悄悄删订阅——加个告警,好在 event 丢失前重新注册。
相关阅读
常见问题
provider dashboard 说 “Succeeded” / 200,但我的代码根本没跑,这怎么可能?
那个 200 是你 app 前面的某个东西返回的——CDN、负载均衡、一个 catch-all 路由,或者还在用那个 URL 提供服务的旧部署。在 handler 第一行加上 Step 7 的 log。没有这行 log,就说明请求在到达你代码之前就被回复了。确认 dashboard 里的 URL 指向的是你真正在改的那个部署。
活明明只要几秒,为什么我的 handler 还会超时?
因为 provider 的截止时间比你以为的短。Stripe 要求几秒内返回 2xx、Shopify 每次尝试等 5 秒,很多其他家是 5-30 秒。任何同步操作(调 LLM、发邮件、慢 join)都会吃掉这个预算。把活挪到队列里,立刻返回 200(Step 4)。
secret 明明是对的,签名验证还是一直失败,问题出在哪?
几乎都是你 hash 的字节不对。要对原始 request body 签名,而不是重新序列化的 JSON.stringify(req.body)——任何字段重排或空白变化都会让 HMAC 对不上。在 Express 里给 webhook 路由挂 express.raw(),这样 req.body 就是原始 Buffer。另外确认 GitHub 读的是 X-Hub-Signature-256,而不是老的 SHA-1 header。
我的 Shopify webhook 没报错就突然不触发了,它去哪了? Shopify 对失败端点在约 4 小时内 retry 8 次,如果失败跨多个 event 持续,就会删掉订阅。这个 hook 是没了,不是在失败。重新注册它,先修好底层错误,再加监控,好在触达删除阈值之前就发现失败。
不部署的情况下,怎么用本地机器测 webhook?
用隧道。Stripe 用 stripe listen --forward-to localhost:3000/webhooks/stripe,它会转发真实 event 并打印本地 signing secret。其他的用 ngrok http --url=your-domain.ngrok-free.dev 3000——免费版现在给每个账号一个静态 dev 域名,公网 URL 重启后保持稳定,dashboard 里配一次就行。
签名验证失败时,该返回错误状态码吗?
返回 400,不要返 200。400 会出现在投递日志里,你能看到这次拒绝,也能避免你悄悄把未验证的 payload 入了队。别为坏签名返 5xx——那会触发对一个你本就打算拒绝的请求的 retry。