這個症狀安靜得出奇。訂單不再出現在你的應用程式裡。log 裡沒有錯誤,因為根本沒有東西進到 log。Shopify 管理介面裡也沒有失敗的傳遞,因為已經沒有訂閱可以顯示傳遞了。Shopify 把它移除了,除非警告信寄到了有人會看的信箱,否則你收到的第一個訊號,會是商家來問為什麼最近兩天的訂單都不見了。
Shopify 的 Webhook 規則比大多數服務商嚴格,而這份嚴格集中在一個數字上。
重點摘要
- 五秒。這就是你每次傳遞的全部回應時限。
- Shopify 會在 4 小時內重試 8 次,連續失敗 8 次後,透過 Admin API 建立的訂閱就會被刪除(在
shopify.app.toml裡宣告的不會)。 - 從你的應用程式來看,刪除是無聲無息的。沒有流量,也沒有錯誤,因為已經沒有訂閱了。
- 警告信和刪除通知信會寄到你 Partner 帳號的緊急開發者電子郵件(emergency developer email),而那通常是沒人在看的信箱。
- 復原代表要重新建立訂閱,而且還要用 Admin API 回填,因為 Shopify 不會重播。
那五秒實際上花在哪裡
GitHub 給你十秒,Stripe 完全沒有公布數字,其他服務商的數字都在我們的 Webhook 服務商參考資料裡。Shopify 等回應等五秒,比這更慢的都視為傳遞失敗。
五秒也還不是最低。Slack 給的是三秒,而且嚴格到讓只串接過電商的人措手不及。Twilio 是個例外,也正好說明這些數字有多任意:它把旋鈕交給你,讓你自己設定逾時,但幾乎沒有人去轉它。
五秒聽起來綽綽有餘,直到你把一個典型處理程式裡實際發生的事寫下來:
| 步驟 | 一般情況 | 狀況差的日子 |
|---|---|---|
| 冷啟動(無伺服器) | 200 毫秒 | 2.5 秒 |
| HMAC 驗證 | 2 毫秒 | 2 毫秒 |
| 依網域查詢商店 | 30 毫秒 | 400 毫秒 |
| 寫入訂單和品項明細 | 80 毫秒 | 1.2 秒 |
| 同步到 ERP/3PL | 600 毫秒 | 逾時 |
| 寄送確認信 | 250 毫秒 | 3 秒 |
順利的情況下是 1.2 秒,看起來完全健康。接著某天早上 ERP 變慢,每一次傳遞同時超過五秒,你在四小時內用光全部八次重試。程式碼一行都沒改。程式碼也沒有任何錯。到了週四,訂閱就不見了。
有兩個讓情況更糟的因素,是 Shopify 特有的。
限時搶購會讓失敗集中發生。Webhook 的數量和訂單數成正比,所以你最需要 orders/create 的時刻,正是資料庫最忙、回應時間最差的時刻。這種失敗發生的時機,恰好和你承受得起的時機完全相反。
大量操作會一口氣產生大量 Webhook。商家透過應用程式或 CSV 匯入編輯 5,000 個商品,就會在短時間內產生 5,000 個 products/update Webhook。這時端點的速度被你自己的資料庫連線池卡住,而每一次超過五秒的傳遞都會算在你頭上。
刪除實際上是什麼樣子
在四小時內失敗八次之後,Shopify 就不再重試那個事件;連續失敗 8 次後,透過 Admin API 建立的訂閱會被移除,並寄一封警告信給應用程式的緊急開發者聯絡人。在 shopify.app.toml 裡宣告的應用程式專屬訂閱,不會以這種方式被移除。
Shopify 確實會寄信給你:偵測到反覆失敗時寄一封警告,訂閱被刪除時再寄第二封。兩封都寄到你 Partner 帳號上的緊急開發者電子郵件。實務上,這個地址只在當初註冊時設定過一次,常常是某位早已離職的人的個人信箱,這也是團隊被這件事嚇到最常見的原因。
現在就去確認那個地址是什麼。只要三十秒,這也是這篇文章裡最有價值的一件事。
刪除之後,webhookSubscriptions 對那個主題(topic)什麼都不回傳,也沒有任何傳遞或失敗,因為已經沒有東西可以失敗了。訂閱不存在期間產生的事件,也沒有在任何地方排隊等著。重新建立訂閱後,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
}
有四個細節值得講清楚。
讀原始請求主體(raw body),不要讀解析過的 JSON。Shopify 的 HMAC 是對原始位元組計算的,所以任何會解析再重新序列化的 middleware,都會改變鍵的順序或空白,讓每一次簽章驗證都失敗。在 Next.js App Router 上,任何解析之前先呼叫 await req.text()。各服務商的細節見驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章。
先驗證再排入佇列,而不是反過來。驗證大約只要 2 毫秒,它是阻止攻擊者塞滿你佇列的那道關卡。便宜的關卡放最前面。
用 X-Shopify-Webhook-Id 去重。同一個事件的所有重試,它都不會變,所以它是正確的冪等鍵。Shopify 會重新傳送,所以你的消費端必須冪等。做法見同一個 Webhook 收到兩次。
注意冷啟動。在無伺服器上,程式碼還沒開始執行,冷啟動就可能吃掉一半的時限。讓 Webhook 路由的依賴圖保持精簡:一個只需要把位元組排入佇列的處理程式,卻 import 整個 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 都會大約一秒就回 200。Shopify 的計時現在針對的是這個回應,而不是你的伺服器,所以伺服器變慢,不會再啟動那八次重試的倒數。唯一的例外是 AnyHook 完全無法儲存的事件:這時它會回 503 而不是 200,改由 Shopify 自己的重試接手。
- 你的伺服器變慢,和 Shopify 的計時脫鉤。我們會依 5 分鐘、30 分鐘、3 小時、18 小時、之後每天一次的間隔,重試傳遞到你的伺服器,依方案共 3 到 10 次嘗試。在 Free 方案,最後一次嘗試在 35 分鐘時,所以更長的服務中斷,要靠儲存下來的事件紀錄和重播來補。
- 每個事件都在嘗試傳遞之前先儲存,儲存時已加密,所以缺口可以從我們的事件紀錄重播,而不必從 Admin API 重建。
- 連續失敗 1、5、20 次時會發出警示,寄到你能掌控的信箱,連續失敗 20 次時自動暫停。
- 一鍵重播可以涵蓋伺服器當機的那段時間範圍,而且重播失敗的事件不計入每月配額,不過重播有每日上限,也會用到你的運算額度。
跨多家商店的公開應用程式,只需要一個 AnyHook App(一個接收網址,對應一個 Webhook 來源),不必每家商店各一個:把每家商店的訂閱都指向同一個接收網址(inbound URL)。Shopify 用你應用程式的用戶端密鑰(client secret)替應用程式的 Webhook 簽章,每家商店都一樣,所以 AnyHook 驗證的就是這個密鑰;X-Shopify-Shop-Domain 標頭仍然會告訴你的處理程式,每個事件是哪家商店送來的。
Shopify 的五秒規則,講的是它直接對話的那個端點。在那裡放一個大約一秒就回應的端點,這條規則就不再限制你的商業邏輯。
結論
Shopify 的五秒不是可以通融的目標,一再錯過的懲罰是刪除,而不是一個你能在儀表板上看到的暫停端點。把回應路徑精簡到驗證、儲存、確認收到,用 X-Shopify-Webhook-Id 去重,然後去確認你 Partner 帳號的緊急開發者電子郵件指向哪個地址。你唯一會收到的警告,就會寄到那裡。