Webhook2026年8月3日8 分钟阅读

Shopify 删除了你的 Webhook 订阅:原因全在这 5 秒

Shopify 只给你的端点五秒,四小时内重试八次,然后删除订阅。没有任何报错。订单只是不再进来。

同样的四个步骤叠加起来,对着一堵固定的墙正常时1.2秒ERP 变慢时7.1秒5秒每次投递一上来就越线,所以八次重试会在四小时内全部耗尽代码一行没改。到周四,订阅就被删除了。
这个处理程序没有任何错误。它只是必须在 Shopify 停止等待之前完成。

症状安静得反常。订单不再出现在你的应用里。日志里没有错误,因为根本没有请求到达日志。Shopify 后台里也没有失败的投递,因为已经没有订阅可以显示投递记录了。Shopify 删除了它,除非那封警告邮件进了有人看的邮箱,否则你收到的第一个信号,会是商家来问为什么最近两天的订单都不见了。

Shopify 的 Webhook 规则比大多数平台都严格,而这种严格集中在一个数字上。

TL;DR

  • 五秒。这就是你每次投递的全部响应时限
  • Shopify 会在 4 小时内重试 8 次,连续失败 8 次后,通过 Admin API 创建的订阅会被删除(在 shopify.app.toml 中声明的不会)
  • 从你的应用这边看,删除是无声的。没有流量,也没有错误,因为订阅已经不在了
  • 警告邮件和删除邮件都发到你 Partner 账号的紧急开发者邮箱,而这通常是没人查看的那个地址
  • 恢复意味着重新创建订阅,并且用 Admin API 回填,因为 Shopify 不会补发

五秒到底花在了哪里

GitHub 给你十秒,Stripe 根本没有公布数字,其余平台见我们的 Webhook 平台参考。Shopify 等待响应五秒,更慢的一律当作一次失败的投递。

五秒也不是最低的。Slack 只给三秒,而且执行得很严格,只对接过电商店铺的人很容易在这里中招。Twilio 是个例外,它说明这些数字有多随意:它把旋钮交给你,让你自己设置超时,而几乎没人去调它。

五秒听起来很充裕,直到你把一个典型处理程序内部实际发生的事情写下来:

步骤通常糟糕的一天
冷启动(Serverless)200 毫秒2.5 秒
HMAC 验证2 毫秒2 毫秒
按域名查找店铺30 毫秒400 毫秒
写入订单和订单明细80 毫秒1.2 秒
同步到 ERP / 3PL600 毫秒超时
发送确认邮件250 毫秒3 秒
同样的四个步骤叠加起来,对着一堵固定的墙正常时1.2秒ERP 变慢时7.1秒5秒每次投递一上来就越线,所以八次重试会在四小时内全部耗尽代码一行没改。到周四,订阅就被删除了。

正常路径耗时 1.2 秒,看起来完全健康。然后某天上午你的 ERP 变慢,每一次投递同时超过五秒,四小时内你就耗尽了全部八次重试。你的代码什么都没改。你的代码也没有任何错误。到周四,订阅已经没了。

有两个让情况更糟的因素是 Shopify 特有的。

限时抢购让失败扎堆出现。Webhook 的数量和订单成正比,所以你最需要 orders/create 的时刻,恰恰是数据库最忙、响应时间最差的时刻。这种故障出现的时机,和你承受得起它的时机正好相反。

批量操作会大规模扇出。商家通过应用或 CSV 导入编辑 5,000 个商品,会在很短的时间内产生 5,000 个 products/update Webhook。这时你的端点受限于你自己的数据库连接池,每一次超过五秒的投递都会记在你头上。

删除到底是什么样子

在四小时窗口内失败八次后,Shopify 停止重试这个事件;连续失败 8 次后,通过 Admin API 创建的订阅会被删除,同时向应用的紧急开发者联系人发送一封警告邮件。在 shopify.app.toml 中声明的应用专属订阅不会以这种方式被删除。

Shopify 确实会给你发邮件:检测到反复失败时发一封警告,订阅被删除时再发一封。两封都发往你 Partner 账号上的紧急开发者邮箱。实际上,这个地址通常只在入驻时设置过一次,往往是某个后来已经离职的人的个人邮箱,这也是团队直到出事才知道有这回事的最常见原因。

现在就去查一下这个地址是什么。只要三十秒,这是本文里最有价值的一件事。

删除之后,webhookSubscriptions 对这个主题什么都不返回,没有投递,也没有失败,因为已经没有东西可以失败了。订阅不存在期间产生的事件,也没有在任何地方排队。重新创建订阅后,Shopify 不会补发。

最后这一点让代价变得很高。Stripe 端点被禁用时,事件在 API 中仍然保留 30 天;而 Shopify 订阅被删除,意味着这些 Webhook 根本就没有发出过。恢复是一项基于 Admin API 的对账工作,而不是补发。

自托管的平台在这一点上也不手软:WooCommerce 同样会在连续失败超过五次后禁用 Webhook。

把响应压到五秒以内

唯一可靠的结构是:验证、持久化、应答,真正的工作放到别处去做。超时检测工具能告诉你,你的处理程序现在超出五秒多少;如果慢的那一步是模型调用,你的 Webhook 处理程序调用了大模型(LLM)给出了每个平台上同样结构的做法。

