Webhook2026年8月6日閱讀時間 8 分鐘

Paddle 兩天內嘗試 60 次。Stripe 則拉開間隔。

名義賣方(merchant of record)金流的回應時限更緊,重試曲線也更密。獨立開發的 SaaS 改用 Paddle 或 Lemon Squeezy 時,哪些事會跟著變。

Paddle · 大約 2 天內最多 60 次嘗試,第一個小時大約 10 次第一個小時大約 2 天3 天Stripe · 指數退避,最長 3 天只是比較慢的處理程式,被一個猛打,被另一個輕輕試探
重試期間更短,壓力也天差地遠。Paddle 把大約 10 次嘗試集中在第一個小時。

現在有不少獨立開發的 SaaS 都跑在名義賣方(merchant of record)上。Paddle 和 Lemon Squeezy 會處理銷售稅、VAT 和拒付(chargeback),對不想研究歐盟 VAT 門檻的一人創辦者來說,這確實是划算的交換。

它們的 Webhook 和 Stripe 的長得不一樣,而且這些差異只有在負載上來時才會顯現。如果你把 Stripe 的處理程式搬過來,只改了簽章驗證,那有兩個數字值得知道。

重點摘要

  • Paddle 要求五秒內回傳 200;Stripe 完全沒有公布數字。
  • Paddle 正式模式(live mode)在大約 2 天內最多嘗試 60 次,第一個小時大約 10 次,第一天大約 45 次。
  • Paddle 的沙箱環境(sandbox)行為完全不同:大約 2 分鐘內嘗試 3 次。
  • Lemon Squeezy 以 X-Signature 簽章,內容是對原始請求主體(raw body)計算的 HMAC-SHA256 hex 摘要,也可以在儀表板重新傳送。
  • 在這裡,處理程式慢一點,受到的懲罰比在 Stripe 重得多,因為 Paddle 的重試一開始只隔一分鐘,之後才慢慢拉開。

白紙黑字的五秒

Stripe 從不公布數字,社群通常以大約二十秒為準。Paddle 公布了:你的端點要「在收到請求後五秒內回傳 200」。

五秒讓 Paddle 和 Shopify 歸在同一級,而不是和 Stripe;這大約只有你的 Stripe 處理程式當初所依據時限的四分之一。一個履約處理程式要發放授權金鑰、寫入資料庫,再透過第三方 API 寄歡迎信,穩穩落在二十秒內,也穩穩超過五秒。

能撐下來的結構,和在任何地方都撐得下來的那個一樣:驗證、儲存、確認收到,之後再處理。

export async function POST(req: Request) {
  const raw = await req.text();                       // raw bytes, before parsing
  if (!verifyPaddleSignature(raw, req.headers.get("paddle-signature"))) {
    return new Response("unauthorized", { status: 401 });
  }

  await queue.publish(raw);                           // durable, ~10ms
  return new Response(null, { status: 200 });         // inside 5s
}

重試曲線才是真正的差異

Paddle 正式模式的排程:「我們在大約兩天內總共最多嘗試 60 次。大約 10 次嘗試發生在第一個小時,第一天內大約 45 次。」

Stripe 用指數退避,把寥寥幾次嘗試分散在最長三天內。Paddle 的等待從一分鐘開始,慢慢拉長,所以六十次嘗試大多落在前面。

Paddle · 大約 2 天內最多 60 次嘗試,第一個小時大約 10 次第一個小時大約 2 天3 天Stripe · 指數退避,最長 3 天只是比較慢的處理程式,被一個猛打,被另一個輕輕試探

想想這對一個沒壞、只是慢的處理程式代表什麼。資料庫剛好卡了一下,處理程式花了六秒。Paddle 在五秒時逾時並重試。第一個小時內這會發生大約十次,每一次都讓你的處理程式再跑六秒,而且是對著那個本來就是問題根源的資料庫。

和我們 Webhook 服務商參考資料裡的其他發送端並排來看,Paddle 是個異數。指數退避的存在,是為了給陷入困境的系統恢復的空間。Paddle 也把自己的排程稱為指數退避,但這麼集中在前段的曲線,會在你的端點最撐不住的時候,施加最大的壓力。Paddle 並非不講理,因為對計費事件來說,你本來就希望傳遞鍥而不捨;但這確實代表,處理程式慢所付出的代價,累積得比同一個處理程式在 Stripe 上快得多。

這也代表不冪等的處理程式,可能在一小時內把同一件工作做大約十次,而不是一天內做幾次。如果那件工作是「發放授權」或「寄送收據」,你的客戶會發現。

沙箱環境不會讓你看到這些

「對沙箱帳號,我們總共嘗試 3 次,全都在大約兩分鐘內。」

大約兩分鐘內嘗試三次,和大約兩天內嘗試六十次,是兩個不同的系統。你在沙箱環境驗證過的每一個時間假設,到了正式環境都是錯的,而且錯在比較安全的那一邊,這是最糟的一種錯。你的處理程式會一直看起來沒事,直到它開始處理真正的錢。

