症状安静得反常。订单不再出现在你的应用里。日志里没有错误,因为根本没有请求到达日志。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 / 3PL | 600 毫秒 | 超时 |
| 发送确认邮件 | 250 毫秒 | 3 秒 |
正常路径耗时 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 账号的紧急开发者邮箱指向哪个地址。你唯一能收到的那封警告,就会发到那里。