Stripe webhookが500で再送されても領収書は二度と送られない
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
idempotency claimを勝ち取った後のbest-effortな副作用を裸awaitで呼ぶと、そのthrowがwebhookハンドラ全体を500にする一方、Stripeの再配信は本処理を既にclaim済みとしてスキップするため、副作用だけが永久に再送されなくなる。
結論
idempotency claimを勝ち取った後に呼ぶbest-effortな副作用を裸awaitで呼ぶと、そのthrowがwebhookハンドラ全体を500にする一方で、Stripeの再配信は本処理が既にclaim済みであることを理由に処理そのものをスキップするため、副作用だけが永久に再送されなくなる。 入金反映(本処理)は最初の1回で正しく完了しているため、誰も気づかない。
症状
カード決済のWebhookは、markContractPaidでpayment_statusを条件付きUPDATEでclaimしたあと、領収書発行を呼んでいた。
// persist.ts:34-45(修正前・b8ca5e9時点)
const { data: claimed } = await admin
.from("contracts")
.update(patch)
.eq("id", contractId)
.in("payment_status", ["未請求", "請求済"])
.select("id")
.maybeSingle();
if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op(領収書を再送しない)
// システム発行 = issuerId null。失敗しても入金反映は壊さない(best-effort)。
await issueReceiptAndEmail(admin, contractId, null);
コメントは「失敗しても入金反映は壊さない」と書いているが、実装は裸のawaitのままだった。issueReceiptAndEmailが例外を投げると、その場でthrowがそのまま関数の外へ伝播する。
呼び出し元のwebhookハンドラは、この例外を外側のtry/catchで受けて500を返す設計になっている。
// route.ts:66-81
try {
const { contractId } = await handleStripeEvent(admin, stripe, event);
await admin
.from("stripe_events")
.update({ processed_at: new Date().toISOString(), contract_id: contractId })
.eq("id", event.id);
return Response.json({ received: true });
} catch (e) {
// 台帳行を削除し 500 → Stripe の再配信で再処理させる(at-least-once)。
await admin.from("stripe_events").delete().eq("id", event.id);
const msg = e instanceof Error ? e.message : "error";
await notifySlack(
`⚠️ Stripe Webhook 処理で例外(${escapeSlack(event.type)}: ${escapeSlack(msg)})→再配信待ち/手動確認 ${slackLink("/admin/contracts", "契約")}`
);
return new Response(`error: ${msg}`, { status: 500 });
}
ここで冪等台帳stripe_eventsの該当行は削除される。Stripeから見れば「このevent.idはまだ処理されていない」ことになり、at-least-onceの原則どおり再配信が来る。
ところが再配信が届いたとき、contracts.payment_statusは最初の呼び出しで既に「入金済」へ書き換わっている。markContractPaidのUPDATEは.in("payment_status", ["未請求", "請求済"])という条件付きなので、再配信ではこの条件にマッチせずclaimedはnullになり、if (!claimed) return { claimed: false };で早期returnする。領収書発行の行まで二度と到達しない。
台帳行(stripe_events)のレベルでは「claimされていない」ように見えるのに、業務データ(contracts.payment_status)のレベルでは既に「claim済み」。この2つのclaim状態がずれているのが本質で、再送は前者を信じて後者の状態に初めて気づかず終わる。
原因
「失敗しても本処理は壊さない」という意図自体は正しい設計判断だった。入金反映(本処理)と領収書発行(副作用)を同じ失敗の重大度で扱うべきではない。問題は、その意図をコードの構文で保証していなかったことにある。裸のawaitは、意図に反して例外をそのまま外へ伝播させる。
外側のcatchは「webhookハンドラ全体が失敗した」という前提で書かれており、台帳行の削除とStripeへの500応答という、本処理の失敗時の挙動がそのまま発動する。しかし実際に失敗したのは副作用だけで、本処理(payment_statusの更新)は直前のUPDATE文で既にコミットされている。この「本処理は成功、副作用だけ失敗」という状態を、外側のtry/catchは区別できない。
直し方
issueReceiptAndEmailの呼び出しをtry/catchで囲み、失敗してもpaid状態を維持したままログに残すだけにした。
// persist.ts:42-53(修正後・56f097fb時点)
if (!claimed) return { claimed: false }; // 既に入金済=冪等 no-op(領収書を再送しない)
// システム発行 = issuerId null。失敗しても入金反映は壊さない(best-effort・Reviewer nit:
// 裸 await だと throw で webhook が 500→再配信は claim 負け→領収書だけ永久未送、になる)。
try {
await issueReceiptAndEmail(admin, contractId, null);
} catch (e) {
console.error("[stripe] receipt issue failed (paid state kept):", e);
}
これで領収書発行が失敗しても、外側のcatchまで例外が伝播せず、webhookは200を返して正常終了する。本処理の成功と副作用の失敗が、構文のレベルで分離された。
副作用の失敗はconsole.errorでログに残るだけで、再送による自動復旧は無くなる。これは設計として正しい。再送に頼るのは「まだclaimされていない」場合だけで、今回のように本処理が既に完了している副作用は、再送ではそもそも直らない非対称な失敗だからだ。
再発防止
このコミットでは同時に、冪等台帳へのINSERT失敗を理由を問わず「二重配信」として200で握り潰していた別の分類漏れも直されている(本処理そのものを喪失させる、より重い問題)。「失敗しても本処理は壊さない」というコメントと、それを保証する構文(try/catch)が一致しているかどうかは、markContractPaidのようなbest-effort処理を見るときの固定の確認点になった。コメントに「best-effort」「失敗しても壊さない」と書かれた箇所で、実装が裸のawaitのままになっていないかは、同種の処理を追加・変更するたびに見るべき箇所として残っている。
よくある質問
Q1webhookが500を返しているのに、なぜStripeの再送で直らないのですか?
500を受けたStripeは再配信するが、再配信が届いた時点でDB側のpayment_statusは既に「入金済」に書き換わっている。本処理のUPDATEは「未請求または請求済のときだけ」という条件付きなので、再配信ではこの条件にマッチせずclaimされず、処理そのものが早期returnする。副作用(領収書発行)を呼ぶ行まで到達しないため、再送のたびに同じ理由で素通りする。
Q2try/catchで囲むだけで直るのはなぜですか?
本処理(payment_statusの更新)と副作用(領収書発行)の成功・失敗を非対称に扱えるようにするため。try/catchで副作用の例外を握ってログに残すだけにすれば、webhookは200を返して正常終了し、本処理の成功がそのまま確定する。副作用の失敗は別途ログやSlack通知で拾う対象になり、webhook全体の成否とは切り離される。
Q3似たような「best-effortのつもりの処理」を見分けるコツはありますか?
コメントや変数名に「失敗しても壊さない」「best-effort」と書きながら、実装がbareなawaitのままになっている箇所を探す。コメントの意図と、例外が伝播する実装が一致していないのがこのパターンの特徴で、意図を保証する構文(try/catch)が伴わないコメントは検索語としても使える。
確認した環境
- stripe ^22.3.1 / Next.js 16.2.7
- 2026-07-12 に社内レビュー指摘への対応として修正(コミット 56f097fb)
この記事の根拠
- TypeScriptファイル 42〜45行目コミット b8ca5e9
- TypeScriptファイル 42〜53行目コミット 56f097f
- TypeScriptファイル 66〜81行目コミット 56f097f
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。