Stripe webhook冪等台帳のINSERT失敗、23505以外も200で握り潰していた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
stripe_events台帳へのINSERT失敗を理由を問わず二重配信の200 no-opとして返していたため、UNIQUE制約違反(23505)以外のDB一時障害でもStripeは成功と解釈して再送せず、イベントが恒久的に失われていた。
結論
Stripeのwebhookハンドラは、冪等台帳stripe_eventsへのINSERT失敗を理由を問わず二重配信とみなし、200 no-opを返していた。 UNIQUE制約違反(23505)だけがStripeからの再配信によるものだが、DBの一時的な書込障害でも同じ200が返るため、Stripeはそのイベントの配信が成功したと解釈して二度と再送しない。イベントは処理されないまま恒久的に失われる。
何が起きていたか
Stripeのwebhookルートは、受信したイベントをまず冪等台帳stripe_eventsにINSERTする。ここでevent.idが既に存在すればUNIQUE制約違反(23505)でINSERTが失敗し、それは「同じイベントの再配信」を意味するので処理をスキップして200を返せばよい。
修正前のコードは、このINSERTが返したerrorをそのまま「二重配信の合図」として扱っていた。
// 冪等台帳へ event.id を INSERT。一意制約違反 = 二重配信 → 200 no-op。
const { error: insErr } = await admin.from("stripe_events").insert({
id: event.id,
type: event.type,
payload: {
api_version: event.api_version,
object: (event.data.object as { object?: string })?.object ?? null,
},
});
if (insErr) {
return Response.json({ received: true, duplicate: true });
}
if (insErr)はerrorオブジェクトが存在するかどうかしか見ていない。コメントは「一意制約違反 = 二重配信」と書いているが、実装はそれを検査していない。UNIQUE制約違反であろうと、それ以外の理由(コネクション断・タイムアウトなどDBの一時的な書込障害)であろうと、insErrが入っていれば同じifブロックに入り、同じ200 no-opが返る。
原因
Stripeのwebhookはat-least-once(少なくとも1回)配信を前提にした仕組みで、受信側が200を返さない限り再送し続ける。逆に言えば、受信側が200を返した時点でStripeは「このイベントは処理された」とみなし、以後そのイベントを再送しない。
このコードは、stripe_eventsへのINSERTが失敗するケースを「同じevent.idが既に台帳にある(=過去に受理済みで再配信された)」場合しか想定していなかった。しかしINSERTはそれ以外の理由でも失敗しうる。DBの一時的な接続断や書込エラーが起きたとき、実際にはそのイベントは一度も台帳に記録されておらず、処理も一度も行われていない。にもかかわらず、コードはUNIQUE制約違反と区別せず200を返すため、Stripeにとっては「配信成功」の記録として扱われ、二度と同じイベントを送ってこなくなる。
イベントが失われた場合の実害は、そのイベントが何を表していたかに依存する。例えばカード決済の成功イベントが失われれば、Stripe側では課金が成立しているのにportal側の契約は未請求のまま残る。staffがそれに気づかずに従来の振込請求書を送れば、カード課金と請求書の二重請求になる。
直し方
insErr.codeを見て、23505のときだけ200 no-opを返すように分岐した。それ以外のINSERT失敗はログに残したうえで500を返し、Stripeの再配信に処理を委ねる。
// 冪等台帳へ event.id を INSERT。**一意制約違反(23505)だけ**が二重配信 → 200 no-op。
// それ以外の INSERT 失敗(DB一時障害等)を 200 で飲むとイベントが恒久喪失し、
// 「カード支払済みなのに契約は未請求→staffが請求書を送る」二重請求に直結する(Reviewer P2(a))
// → 500 を返して Stripe の再配信に委ねる(at-least-once)。
const { error: insErr } = await admin.from("stripe_events").insert({
id: event.id,
type: event.type,
payload: {
api_version: event.api_version,
object: (event.data.object as { object?: string })?.object ?? null,
},
});
if (insErr) {
if (String(insErr.code ?? "").includes("23505")) {
return Response.json({ received: true, duplicate: true });
}
console.error("[stripe] event ledger insert failed:", insErr.message);
return new Response("ledger insert failed", { status: 500 });
}
500を返すことで、Stripeのat-least-once配信がそのまま再送のリトライ機構として働く。一時的な書込障害であれば次回の再送でINSERTが成功し、イベントは正しく台帳に記録されて処理される。200を返す条件を「UNIQUE制約違反であること」に絞ったことで、200が「本当にStripeが再送を止めてよい状態」を意味するようになった。
なぜ気づけなかったか
if (insErr)という書き方は、Supabaseクライアントのerrorが「何かがおかしい」ことを示す汎用の値である、という前提で書かれている。だがこのINSERTに限っては、errorが入るケースが1種類(UNIQUE制約違反)だけだという思い込みがあり、実装時にその1種類以外を想定していなかった。
冪等台帳へのINSERTという操作は、「失敗=想定内の重複」という成功パターンと「失敗=想定外の障害」という失敗パターンの両方を、同じerrorという形で返してくる。コメントに書いた前提(一意制約違反=二重配信)をコードが実際に検査していないと、if (insErr)のような一言判定は静かに両方を同じ枝に落とし込む。エラーの「有無」ではなく「種類」を見る必要がある処理では、コード側で種類を検査するところまで書かないと、コメントの前提は実装を保証しない。
よくある質問
Q1なぜUNIQUE制約違反以外のINSERT失敗も二重配信として扱われていたのですか?
webhookハンドラは冪等台帳stripe_eventsへのINSERTが返したerrorを、理由を区別せず『if (insErr)』の一言だけで判定していたためです。event.idの一意制約に違反した場合(23505)も、DBの一時的な書込障害も、この条件では同じ枝に落ち、どちらも『二重配信』として200 no-opを返していました。
Q2この分類漏れがあると実際に何が起きるのですか?
DBの一時障害でINSERTが失敗すると200が返るため、Stripeはそのイベントの配信が成功したと解釈して再送しません。イベントはstripe_events台帳に記録されないまま処理もされず、恒久的に失われます。決済イベントの取りこぼしは、カードで支払済みの契約が未請求のまま残り、staffが気づかず請求書を二重に送ってしまう二重請求のリスクに直結します。
Q3どうやって直しましたか?
INSERTがerrorを返した際に、insErr.codeが23505(UNIQUE制約違反)を含む場合だけ200 no-opを返すよう分岐しました。それ以外のエラーはconsole.errorでログに残したうえで500を返し、Stripeのat-least-once再配信の仕組みに処理を委ねています。
Q423505とはどのようなエラーコードですか?
PostgreSQLのSQLSTATEコードで、UNIQUE制約違反を表します。stripe_events台帳ではevent.idに一意制約がかかっており、Stripeから同じevent.idのイベントが再配信されたときにだけこのコードでINSERTが失敗します。それ以外の理由でのINSERT失敗は23505になりません。
確認した環境
- stripe ^22.3.1 / Next.js 16.2.7
- 2026-07-12 に社内レビュー指摘への対応として修正
この記事の根拠
- TypeScriptファイル 44〜57行目コミット b8ca5e9
- TypeScriptファイル 44〜64行目コミット 56f097f
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。