信件主旨大概是「Your webhook endpoint is failing」之類的。如果你忽略了第一封,後續那封會說端點已經被停用。現在什麼都收不到了。訂閱沒有開通,帳單無法對帳,你也不知道損害要往回追溯多久。
好消息是:不論傳遞是否成功,Stripe 都會把每個事件保存 30 天。只要在這段期間內動手,幾乎都能復原。壞消息是:多數人重新啟用端點,看到新流量一片綠色勾勾,就再也沒有回填中間的缺口。會讓你賠錢的,正是這個缺口。
重點摘要
- 先重新啟用端點,但這只會恢復新事件的傳遞,不會重新傳送任何東西。
- 用
GET /v1/events?delivery_success=false回填,可以往回查 30 天。 - 回填之前先做好冪等,否則你會重複履約。
- 根本原因幾乎都是處理程式太慢,而不是壞掉。
- 處理之前先把 payload 存下來,這件事就不再是緊急狀況。
為什麼 Stripe 會停用端點
在正式模式(live mode)下,失敗的傳遞 Stripe 會以指數退避重試,最長三天。這段期間很寬裕,目的是讓一次部署或短暫的服務中斷不會讓你損失任何東西。Stripe Webhook 重試機制:正式環境指南詳細說明了這個排程,以及在走到本文所說的狀態之前,該怎麼為它設計。
不過三天不是標準,把它當成標準,下一次整合就會吃虧。同一個失敗的事件,Paddle 會在大約兩天內送出最多六十次。Paddle 兩天內嘗試 60 次,Stripe 則拉開間隔把兩種排程並排比較,如果你同時透過兩者收款,這點最重要。
持續失敗的端點,則是另一種訊號:這個網址已經不能用了,繼續猛打只會浪費 Stripe 的傳遞容量,也讓你的事件紀錄淹沒在雜訊裡。所以 Stripe 會寄信警告你,告訴你如果情況沒有改變,端點會在哪一天被關閉,最後就真的把它關掉。
值得記住的是:停用是第二個通知,不是第一個。停用發生時,這些失敗已經在 Dashboard 上看得到好幾天了。如果警告信寄到沒人看的創辦人信箱,或是一個會退信的別名信箱,那才是真正的 bug。
步驟 1:重新啟用之前,先找出壞在哪裡
先忍住,別急著按 Enable(啟用)。如果根本原因還在,你會再燒掉一次重試期間。
找出原因本身就是一件工作,而且拖得越久越難,因為你還在看的時候,失敗的傳遞紀錄就會從 Dashboard 上過期消失。在正式環境除錯失敗的 Webhook,不賠上你的週末整理了值得照著走的順序。
在 Dashboard 前往 Developers → Webhooks(或 Workbench → Webhooks),打開這個端點,查看最近的傳遞。Stripe 會記錄每次嘗試收到的回應。你要找的是下面哪一種:
| 你看到的 | 代表什麼 |
|---|---|
Timed out | 處理程式太慢。這是最常見的原因,遙遙領先 |
500 / 502 | 你的程式碼拋出例外,或是它前面的平台出錯 |
503 | 你的應用程式當機了,或縮減到零之後沒有再被喚醒 |
401 / 403 | 應用程式前面有東西擋住了 Stripe:WAF、Cloudflare 規則、驗證中介軟體 |
404 | 路由在重構時搬家了,卻沒有人更新端點網址 |
401/403 常在加上機器人防護或新的驗證層之後冒出來,因為 Webhook 請求不帶 session cookie,會被當成匿名流量。404 則通常出現在框架遷移之後。
先修好它,再啟用。
步驟 2:重新啟用,並弄清楚它不會做什麼
重新啟用後,恢復傳遞的是從那一刻起建立的事件。已經被放棄的事件,不會自動重新傳送。只有一個很窄的例外,Stripe 的文件有寫:如果你在 Stripe 下一次重試之前停用又重新啟用,進行中的重試仍然會送達。如果端點已經停用了一週,不會有任何東西自己回來。
所以你現在的狀態是:新事件正常流入,過去的缺口原封不動。
步驟 3:從 Events API 回填
這是大家會跳過的部分。Stripe 的 List Events API 能查到最近 30 天的每個事件,而且接受 delivery_success 篩選條件:
curl -G https://api.stripe.com/v1/events \
-u "$STRIPE_SECRET_KEY:" \
-d delivery_success=false \
-d "types[]=payment_intent.succeeded" \
-d "types[]=customer.subscription.created" \
-d "types[]=invoice.paid" \
-d limit=100
這裡有幾件重要的事:
依事件類型篩選,不要全部拉。忙碌的帳號會產生很多你根本沒有處理程式的事件類型。只拉你真正會處理的。
用 ending_before 搭配自動分頁。傳入服務中斷開始前不久的某個事件 ID,自動分頁就會依時間先後回傳結果。這很重要:customer.subscription.created 應該在 customer.subscription.updated 之前處理,而預設排序是最新的在前。
const missed = stripe.events.list({
delivery_success: false,
ending_before: "evt_lastKnownGood",
types: ["payment_intent.succeeded", "invoice.paid"],
limit: 100,
});
for await (const event of missed) {
await handleEvent(event); // must be idempotent (see below)
}
30 天是硬性界線。超過的事件,API 上就查不到了。如果你的端點五週前就被停用,第一週的事件已經無法透過 Stripe 復原,你只能改從 charges 和 subscriptions 物件對帳,那會是一個糟糕得多的下午。
步驟 4:先做好冪等,否則不要跑回填
你回填的事件裡,可能有處理程式在 Stripe 記下逾時之後才跑完的;而你現在手動處理的事件,有些 Stripe 可能還在重試。如果 handleEvent 不是冪等的,你會寄出兩封收據、發出兩組授權金鑰,或把同一筆訂單出貨兩次。
便宜的做法,而且真的夠用:
INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;
影響的資料列為零,代表你已經看過這個事件,應該停下來。能做到的話,把這個 INSERT 和副作用放在同一個交易裡。如果副作用是無法加入交易的外部 API 呼叫,先把這列標成 processing,完成後再標成 processed,這樣處理途中當掉就看得出來,而不是無聲地永遠重試下去。
這個問題更完整的版本,包括順序錯亂,以及為什麼至少一次(at-least-once)是刻意的設計選擇,我們寫在同一個 Webhook 收到兩次。
步驟 5:修好真正的原因
依我們的經驗,端點被停用,幾乎從來不是因為處理程式寫錯,而是因為它太慢。典型的樣子是這樣:
// The shape that eventually gets your endpoint disabled
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await db.insertOrder(event); // 40ms
await salesforce.sync(event); // 900ms, sometimes 8s
await resend.sendReceipt(event); // 300ms, sometimes times out
await slack.notify(event); // 200ms
return Response.json({ ok: true });
}
在平靜的週二,這是 1.5 秒,沒有人會注意。等到 Salesforce 哪天下午狀況不好,它就卡住,每次傳遞同時逾時,你離端點被停用只剩幾天。而出問題的依賴,甚至不是你的。
解法是結構性的,不是把逾時調大:
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await queue.publish(event); // durable write, ~10ms
return new Response(null, { status: 200 });
}
接收、儲存、確認收到。把 Salesforce 同步放到 worker 裡做,它可以照自己的排程重試,不用 Stripe 在旁邊計時。這樣一來,慢吞吞的第三方只是一個慢的工作,而不是一次傳遞失敗。
另一半是警示。你要在連續第五次失敗時就知道,而不是幾天後等 Stripe 寄來停用日期。如果你唯一的監控就是 Stripe 自己的警告信,務必確認它寄到有真人在看的地方。
AnyHook 的角色
AnyHook 位於 Stripe 和你的伺服器之間,讓變慢的伺服器不會演變成被停用的端點。Stripe 改打 in.anyhook.net/you/stripe,而不是你的應用程式;不管你的伺服器在做什麼,我們大約一秒就回 200。
- Stripe 永遠看不到逾時,所以永遠不會走上停用這條路。
- 每個事件在嘗試任何傳遞之前,都會連同完整標頭和請求主體儲存起來,儲存時已加密。
- 重播只是一個按鈕。如果你的伺服器在 14:00 到 16:00 當機,就重播那段時間。重播的資料來自你自己的事件紀錄,不是 Stripe 的,事件在 Free 方案可重播 3 天、Pro 方案 30 天、Scale 方案 90 天。重播失敗的事件不計入每月配額,不過有每日上限,也會用到你的運算額度。
- 連續失敗 1、5、20 次時會發出警示,到 20 次時自動暫停,避免一次壞掉的部署變成重試風暴。
實務上的差別是:前面有一層 Webhook 中繼服務(webhook relay),上面那套復原步驟,就縮成選一段時間範圍、按下重播。詳見伺服器當機時漏掉的 Webhook,怎麼重送。
結論
重新啟用端點只是復原工作的 10%。另外 90%,是在 30 天期限結束前回填缺口,並確保處理程式夠快,讓 Stripe 再也沒有理由停用它。如果你的 Webhook 處理程式在請求當下直接呼叫第三方 API,你已經走在這條路上了,只是那個糟糕的下午還沒來。