export async function POST(req: Request) {
  const raw = await req.text();               // raw body, required for HMAC
  if (!verifyShopifyHmac(raw, req.headers.get("x-shopify-hmac-sha256"))) {
    return new Response("unauthorized", { status: 401 });
  }

  await queue.publish({                       // durable write, ~10ms
    topic: req.headers.get("x-shopify-topic"),
    shop: req.headers.get("x-shopify-shop-domain"),
    webhookId: req.headers.get("x-shopify-webhook-id"),
    body: raw,
  });

  return new Response(null, { status: 200 }); // well inside 5s
}

有四个细节需要精确把握。

读取原始请求体,而不是解析后的 JSON。Shopify 的 HMAC 是对原始字节计算的,所以任何解析后再重新序列化的中间件都会改变键的顺序或空白,每一次签名验证都会失败。在 Next.js App Router 上,先调用 await req.text(),再做任何解析。各平台的细节见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。

先验证再入队,而不是反过来。验证大约只要 2 毫秒,它是阻止攻击者塞满你队列的那道闸门。便宜的闸门放在前面。

按 X-Shopify-Webhook-Id 去重。同一个事件的所有重试里,它都保持不变,所以它才是正确的幂等键。Shopify 会重复投递,所以你的消费者必须幂等。具体模式见同一个 Webhook 收到了两次。

注意冷启动。在 Serverless 上,冷启动可能在你的代码运行之前就吃掉一半时限。让 Webhook 路由的依赖图保持精简:一个只需要把字节入队的处理程序,却导入了整个 ORM 和 admin SDK,这是一笔实实在在、本可避免的开销。

订阅被删除后如何恢复

第一步,重新创建订阅。

mutation {
  webhookSubscriptionCreate(
    topic: ORDERS_CREATE
    webhookSubscription: {
      callbackUrl: "https://your-app.example.com/webhooks/shopify"
      format: JSON
    }
  ) {
    webhookSubscription { id }
    userErrors { field message }
  }
}

第二步,用 Admin API 对账,补上缺口。既然什么都不会补发,就直接查询故障窗口内的对象,并让它们走你的 Webhook 消费者所用的同一条代码路径:

{
  orders(first: 250, query: "updated_at:>2026-07-28T00:00:00Z") {
    edges { node { id name updatedAt displayFinancialStatus } }
  }
}

让这些数据经过你的幂等消费者。已经处理过的不会产生任何效果;漏掉的会被补上。这也是你发现自己的消费者到底是不是真的幂等的时刻。

第三步,核查丢了哪些主题。列出当前的订阅,和你的应用期望的订阅做对比。删除是按订阅进行的,所以丢了 orders/create 而 products/update 还在,是完全正常的,这让症状更加令人困惑。

第四步,修正紧急开发者邮箱,并在此之上加上你自己的监控。一条“营业时间内 6 小时没有收到 orders/create”的告警,第一天就能发现这个问题。

AnyHook 能做什么

五秒的时限是一个传输问题,而传输正是转发服务要接管的部分。

把 Shopify 订阅指向 in.anyhook.net/you/shopify。无论你的 ERP 是健康还是着了火,AnyHook 都会约 1 秒返回 200。Shopify 的计时现在针对的是这个应答,而不是你的源站,所以源站的缓慢不会再启动那八次重试的倒计时。唯一的例外是 AnyHook 完全无法保存的事件:这时它返回 503 而不是 200,由 Shopify 自己的重试接手。

  • 源站的缓慢和 Shopify 的计时脱钩。我们会间隔 5 分钟、30 分钟、3 小时、18 小时向你的服务器重试,之后每天一次,按套餐共 3 到 10 次尝试。在 Free 套餐上,最后一次尝试发生在第 35 分钟,所以更长的宕机要靠保存下来的日志和重放来兜底
  • 每个事件在尝试投递之前就已持久化,并做了静态加密,所以缺口可以从我们的日志里重放,而不必从 Admin API 重建
  • 连续失败 1 次、5 次和 20 次时触发连续失败告警,发到你自己掌控的地址;连续失败 20 次时自动暂停
  • 一键重放可以覆盖你的服务器宕机的那段时间范围,而重放失败的事件不计入你的月度配额,不过有每日上限,并且会消耗你的计算额度

一个装在许多店铺上的公开应用,只需要一个 AnyHook App,而不是每个店铺一个:把每个店铺的订阅都指向同一个接收 URL。Shopify 用你应用的 client secret 为应用 Webhook 签名,每个店铺都一样,所以 AnyHook 校验的就是这个密钥,而 X-Shopify-Shop-Domain 请求头仍会告诉你的处理程序,每个事件来自哪个店铺。

Shopify 的五秒规则约束的是它直接通信的那个端点。在那里放一个约 1 秒就应答的端点,这条规则就不再约束你的业务逻辑。

总结

Shopify 的五秒不是一个弹性目标,反复达不到的惩罚是删除订阅,而不是一个你能在控制台里看到的暂停状态。把响应路径压缩到验证、持久化、应答,按 X-Shopify-Webhook-Id 去重,再去查一下你的 Partner 账号的紧急开发者邮箱指向哪个地址。你唯一能收到的那封警告,就会发到那里。

全部文章2026年8月3日 · 8 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。