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

在 Cloudflare Workers 上接收 Webhook:從我們自己跑的 Worker 談起

沒有 Node crypto、CPU 時間以毫秒計、waitUntil 不持久。接收端換成 Worker 後有哪些不同,來自 AnyHook 自己的入口服務。

同步處理 · 共用一個時限Stripe驗證 + AI 處理程式45 秒它的逾時到這裡為止重試 → 重複執行邊緣節點先回應 + 非同步傳遞 · 兩個時限Stripe邊緣節點持久寫入 + 200約 1 秒佇列重試處理程式45 秒,沒問題服務商的計時在邊緣節點就停止
把接收端放在 Workers 上的理由是:確認收到和持久儲存都很便宜,所有昂貴的工作都可以在別處進行。

AnyHook 的入口服務(ingress)是一個 Cloudflare Worker。服務收到的每個 Webhook 都先落在這裡:驗證簽章、持久儲存,然後大約一秒就回 200 給發送端,其中大部分時間花在往返佇列所在的區域。它已經在正式環境跑了一段時間,而真正棘手的地方,都不是我原本預期的那些。

這篇是「在 Vercel 上接收 Webhook,不讓事件遺失」的姊妹篇,同樣談接收端。兩者的失敗模式確實不同,而大部分差異都來自 Workers 不是 Node。

重點摘要

  • 預設沒有 node:crypto。簽章驗證要透過 WebCrypto,而它是非同步的
  • 和其他平台一樣:在任何東西解析之前,先以位元組讀取一次請求主體
  • 在串流讀取時就執行你自己的 payload 大小上限,因為讀完一個大主體才發現它很大,順序就錯了
  • ctx.waitUntil 能讓請求延續到回應之後,但不持久,和 Vercel 的完全一樣
  • Workers Free 方案的 CPU 時間上限很小,而且和實際經過的時間分開計算。等待一個慢的 API 不會消耗它,對大 payload 跑密集迴圈才會
  • Worker 很適合用來確認收到和持久儲存,卻不適合放商業邏輯

簽章驗證是非同步的,這會改變你的控制流程

在 Node 上,你會寫一個同步的驗證函式,並且很早就呼叫它。WebCrypto 的方法回傳的是 promise,所以同樣的邏輯變成非同步,於是很容易就想先為了路由解析主體,之後再 await 驗證。不要這樣做。驗證必須排在任何信任 payload 的步驟之前。

下面是我們的入口服務在驗證之前,解密已儲存的服務商密鑰時用的寫法。全部都是 crypto.subtle:

const keyMaterial = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
  "AES-GCM",
  false,
  ["decrypt"],
);

const decrypted = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv, tagLength: 128 },
  keyMaterial,
  dataToDecrypt,
);

有兩個細節,要是吃過苦頭才發現,會花掉不少時間。

WebCrypto 的 AES-GCM 要求驗證標籤(auth tag)接在密文後面,而 Node 的 createDecipheriv 則透過 setAuthTag 另外傳入。如果你在 Node 上加密、在 Worker 上解密,就得自己把兩者接起來。沒有任何東西會告訴你這件事,你只會拿到一個 OperationError。

WebCrypto 沒有標準的 timingSafeEqual。驗證 HMAC 時,與其自己算出摘要再比對字串,不如用 crypto.subtle.verify,它會以固定時間替你完成比對。

你可以開啟 nodejs_compat,取得一個 node:crypto 的相容層(shim),對單純的 HMAC 來說,這是合理的捷徑。但這個相容層底下用的還是同樣的基本元件,而直接針對 WebCrypto 寫,驗證程式碼不用改就能在 Deno、Bun 和瀏覽器裡執行。

以位元組讀取請求主體,太大就提早停止

原始請求主體(raw body)的規則到哪裡都一樣:HMAC 涵蓋的是實際到達的每一個位元組,所以驗證之前什麼都不要解析。先 await request.text() 或 await request.arrayBuffer()。

邊緣接收端特有的問題是:任何拿到你網址的人,都會從你這扇前門進來;先把任意大小的主體緩衝下來,才決定不要它,就等於替別人的流量買單。我們的入口服務會先檢查 content-length,再於串流讀取時再次執行上限,因為 content-length 只是用戶端的聲稱,不是事實:

const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;

const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  totalBytes += value.byteLength;
  if (totalBytes > limitBytes) {
    reader.cancel();          // stop pulling bytes we have decided to reject
    return null;
  }
  chunks.push(value);
}

reader.cancel() 是最值得照抄的部分。少了它,你會把一個已經決定拒收的 payload 完整下載完。

最便宜的關卡排第一

邊緣接收端是公開端點,檢查的順序既是安全決策,也是成本決策。

我們的順序是:先拒收缺少必要標頭的請求(這些標頭是請求自稱的服務商一定會帶的),有設定密鑰時再驗證簽章,最後才把事件計入配額。自稱是 Stripe Webhook 卻沒有 Stripe-Signature 標頭的請求,在任何加密運算執行之前、在任何計數發生之前,就已經被拒收。沒有正確標頭的攻擊者,幾乎消耗不了任何資源。

