工程2026年8月3日閱讀時間 9 分鐘

在 Vercel 上接收 Webhook,不讓事件遺失

簽章驗證莫名失敗、waitUntil 的工作憑空消失、冷啟動吃掉回應時限:無伺服器上會踩到的坑,以及怎麼處理。

waitUntil · 這個 200 是你守不住的承諾驗證回傳 200waitUntil(work)只在記憶體裡被回收 → 消失,不會重新傳送先寫入 · 這個 200 是真的驗證持久寫入回傳 200處理可重試
屬實的 200 和只是希望的 200,差別在這裡。

無伺服器上的 Webhook 處理程式,失敗的方式就固定那幾種,而且會一再重演,但幾乎每一種看起來都不像它真正的問題。payload 明明可以證明完全正確,簽章驗證就是不過。排在回應之後的工作始終沒有執行。本機每項測試都通過的處理程式,只在正式環境、只在高負載時才逾時。

這些都不是 Vercel 故意刁難。這是以單一請求為生命週期的執行模型,遇上一個假設伺服器長時間在線的協定,所造成的結果。下面是最常出現的五個,依照它們找上你的順序排列。

重點摘要

  • 在任何東西解析請求主體之前,先 await req.text(),否則每一次簽章驗證都會失敗。
  • 別急著設 runtime = "edge"。Fluid Compute 上的 Node.js 才是正確的預設。
  • waitUntil 讓函式在回應之後繼續執行,但它沒有持久性。它不是佇列。
  • 冷啟動的時間算在服務商的逾時時限裡,而 Shopify 只給你 5 秒。
  • 處理程式真正的時限,是最嚴格那家服務商的時限,不是 Vercel 的。

1. 原始請求主體(raw body)問題

這是每個人第一個撞上的問題,錯誤訊息也完全幫不上忙,因為記錄下來的 payload 看起來毫無破綻。

每個服務商簽章的,都是它送出的那串原始位元組。Stripe 把時間戳記加在原始請求主體前面再做 HMAC,Shopify 對原始請求主體做 HMAC,GitHub 也對原始請求主體做 HMAC。從請求進來,到你呼叫驗證之前,只要有任何一層把 JSON 解析後重新序列化,鍵的順序或空白就差了一個位元組,HMAC 也就對不上了。資料一模一樣,簽章卻不一樣。

HMAC 算的是位元組,不是語意原始位元組{"id":"evt_1","n":2}req.text()hmac相符解析後再轉回字串{ "n": 2, "id": "evt_1" }req.json()hmac不相符同樣的資料同樣的資料,不同的鍵順序、不同的空白,而且 payload在你的 log 裡看起來完全正常。

在 App Router 裡,先讀成文字,再從手上那個字串解析:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const raw = await req.text();                     // exact bytes, before any parsing
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("invalid signature", { status: 400 });   // no details to the caller
  }

  await queue.publish(event);
  return new Response(null, { status: 200 });
}

有兩件事要記清楚。

絕對不要先呼叫 await req.json()。這樣你就再也拿不回原始位元組:串流已經被讀完,而你重新序列化出來的東西,並不是當初被簽章的內容。

Pages Router 必須明確關閉 body parser。如果你還在用 pages/api,就設定 export const config = { api: { bodyParser: false } },自己讀取原始串流。大多數「本機正常、部署後就壞」的回報,追到最後都是少了這一行或它的等效寫法。

各服務商的簽章格式(Stripe 的 t=/v1= 格式、GitHub 的 sha256= 前綴、Shopify 的 base64),詳見驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章。另外,服務商的 SDK 有驗證函式就用它,不要自己手刻:它會幫你做常數時間比較和時間戳記的容許時間差檢查,這兩件事都很容易在細節上出錯。

2. 別把 Webhook 處理程式放在 Edge runtime

很多人一直有個直覺:接收 Webhook 的路由必須夠快,所以應該設成 runtime = "edge"。這是過時的建議。