如果你想測試真正重要的重試行為,就在正式模式下,針對一個測試用的目的地刻意回傳 500,然後觀察第一個小時。

簽章驗證

Paddle 以 Paddle-Signature 標頭簽章,裡面帶有時間戳記和 H1 簽章,大致和 Stripe 的 t=/v1= 格式屬於同一類。Lemon Squeezy 以 X-Signature 簽章,內容是用你 Webhook 的簽章密鑰,對原始請求主體計算的 HMAC-SHA256 hex 摘要。

兩者都有每個服務商共通的陷阱:HMAC 是對實際送達的原始位元組計算的。先解析 JSON 再重新序列化,摘要就會改變,而 log 裡的 payload 看起來完全正確。這是所有服務商之間最常見的 Webhook bug,各服務商的運作細節見驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章。

import crypto from "node:crypto";

function verifyLemonSqueezy(raw: string, header: string | null, secret: string) {
  if (!header) return false;
  const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

timingSafeEqual 之前的長度檢查很重要,因為長度不符時它會直接丟出例外,而不是回傳 false;攻擊者送來的畸形標頭,不應該造成 500。

在 Next.js App Router 上,第一件事就是 await req.text()。在無伺服器環境上,在 Vercel 上接收 Webhook,不讓事件遺失談到那個會悄悄吃掉請求主體串流的 body parser。

冪等性,以及名義賣方特有的麻煩

Paddle 會送出一個可以當作鍵的事件識別碼,做法就是常見的唯一性約束:

INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'paddle') ON CONFLICT (event_id) DO NOTHING;

影響零列,就代表你已經看過這個事件。回傳 200 而不是錯誤,否則你就做出了一個會跑六十次的迴圈。

名義賣方特有的麻煩是:同一位客戶的訂閱生命週期事件可能接連抵達,而重試可能打亂它們的順序。一個一小時後才重試的 subscription.updated,可能落在 subscription.canceled 之後,把訂閱改回啟用狀態。去重幫不上忙,因為這兩個事件各自不同,也都是正當的。你需要一個以事件本身時間戳記為鍵的版本防護,也就是同一個 Webhook 收到兩次裡描述的同一種模式。

以 Paddle 的重試密度來說,舊事件落在新事件之後的機會,比在 Stripe 上多得多。在這裡,這不是紙上談兵的擔憂。

從 Stripe 轉過來:要改哪些地方

把處理程式的時限砍成四分之一。不屬於驗證、儲存、確認收到的工作,都移到 worker 去。

假設重複是常態,而不是少見的例外。一小時大約十次嘗試,會改變你的冪等處理需要多謹慎的盤算。

凡是有生命週期的東西,都加上版本防護。訂閱尤其如此。

不要以沙箱環境為基準來調校。它的重試行為和正式環境完全不同。

保存原始 payload 的紀錄。Lemon Squeezy 可以在儀表板重新傳送,Paddle 也保存傳遞歷史,但兩者都是服務商的副本,跟著服務商的保存期限,也都回答不了「我們的處理程式到底拿它做了什麼」。

AnyHook 的角色

五秒是傳輸層的限制,六十次嘗試是傳輸層的行為。一旦連線由別的東西接手,兩者都不再作用在你的程式碼上。

把通知目的地(notification destination)指向 in.anyhook.net/you/paddle。AnyHook 在邊緣節點驗證並儲存,大約一秒就回 200,所以五秒的時限由一個永遠只做兩件事的 Worker 滿足,六十次嘗試的曲線也永遠不會啟動,因為沒有東西需要重試。

  • 依方案不同,你的處理程式在運算額度內有 60 到 300 秒,資料庫變慢也不會再每隔幾分鐘就招來一次重試。
  • 每個事件都在傳遞之前儲存,儲存時已加密,所以重播一段出錯的部署時間,只要選取範圍再按一下。
  • 對你端點的傳遞以指數退避重試,這才是適合正在恢復中系統的曲線。
  • 連續失敗 1、5、20 次時發出警示,連續失敗 20 次時自動暫停傳遞,讓壞掉的處理程式不會被持續猛打。

你的處理程式仍然需要冪等和版本防護,因為重試和重播都存在。

結論

對獨立開發的 SaaS 來說,名義賣方金流是划算的交換,而它的 Webhook 約定,比你多半正在遷出的那一套更嚴格。五秒、大約兩天內最多六十次嘗試,而且大多集中在前面。用原始位元組驗證,先確認收到再工作,用服務商的事件 ID 去重,並以事件本身的時間戳記保護訂閱的寫入。

在 Stripe 上好好跑了兩年的處理程式,到這裡不會自動沒事,而告訴你這件事的,也不會是沙箱環境。

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

別再漏掉 Webhook。

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