Webhook 收不到:provider 显示 200,你的端点却没动静

Stripe、GitHub、Shopify 都说投递成功了,server 却没收到。先看投递日志里的状态码,再对症修复。

Stripe 的 Workbench 显示 “Succeeded”,你的 server 日志里却什么都没有;GitHub 的 “Recent Deliveries” 是一整列红 X;Shopify 一直在 retry,然后订阅干脆消失了。当业务逻辑依赖 webhook(支付成功后给用户加权限、PR 合并后触发部署、同步订单)时,webhook 挂了就等于产品挂了。

最快的排查: 打开 provider 的投递日志,看最后一次尝试返回的 HTTP 状态码。这一个数字就能告诉你属于下面七种原因里的哪一种。200 代表 provider 已经到达你这里、bug 在你的 handler 内部;404 代表 URL 写错;5xxTimed out 代表你的代码抛异常或太慢。别上来先翻自己的日志猜——provider 已经把发生了什么完整记录下来了。

还有一个跟时间相关、最容易踩的点:响应超时窗口很短。Stripe 要求几秒内返回 2xxShopify 每次尝试等 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 含 localhost127.0.0.1,或私有段(10.x192.168.x172.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-299provider 到达了你这里;bug 在 handler 内部Step 7(日志)
404URL 错或路由没部署Step 2
400/401/403签名或 auth 被拒Step 5
5xxhandler 抛异常Step 4 + Step 7
Timed outhandler 太慢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 指向的根本不是你正在改的那个部署。

如何确认已修好

  1. 在 provider dashboard 上对一条最近失败的尝试点 Redeliver(Stripe Workbench 和 GitHub 都有),或跑 stripe trigger payment_intent.succeeded
  2. 投递日志现在应该显示 200,并带上你真实的 response body。
  3. server 日志应该在尝试后一秒内出现 Step 7 那行 “webhook received”。
  4. 下游效果(加了权限、排了部署、同步了订单)确实发生了——检查这个副作用,而不只是状态码。

四项全绿,才说明整条链路是通的,而不只是往一个黑洞里返回 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,不要返 200400 会出现在投递日志里,你能看到这次拒绝,也能避免你悄悄把未验证的 payload 入了队。别为坏签名返 5xx——那会触发对一个你本就打算拒绝的请求的 retry。

标签: #后端 #排查 #排查