メールの件名は「Your webhook endpoint is failing」のような文面です。最初の 1 通を無視していると、次のメールではエンドポイントが無効化されたと告げられます。今は何も届いていません。サブスクリプションはプロビジョニングされず、請求書の照合も進みません。被害がどこまでさかのぼるのかもわかりません。
良い知らせもあります。Stripe は配信の成否にかかわらず、すべてのイベントのコピーを 30 日間保存しています。その期間内に動けば、ほぼすべてを復旧できます。悪い知らせは、ほとんどの人がエンドポイントを再有効化し、新しいトラフィックに緑のチェックマークが並ぶのを見て、空白期間を補完しないままにすることです。お金を失うのは、その空白期間です。
TL;DR
- まずエンドポイントを再有効化します。ただし再開されるのは新しいイベントだけで、何も再送されません
GET /v1/events?delivery_success=falseで補完します。さかのぼれるのは 30 日です- 補完の前に冪等にしておいてください。そうしないと注文が二重処理されます
- 根本原因はほぼ必ず、壊れたハンドラーではなく遅いハンドラーです
- 処理する前にペイロードを永続化すれば、これは緊急事態ではなくなります
Stripe がそもそもエンドポイントを無効化する理由
本番環境では、Stripe は失敗した配信を指数バックオフで最長 3 日間リトライします。これは寛大な猶予で、デプロイや短い障害で何も失わないようにするためのものです。このスケジュールと、この記事で扱う状態に陥る前にそれを前提として設計する方法は、「Stripe Webhook のリトライ:本番運用ガイド」で説明しています。
ただし、3 日は標準ではありません。標準だと思い込むと、次の連携で痛い目を見ます。Paddle は、失敗した同じイベントを約 2 日間で最大 60 回送ります。「Paddle は 2 日で 60 回試行する。Stripe は間隔を空ける。」では 2 つのスケジュールを並べて比べています。両方で課金している場合に特に重要です。
失敗し続けるエンドポイントは、それとは別のシグナルです。そのアドレスはもう機能していません。叩き続けても Stripe の配信能力を無駄にし、イベントログをノイズで埋めるだけです。そこで Stripe はメールで警告し、何も変わらなければエンドポイントを無効化する日付を伝え、最終的に無効化します。
覚えておくべきなのは、無効化は 2 回目の通知であって、最初の通知ではないということです。無効化される頃には、失敗はもう何日もダッシュボードに表示されています。警告メールが誰も読まない創業者のメールボックスや、バウンスしたエイリアスに届いていたなら、それこそが本当のバグです。
ステップ 1:再有効化の前に、何が壊れたのかを突き止める
すぐに Enable(有効化)をクリックしたくなっても、こらえてください。根本原因が残ったままなら、リトライの猶予期間をもう一度使い果たすことになります。
原因の特定はそれだけで一仕事で、放っておくほど難しくなります。調べている間にも、失敗した配信が古くなってダッシュボードから消えていくからです。どの順で調べるかは、「Webhook が本番環境で失敗するとき:週末をつぶさずにデバッグする」にまとめています。
ダッシュボードの Developers → Webhooks(開発者 → Webhook)、または ワークベンチ(Workbench)の Webhook タブに移動し、エンドポイントを開いて最近の配信を確認します。Stripe は配信試行ごとに受け取ったレスポンスを記録しています。次のどれに当たるかを見ます。
| 表示 | 意味 |
|---|---|
Timed out | ハンドラーが遅すぎる状態。群を抜いて多い原因 |
500 / 502 | コード、またはその手前のプラットフォームで発生したエラー |
503 | アプリがダウン中、またはゼロまでスケールダウンして起動しなかった状態 |
401 / 403 | アプリの手前にある何かによる Stripe のブロック。WAF、Cloudflare のルール、認証ミドルウェアなど |
404 | リファクタリングでルートが移動し、エンドポイント URL が誰にも更新されていない状態 |
401/403 は、ボット対策や新しい認証レイヤーを追加した後によく引っかかります。Webhook エンドポイントへのリクエストはセッション Cookie を持たないため、匿名のトラフィックとして扱われるからです。404 はたいてい、フレームワークの移行の後に現れます。
まずそれを直します。有効化はその後です。
ステップ 2:再有効化し、それで起きないことを理解する
再有効化で再開されるのは、その時点以降に作成されたイベントの配信です。すでに諦められたイベントが、自動で送り直されることはありません。Stripe のドキュメントに記載されている狭い例外が 1 つあります。Stripe の次のリトライの前に無効化と再有効化を済ませれば、進行中のリトライはそのまま届きます。エンドポイントが 1 週間無効のままだったなら、ひとりでに戻ってくるものは何もありません。
つまり今は、新しいイベントは流れていても、過去の空白期間は手つかずのままです。
ステップ 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 から消えています。エンドポイントが 5 週間前に無効化されていたなら、最初の 1 週間分は Stripe 経由では復旧できません。代わりに charges と subscriptions のオブジェクトから照合することになり、ずっとつらい午後を過ごすことになります。
ステップ 4:冪等にする。できないなら補完は実行しない
補完の対象には、Stripe がタイムアウトを記録した後でハンドラーが処理を終えたイベントが含まれることがあります。また、今手作業で処理しているものの一部を、Stripe がまだリトライしているかもしれません。handleEvent が冪等でなければ、領収書を 2 通送り、ライセンスキーを 2 つ発行し、注文を 2 回発送することになります。
安上がりな方法ですが、本当にこれで十分です。
INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;
影響を受けた行が 0 なら、そのイベントは処理済みなので、そこで止めます。可能なら、この INSERT は副作用と同じトランザクションで実行します。副作用がトランザクションに参加できない外部 API の呼び出しなら、まず行を processing にし、終わった後に processed にします。そうすれば途中でクラッシュしても、黙って永遠にリトライされるのではなく、目に見える形で残ります。
順不同の配信や、少なくとも 1 回の配信(at-least-once)が意図的な設計判断である理由も含め、この問題の全体像は「同じ Webhook が 2 回届いた」で書きました。
ステップ 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 との同期はワーカーで行います。ワーカーなら、Stripe に時間を計られることなく、自分のスケジュールでリトライできます。これで遅い外部サービスは、配信の失敗ではなく、ただの遅いジョブになります。
残りの半分はアラートです。知るべきタイミングは、Stripe が無効化の日付をメールしてくる何日も後ではなく、5 回目の連続失敗の時点です。監視が Stripe 自身の警告メールだけなら、それが人の読む場所に届くことを必ず確かめてください。
AnyHook の位置づけ
AnyHook は Stripe とサーバーの間に入り、遅いオリジンがエンドポイントの無効化につながらないようにします。Stripe はアプリではなく in.anyhook.net/you/stripe と通信し、AnyHook はサーバーの状態にかかわらず約 1 秒で 200 を返します。
- Stripe がタイムアウトを目にすることはないので、無効化への道に入ることもありません
- 配信を試みる前に、すべてのイベントをヘッダーとボディごと永続化します。保存時は暗号化されます
- 再送はボタンを押すだけです。サーバーが 14:00 から 16:00 までダウンしていたなら、その時間帯を再送します。再送は Stripe のログではなく自分のログから行い、イベントは Free なら 3 日、Pro なら 30 日、Scale なら 90 日の間、再送できます。失敗したイベントの再送は月間クォータにカウントされません。ただし 1 日の再送上限があり、コンピュート枠を消費します
- 連続失敗のアラートは 1 回、5 回、20 回で送られ、20 回で自動的に一時停止します。壊れたデプロイがリトライの嵐になることはありません
実際の違いはこうです。前段に中継サービスがあれば、上の復旧手順は、時間帯を選んで Replay(再送)をクリックするだけになります。「サーバーがダウンしていた間の Webhook を再送する方法」を参照してください。
まとめ
エンドポイントの再有効化は、復旧の 10% にすぎません。残りの 90% は、30 日の期間が終わる前に空白期間を補完することです。そして、Stripe が二度と無効化する理由を持たないよう、ハンドラーを十分に速くすることです。Webhook ハンドラーが外部 API をその場で呼び出しているなら、すでにその道を歩いています。まだ悪い午後を迎えていないだけです。