Vercel 的預設是 Fluid Compute 上的 Node.js,它會讓並行的請求共用執行個體,當初讓 Edge 顯得吸引人的冷啟動差距,大致已經補平了。另一方面,Edge runtime 會讓你失去 Webhook 處理程式真正需要的東西:完整的 node:crypto、多數服務商 SDK 預設存在的 Node API,以及更長的執行時間。另一個常被拿出來講的理由是串流回應,但串流在 Node.js 上運作正常,而且已經好一陣子了。

具體的失敗是這樣:服務商 SDK 呼叫 crypto.createHmac,在 Edge 上直接丟出錯誤,於是你改用 WebCrypto 手刻驗證,而防時序攻擊的比較,就在這個手刻版本裡悄悄消失了。你拿一項實實在在的安全保障,去換一個大致已經消失的冷啟動差距。

維持預設就好。

如果你眼前已經有一個本機正常、正式環境卻失敗的處理程式,在正式環境除錯失敗的 Webhook 有一張決策樹,幫你縮小範圍,找出是哪一層吃掉了請求。

3. waitUntil 不是佇列

在 Vercel 上要做到「先快速回應,之後再處理」,最自然的做法是 waitUntil:

import { waitUntil } from "@vercel/functions";

export async function POST(req: Request) {
  const event = await verify(req);
  waitUntil(syncToSalesforce(event));      // continues after the response
  return new Response(null, { status: 200 });
}

它真的很有用,但它真的不提供持久性。這個差別比聽起來更要緊:

  • 執行個體如果被回收,或在執行途中當掉,工作就沒了。沒有重試,沒有死信佇列,沒有任何紀錄。
  • 你已經回傳 200 給服務商了。它永遠不會重新傳送。這個事件從此不存在於任何地方。
  • waitUntil 裡的失敗不會讓請求失敗,所以除非你明確地捕捉並回報,否則根本看不到。

這是最糟的一種失敗型態:很少發生、無聲無息,事後也無從重建,因為事件唯一的副本只存在記憶體裡。

waitUntil 適合用在丟出去就不管、丟了也無妨的工作:分析用的 ping、快取預熱、一則 Slack 通知。只要是客戶會發現少了的東西,就不該用它。

waitUntil · 這個 200 是你守不住的承諾驗證回傳 200waitUntil(work)只在記憶體裡被回收 → 消失,不會重新傳送先寫入 · 這個 200 是真的驗證持久寫入回傳 200處理可重試

讓你遠離麻煩的規則是:先儲存,再確認收到。把事件寫進 Postgres、Redis 或真正的佇列,然後才回傳 200,接著再處理。這時候 200 說的就是實話,因為事件確實在你手上,處理過程要失敗、要重試幾次都沒關係。

export async function POST(req: Request) {
  const event = await verify(req);
  await db.insert(events).values({ id: event.id, payload: event }); // durable
  waitUntil(processNow(event.id));   // fast path; a cron sweeps anything left behind
  return new Response(null, { status: 200 });
}

快速路徑處理正常情況,掃描則處理執行個體掛掉的情況。兩者都不會弄丟事件。

4. 冷啟動算在服務商的計時裡

Vercel 預設的執行上限是 300 秒,聽起來逾時好像根本不是問題。其實不然,因為替你計時的不是 Vercel,而是呼叫你的那一方,而他們給的時限緊得多:

服務商回應時限
Slack、Discord3 秒
Shopify5 秒
GitHub10 秒
Twilio15 秒
Stripe未公開

處理程式真正的逾時時限,取決於你接收的服務商裡最嚴格的那一家,所以值得弄清楚你面對的每一個時限,而不是只記得其中一個。我們的 Webhook 服務商參考資料列出了全部。以 Shopify 的五秒來說,2 秒的冷啟動在你第一行程式執行之前,就已經用掉 40% 的時限。逾時檢查工具會對你的路由送出兩個請求並計時,冷的那次會和熱的那次並排顯示。如果慢的是模型呼叫,你的 Webhook 處理程式呼叫 LLM 這篇談到 after(),以及它的極限在哪裡。