這個順序要成立,便宜的檢查就必須真的便宜。檢查標頭是否存在,只是一次 map 查詢。簽章驗證是一次金鑰匯入加一次 HMAC。配額是一次 KV 讀取。照這個順序排,昂貴的操作只會用在已經通過前面關卡的流量上。

在這一切之下還有速率限制,依 IP、依 App(一個接收網址,對應一個 Webhook 來源)分別計算,另外還有一個短時間的負向快取(negative cache),記住不存在的 slug,讓掃描有效端點的行為不會變成一張帳單。

再說一次:waitUntil 不是佇列

ctx.waitUntil 讓 Worker 在回應之後繼續存活,好讓背景工作做完。它確實有用,但它不是持久性。

如果 isolate 被逐出,或請求在途中被取消,工作就沒了。沒有重試、沒有死信、沒有紀錄。而你已經回了 200,所以發送端認為事件已送達,再也不會送一次。這個事件現在哪裡都不存在。

waitUntil · 這個 200 是你守不住的承諾驗證回傳 200waitUntil(work)只在記憶體裡被回收 → 消失,不會重新傳送先寫入 · 這個 200 是真的驗證持久寫入回傳 200處理可重試
值得送出的 200,只有背後已經有資料寫下來的那一個。

這和 Vercel 的 waitUntil 是同一個陷阱,也適用同一條規則:先持久儲存,再確認收到。把事件寫到持久的地方,回 200,然後才處理。

我們只把 waitUntil 用在丟了也無妨的工作上,例如遞增計數器。逐出時拒收計數器少算了三次,沒有人會受害。事件 payload 遺失了,就會有人受害。

它的時間也很短。Cloudflare 會在回應後 30 秒取消 waitUntil 的工作,由你交給它的所有 promise 共用,而光是一次模型呼叫就可能超過。各平台該改用什麼,寫在「你的 Webhook 處理程式呼叫 LLM」。

必須留存下來的東西,選項是佇列、持久儲存,或兩者都用。我們的入口服務會發布到一個託管佇列;發布失敗,就把事件暫存到 R2;那也失敗,就把事件放進 Postgres。三者都失敗時,我們回 503 而不是 200,讓發送端自己的重試成為最後一道防線。這個決定值得明說:替一個哪裡都沒存下來的事件回 200,是一個會讓你失去這個事件的謊言。

CPU 時間不是實際經過的時間

Workers 計費和限制的是 CPU 時間,而不是請求花在等待上的時間。從 Lambda 過來的人常搞混,因為在 Lambda 上兩者是同一件事。

等待資料庫寫入或上游 API 不會消耗 CPU。解析一個兩 MB 的 JSON payload 會。對大主體計算雜湊會。跑一個一萬個元素的陣列迴圈也會。

對 Webhook 接收端來說,這大多是好消息,因為你本來就想要的結構幾乎全是 I/O:讀取位元組、驗證、寫入、回應。會害到你的,是大家不假思索就加上去的操作,例如對一個只需要儲存的 payload 做 JSON.parse,或是對整個主體跑正規表示式。

如果你只需要依標頭路由並保存位元組,就完全不要解析主體。以位元組儲存,讓有充裕 CPU 時間的下游消費端去解析。

什麼該放在邊緣,什麼不該

在正式環境跑過之後,這條界線比一開始清楚多了。

適合放在 Worker 上的:在靠近發送端的地方終結 TLS、拒收垃圾流量、驗證簽章、持久儲存 payload、發布到佇列、快速回 200。全都受 I/O 限制、全都便宜,而且全都對延遲敏感,靠近呼叫端確實有好處。

不適合放在 Worker 上的:你的商業邏輯。任何需要長時間存活的連線池、龐大的相依套件、大量 CPU 或 Node 生態系的東西。不是做不到,而是你會毫無收穫地和平台對抗;把接收端放在邊緣的重點,就是讓昂貴的部分在別處、依它自己的步調進行。

這種切分,在邊緣確認收到、以非同步方式傳遞,是 AnyHook 從一開始就採用的架構,而不是我們事後編出來的說法。

一個值得知道的部署陷阱

如果你在同一個 wrangler.toml 裡跑多個環境,不加 --env production 的 wrangler deploy 會部署預設環境。正式環境的 Worker 繼續提供舊的程式碼,什麼錯誤都沒有,因為部署確實成功了,只是不在你想的地方。

我們為此浪費了一個下午。密鑰也一樣:要用 wrangler secret put NAME --env production,否則你設定的地方根本沒有在服務流量。

結論

只要你接受 Worker 的本質,它就是很好的 Webhook 接收端:一扇受 I/O 限制的前門,沒有 Node,waitUntil 不持久,而 CPU 時間上限會懲罰你多做的解析。

在任何東西解析之前先讀取位元組,payload 超過上限就取消串流,把便宜的拒收排在昂貴的之前,而且絕不替還沒持久寫入的東西回 200。其他一切都屬於下游,那裡有的是時間。

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

別再漏掉 Webhook。

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