真正有幫助的做法:

  • 讓路由的 import 依賴圖保持精簡。一個只負責驗證和排入佇列的 Webhook 路由,不應該間接 import 你的 ORM、admin SDK 和寄信用的用戶端。這些東西每一個都會在冷啟動時被解析。
  • 把笨重的模組改成在需要它的分支裡才 import,不要放在模組頂層。
  • 把 Webhook 路由的 maxDuration 設低一點。export const maxDuration = 10 不會讓你變快,但能把失控的處理程式變成一次快速失敗,讓服務商重試,而不是卡住 300 秒,燒掉運算資源,卻一點進展也沒有。

5. 凌晨 3 點,並行不是你的朋友

無伺服器會水平擴展,這在限時搶購時正是你要的,卻也正好打破了大多數冪等程式碼背後的假設。

一百個並行的執行個體,就是一百個並行的處理程式,每一個都在開資料庫連線。Postgres 有連線數上限,而且比你想的還低。平台上的連線池就是為此而設;跳過它,一波流量高峰就會讓每個請求都碰到 too many connections,包括真實使用者的請求。

更隱晦的一個:同一個事件的兩次重試,可能在同一毫秒落在兩個執行個體上。先讀再寫的冪等邏輯,在這裡有個競態條件,循序執行的測試永遠抓不到。這件事要交給唯一性約束來做,而不是一個 if:

// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);

// Safe: the database arbitrates
const { rowCount } = await db.query(
  `INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
  [event.id],
);
if (rowCount === 0) return ok();
await doWork(event);

更多細節,包括順序錯亂的傳遞,見同一個 Webhook 收到兩次。

AnyHook 的角色

除了原始請求主體那一項,上面每一項其實都是同一個問題:你要求一個以請求為生命週期的執行環境,做出它自己無法兌現的持久性承諾。

AnyHook 把這個承諾移出函式。服務商 POST 到 in.anyhook.net/you/app,然後:

  • 大約一秒就會回 200,回應來自邊緣節點上的一個 Worker,它唯一的工作就是儲存並確認收到。Shopify 的五秒不再是你的限制。
  • 事件在我們確認收到之前就已持久儲存,而且儲存時已加密,所以「執行個體掛了」只代表一次重試,不代表遺失。
  • 傳遞到你的 Vercel 函式失敗時,會依 5 分鐘、30 分鐘、3 小時、18 小時、之後每天一次的間隔重試,依方案共 3 到 10 次嘗試,仍然失敗的事件會留在事件紀錄裡,可以重播。
  • 單一的 AnyHook-Signature 標頭取代各服務商不同的驗證方式,而且每次嘗試都以新的時間戳記重新簽章,重試時就不會像轉送過來的服務商簽章那樣,超出容許時間差而驗證失敗。
  • 可以依時間範圍重播,部署期間吃掉一小時事件的時候就用得上。

你的處理程式仍然必須是冪等的,這一點沒有任何東西能免除。但它不再需要同時做到持久、快速、隨時保持熱機。

想在註冊任何東西之前先看看,一行指令就能拿到一個可用的接收網址(inbound URL),不需要帳號:

curl -X POST https://anyhook.net/api/v1/quickstart

回應裡有一個 inbound_url,填到服務商那邊;一個 api_key,用來透過 API 讀取事件紀錄;還有一個 claim_url。這個端點是暫時的:除非你打開認領網址並登入,否則 7 天後它和它的事件都會被刪除。登入認領後,網址維持不變。

結論

先讀原始請求主體,留在 Node.js runtime,把 waitUntil 當成盡力而為的機制,而不是佇列。如果只能從這篇帶走一個架構上的觀念,那就是:事件還沒確實寫下來之前,絕對不要回傳 200。清單上其他問題都是效能問題;只有這一個是資料遺失問題,也是唯一一個事後無法除錯的問題。

Workers 版本的清單差異夠大,所以我們另外寫了一篇:在 Cloudflare Workers 上接收 Webhook。

所有文章2026年8月3日 · 9 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。