個人開発・サービス運用

Stripe決済の二重課金を防ぐべき等性の内部挙動と実装設計

Stripeの決済をLaravelアプリへ組み込むとき、最初に動作確認したいのは「決済が成功するか」でしょう。Checkout Sessionを作成し、決済画面へ遷移し、成功ページが表示される。ここまでは比較的スムーズに進みます。…

Stripe決済の二重課金を防ぐべき等性の内部挙動と実装設計

Stripe決済の二重課金を防ぐ冪等性の内部挙動と実装設計

しかし、サービスを公開したあとに問題になりやすいのは、決済そのものではなく、その後に動くWebhook処理です。Webhookの受信処理が同じイベントを2回実行すると、ユーザーへのポイント付与、サブスクリプションの有効化、デジタル商品の開放、注文ステータスの更新などが重複する可能性があります。実装によっては、二重課金に見える状態や、二重付与による直接的な損失にもつながります。

StripeのWebhookは、イベントを必ず一度だけ届ける仕組みではありません。配信は「最低1回」が前提であり、通信障害や受信側のタイムアウト、2xx以外の応答などをきっかけに、同じイベントが複数回届くことがあります。ですから、Webhookを受け取ったら一度だけ処理されるはずだと考えるのではなく、同じイベントが再び届いても結果が壊れない仕組みを、受信側で設計しておく必要があります。

StripeのWebhookでは、重複配信は異常系ではありません。最初から起こり得る正常な動作として、受信側で受け止める設計にしておきましょう。

Stripe Webhookが「最低1回」配信される仕組みとリスク

Webhookは、Stripe側で発生したイベントを、登録済みのエンドポイントへHTTPリクエストとして通知する仕組みです。たとえば決済が完了すると、Checkout Sessionの完了やPaymentIntentの状態変更など、決済に関連するイベントが送信されます。

ここで注意したいのは、Webhookの配信保証が「Exactly-once」、つまり正確に一度だけ届く保証ではないことです。StripeのWebhookは「at-least-once」、最低1回保証の方式です。

受信側がイベントを受け取って処理したあと、Stripeへ正常に応答を返せなかった場合、Stripeから見ると「処理されたかどうか」が分かりません。ネットワークの途中で応答が失われた可能性もありますし、受信側の処理が完了する前に接続が切れた可能性もあります。そのためStripeは、失敗したと判断したWebhookを再送します。

特に、次のような状況では再送が発生します。

  • WebhookエンドポイントがHTTP 2xx以外のステータスコードを返した
  • ハンドラーの処理が長引き、30秒以上応答しなかった
  • アプリケーションサーバーやネットワークに一時的な障害が起きた
  • デプロイや再起動のタイミングで、リクエスト処理と応答が不安定になった

Stripeは失敗したWebhookに対して指数バックオフで再送を行い、最長3日間にわたってリトライします。つまり、Webhookを受信した直後に処理が成功していたとしても、Stripe側でその成功応答を確認できなければ、同じイベントがあとからもう一度到着する可能性があります。

二重課金と二重付与は分けて考える

「Webhookが重複すると二重課金になる」とひとまとめに説明されることがありますが、実際には二つの問題を分けて考えたほうが、設計を整理しやすくなります。

一つ目は、Stripe APIへ決済処理を依頼する段階で、同じリクエストを複数回送ってしまう問題です。ユーザーが決済ボタンを連打したり、アプリ側がタイムアウト後に同じ決済作成処理を再実行したりすると、複数の決済が作られる可能性があります。

二つ目は、決済自体は一度なのに、Webhookを重複処理してしまう問題です。この場合、Stripe上の請求が二重になっていなくても、アプリ内でポイントを二重付与したり、注文を複数回作ったりすることがあります。

発生する場所主な原因防御する仕組み
決済作成リクエストボタン連打、タイムアウト後の再送、アプリ側の再実行Stripe APIのIdempotency-Key、UIの連打防止
Webhook受信処理Stripeによる自動再送、受信側の応答失敗event.id の重複排除、受信イベントの保存
業務データの更新同じ注文や同じ購入対象を複数回処理注文IDやユーザーIDなどのビジネスキー、DBの一意制約
非同期ジョブキューの再試行、ワーカー停止後の再実行ジョブの冪等化、状態遷移の制約、処理履歴

この表のように、Webhookだけを冪等化しても十分ではありません。決済を作る入り口、Webhookの受信、業務データの更新という複数の場所に、それぞれ異なる重複対策が必要です。

Webhook受信側で必須となる冪等性の考え方

冪等性とは、同じ処理を複数回実行しても、最終的な結果が一度実行した場合と変わらない性質です。

たとえば「注文を未決済から決済済みに変更する」という処理は、すでに決済済みならもう一度実行しても状態が変わらないようにできます。一方、「ユーザーのポイントを1,000ポイント加算する」という処理は、同じイベントを2回実行すると2,000ポイント増えてしまうため、そのままでは冪等性がありません。

ポイント付与を安全にするには、単純な加算の前に「このイベントによる付与処理は、すでに完了しているか」を確認する仕組みが必要です。処理済みであれば何もせず終了し、未処理の場合だけ付与と履歴保存を行います。

このとき、アプリケーションのメモリ上に配列やMapを持ってイベントIDを記録する方法は、個人開発の初期段階でも避けたほうがよいでしょう。サーバーが再起動すれば記録は消えますし、複数台構成や複数のコンテナで動かすと、別のインスタンスが同じイベントを処理できます。開発環境では動いているように見えても、本番のデプロイやスケール時に再現性の低い障害になります。

重複管理の記録先には、データベースまたはRedisを使います。ただし、最終的に決済や購入履歴などの永続データを更新するのであれば、まずは同じデータベース内で一意制約を使う設計が扱いやすいでしょう。

署名検証は冪等性とは別の防御

Webhookで最初に行うべきなのは、Stripeから送信されたリクエストであることの確認です。Webhook署名を検証し、署名が不正なリクエストを業務処理へ進ませないようにします。

ここで混同しやすいのが、署名検証と冪等性は別の仕組みだという点です。

署名検証は「誰から送られたリクエストか」を確認するものです。一方、冪等性処理は「このイベントを、すでに処理していないか」を確認するものです。正しい署名を持った同じイベントが再送されることはありますから、署名が正しいからといって重複処理してよいわけではありません。

LaravelのWebhookエンドポイントでは、概ね次の順番で処理を組み立てると、責任の境界が分かりやすくなります。

1. リクエスト本文と署名ヘッダーを使い、Stripeの署名を検証する

2. イベントの種類と必要なオブジェクトを確認する

3. event.id が過去に処理済みかデータベースで確認する

4. 未処理であれば、注文や購入権限などの業務データを更新する

5. 処理結果とイベントIDを保存する

6. StripeへHTTP 2xxを返す

ただし、これは単純な流れを説明したものです。実際には、3から5までをどのトランザクションで囲むか、処理が失敗した場合にイベントをどう扱うかまで決めなければなりません。

event.id を使った重複排除の実装

WebhookイベントにはイベントIDが含まれます。StripeのイベントIDは、たとえば evt_xxx のような形式で扱われます。この値をデータベースに保存し、同じIDが再び到着した場合は処理をスキップします。

Laravelで実装する場合は、Webhook受信履歴を専用テーブルとして持つ構成が分かりやすいでしょう。テーブルには少なくとも次のような情報を保存します。

  • StripeのイベントID
  • イベントの種類
  • 関連するCheckout SessionやPaymentIntentのID
  • 処理状態
  • 処理開始日時と完了日時
  • エラー内容
  • 作成日時、更新日時

この中で特に重要なのは、StripeのイベントIDに一意制約を付けることです。アプリケーションコードで「存在しなければ作成する」と書くだけでは、同じタイミングで2つのリクエストが到着したときに、両方が「まだ存在しない」と判断する競合が起こります。

データベース側のUNIQUE制約があれば、同じイベントIDの登録を一つに制限できます。重複リクエストの一方が一意制約違反になった場合は、そのイベントがすでに別の処理で受け付けられたと判断して、業務処理を二重に実行しないようにします。

イベントIDだけに依存しすぎない

event.id はWebhookイベント単位の重複排除に向いています。ただし、同じCheckout Sessionや同じPaymentIntentに対して、異なる種類のイベントが送信されることがあります。

たとえば、あるイベントIDが未処理だからといって、それだけを見て注文を作成すると、別のイベントタイプを受け取ったときに同じ注文が作られる可能性があります。そのため、イベントIDの重複排除に加えて、業務上のキーも設計しておく必要があります。

業務上のキーとは、たとえば次のような値です。

  • 自社の注文ID
  • Checkout Session ID
  • PaymentIntent ID
  • サブスクリプションID
  • ユーザーIDと購入対象を組み合わせた値

どのキーを使うかは、サービスの決済モデルによって変わります。単発購入であれば、自社注文IDとPaymentIntentの対応を一意に管理する方法が考えられます。継続課金であれば、サブスクリプションIDや請求書に関連するIDを中心に、契約状態を管理するほうが自然です。

event.id は「同じ通知」を見分ける鍵です。注文IDやPaymentIntent IDは「同じ業務上の取引」を見分ける鍵であり、両方を持つことで重複処理の抜け道を減らせます。

重複イベントを受け取ったときの応答

すでに処理済みのイベントが到着した場合、Webhook側は処理を繰り返さず、HTTP 2xxを返して受領済みであることを伝えます。

ここでエラーを返すと、Stripeは未処理と判断して再送を続ける可能性があります。重複を検出できているのに失敗扱いにしてしまうと、必要のない再送を増やし、ログや監視を見づらくしてしまいます。

ただし、イベントIDが登録済みでも、処理状態が「完了」なのか「処理中」なのか「失敗」なのかは区別してください。単純にレコードが存在するだけでスキップすると、処理途中でアプリケーションが停止した場合に、未完了の処理を永久に放置することがあります。

そのため、イベント履歴には少なくとも次のような状態を持たせるとよいでしょう。

  • 受付済み
  • 処理中
  • 完了
  • 失敗
  • 再処理待ち

状態を持たせると、運用時に「Stripeから届いていない」のか、「届いているが処理に失敗した」のかを切り分けやすくなります。個人開発では、障害対応のための管理画面を最初から大きく作る必要はありませんが、イベントID、イベントタイプ、処理状態、エラー内容を検索できるだけでも復旧の速度が変わります。

トランザクションと処理済みマークのタイミング

冪等性の実装で、特に事故につながりやすいのが処理済みマークのタイミングです。

よくある失敗は、業務処理を始める前にイベントを処理済みとして保存する方法です。重複イベントを早く止めたい気持ちは理解できますが、その直後にポイント付与や注文更新でエラーが起きると、Stripe側では決済が成功しているのに、自社システムでは購入権限が付与されない状態になります。

つまり、重複処理は防げても、未処理のまま残った決済を復旧できなくなります。

データベース内で完結する更新であれば、イベントの受付記録、注文状態の更新、購入履歴の保存を同じトランザクションに含める方法が基本になります。トランザクションが成功した場合だけ処理完了として確定し、途中で例外が発生した場合は、関連する更新をロールバックできる構成です。

ただし、外部API呼び出しやメール送信、キュー投入などを同じトランザクションの中に含めると、別の問題が起こります。データベースのロールバックは、Stripeやメールサービス側の処理まで取り消してくれません。

たとえば、データベース更新の途中で外部サービスへメール送信を行い、そのあとにトランザクションが失敗すると、メールだけ送られた状態になります。Webhookが再送された場合には、メールが二重に送られる可能性もあります。

業務処理と副作用を分ける

安定した構成にするには、Webhookの受信処理と、時間のかかる副作用を分けて考えるとよいでしょう。

Webhookでは、署名検証とイベントの保存、最低限の業務状態の確定をできるだけ短時間で行います。そのあと、メール送信や請求書生成、外部システムとの連携などはキューへ渡します。

ただし、キューに入れれば自動的に安全になるわけではありません。キューのジョブも、ワーカーの再起動や一時的な障害によって再実行される可能性があります。したがって、ジョブ側にも注文IDやイベントIDを使った重複チェックが必要です。

たとえば「購入完了メールを送信するジョブ」であれば、メール送信済みの状態を注文に記録し、送信済みなら再送しないようにできます。ポイント付与であれば、ポイント付与履歴に注文IDやイベントIDの一意制約を付ける方法が考えられます。

個人開発のサービスでは、最初から複雑なイベント駆動基盤を導入する必要はありません。まずは次の責任を分けるだけでも、障害時の再現性が高まります。

  • Webhook受信処理は、署名検証とイベント受付に集中させる
  • 決済状態の更新は、データベーストランザクションで確定させる
  • メールや外部連携は、再実行されても壊れないジョブにする
  • 失敗したイベントとジョブを、あとから追跡できるようにする

処理済みフラグを早く記録する場合

実際には、処理済みフラグを早い段階で記録したいケースもあります。たとえば、同じイベントが短時間に集中して到着し、高コストな処理を重複して開始したくない場合です。

この場合は、単純な「処理済み」という真偽値ではなく、処理状態と再試行条件を設計します。処理中にサーバーが停止したとき、一定時間が経過した処理中レコードを再処理対象に戻す仕組みが必要です。

また、処理開始前にイベントを予約する場合でも、業務処理の完了とイベントの状態更新が別々になるなら、失敗時の回復経路を必ず用意してください。管理画面からの再処理、コマンドによる再実行、一定時間後の自動リトライなど、どれを採用するかを決めておくと、公開後の対応が落ち着きます。

Webhookは一度受け取ったら終わりではなく、失敗した処理をどう回復するかまで含めて一つのプロセスです。ここを後回しにすると、決済サービスの管理画面と自社の注文データを人手で突き合わせる作業が発生します。

Stripe APIのIdempotency-KeyとWebhookの違い

Stripeには、APIリクエストの重複作成を防ぐためのIdempotency-Keyがあります。これは、決済作成などのAPIリクエストを送る側で指定するキーです。

Webhookで使う event.id は、Stripeから届いたイベントを重複処理しないためのキーです。一方、Idempotency-Keyは、自社アプリからStripe APIへ送るリクエストを同じ操作として扱うためのキーです。役割が異なるため、どちらか一つだけ導入すればよいものではありません。

たとえば、ユーザーが購入ボタンを押したときに、アプリが注文レコードを作成し、その注文に対応する決済作成リクエストをStripeへ送るとします。このとき、同じ注文に対して同じIdempotency-Keyを使うことで、通信タイムアウト後にアプリがリクエストを再送する場合でも、同じ操作として扱いやすくなります。

キーには、リクエストのたびにランダムな値を作るのではなく、自社の注文IDなど、同じ業務操作に対して再利用できる値を使う設計が向いています。ただし、異なる決済内容や別の注文に同じキーを使うと、意図しない衝突を招くため、キーの生成単位は明確にしてください。

UIの連打防止は最後ではなく最初に行う

決済ボタンの連打防止も重要です。ボタンを押した直後に無効化し、処理中であることを表示するだけでも、ユーザー操作による重複リクエストを減らせます。

ただし、UIのボタンを無効化するだけでは十分ではありません。ブラウザーの再送、通信状態の変化、別タブからの操作、JavaScriptエラーなど、UIの外側から同じリクエストが届く可能性があるためです。

そのため、UI、アプリケーション、Stripe API、Webhook受信、データベースという複数の層で重複を抑えます。

1. 画面では、決済ボタンを連打できない状態にする

2. アプリでは、同じ注文に対する決済作成を重複させない

3. Stripe APIでは、リクエストにIdempotency-Keyを付ける

4. Webhookでは、event.id を保存して同じ通知をスキップする

5. 業務データでは、注文IDやPaymentIntent IDに一意制約を付ける

この多層防御は、どこか一つの仕組みに全責任を負わせないためのものです。たとえばUIが正しく動作しなかったとしても、アプリ側で止められます。Webhookが再送されても、イベント履歴と一意制約で重複処理を防げます。

Laravelで設計するときに見落としやすいポイント

LaravelでWebhookを実装するときは、通常のフォーム送信と同じ感覚で作らないことが大切です。Webhookはユーザーがブラウザーで待っている画面ではなく、Stripeが一定時間内の応答を待つサーバー間通信です。

処理に30秒以上かかるとタイムアウトになるため、Webhook内で重い集計、外部APIの連続呼び出し、大量のメール送信などを実行する構成には注意してください。受信したイベントを検証して保存し、必要な処理をキューへ渡すほうが、応答時間を安定させやすくなります。

ルーティングとミドルウェア

Webhookのルートでは、通常のWebフォームと異なる扱いが必要になる場合があります。LaravelのCSRF保護が有効なルートへ外部サービスからPOSTすると、署名検証へ進む前にリクエストが拒否されることがあります。

Webhook専用のルートを用意し、必要なミドルウェア構成を確認してください。もちろん、CSRFを外すだけで安全になるわけではありません。Stripeの署名検証を必ず行い、検証前のリクエストデータを業務処理へ渡さないことが前提です。

また、JSONを一度加工してから署名検証すると、検証に必要な元のリクエスト本文と一致しなくなることがあります。フレームワークやライブラリの使い方を確認し、署名検証には適切な形式の本文を使うようにしましょう。

イベントタイプの分岐を広げすぎない

Webhookでは、受信したすべてのイベントを一つの巨大な条件分岐で処理するより、対応するイベントタイプを限定し、それぞれの責任を分けたほうが保守しやすくなります。

たとえば、単発決済の完了処理と、サブスクリプションの状態変更処理では、必要なデータも状態遷移も異なります。同じ「決済成功」に見えても、単発購入、継続課金、支払い失敗からの回復では、扱うべき業務ルールが変わります。

イベントタイプを追加するときは、次の点を確認してみましょう。

  • そのイベントは、どの業務状態を更新するために使うのか
  • 同じ対象に別のイベントが届いた場合、どちらを優先するのか
  • イベントの到着順が入れ替わっても、最終状態が壊れないか
  • すでに完了した注文へ、古いイベントが届いた場合どうするのか
  • 失敗したイベントを管理者が再処理できるか

Webhookは常に、発生順どおりに処理できるとは限りません。イベントIDで重複を除外するだけでなく、注文や契約の現在状態を見ながら、許可される状態遷移かどうかを確認する必要があります。

サンプル実装を本番仕様へ近づける

決済ライブラリのサンプルでは、Webhookを受け取ってログを出力するところまでしか書かれていないことがあります。そこから本番向けにするには、イベント履歴、注文との紐付け、トランザクション、エラー処理、再処理の仕組みを追加します。

最小限のデータ設計としては、注文テーブルにStripe関連IDを保存し、Webhookイベントテーブルでイベントの受信履歴を管理します。さらに、ポイント付与や権限開放のような金銭的価値を持つ処理には、専用の履歴テーブルを用意し、注文IDやイベントIDに一意制約を付けると安全です。

ここで、注文テーブルの状態だけを見て処理済みか判定する方法には限界があります。注文が「決済済み」になっていても、ポイント付与まで完了したとは限りません。逆に、Webhookの処理自体は完了していても、メール送信だけが失敗しているかもしれません。

業務上の副作用ごとに、完了状態を持つべきかを検討してください。すべてを細かく管理する必要はありませんが、ユーザーの権利やサービスの売上に直結する処理は、注文ステータスだけに隠さないほうが、あとから原因を追いやすくなります。

本番公開前に確認したい失敗ケース

決済機能は、成功画面だけを確認して公開すると、障害時に設計の弱点が一気に表面化します。公開前には、意図的に失敗ケースを作り、同じイベントが届いても結果が一つに保たれるかを確認してみましょう。

同じWebhookを複数回送る

同じイベントIDを使ってWebhook処理を複数回実行し、注文、ポイント、権限、メール履歴がどのようになるか確認します。

期待する結果は、最初の一回だけ業務処理が実行され、二回目以降は重複として受け付けられることです。HTTP 2xxを返しつつ、ログには重複検出の事実を残しておくと、運用時の調査に役立ちます。

業務処理の途中で例外を発生させる

注文の更新後、ポイント付与の前など、処理途中で意図的に例外を発生させます。その状態でイベントを再送し、注文だけが更新されていないか、イベントが処理済みとして固定されていないかを確認してください。

この試験では、処理の途中でどのデータが確定し、どのデータが戻るのかを把握できます。トランザクションの境界が曖昧なままだと、部分的にだけ成功する状態が見つかります。

応答を遅延させる

処理が長引いた場合に、Stripe側の再送が起きる前提で設計を確認します。Webhookで外部APIを呼び出している場合、その外部サービスが遅いと、受信処理全体が30秒を超えることがあります。

Webhookの中で外部処理を完了させる設計にしている場合は、処理をキューへ移せないか検討してください。どうしても同期処理が必要であれば、最大実行時間とタイムアウト時の回復方法を明確にしておく必要があります。

複数プロセスで同時に到着させる

同じイベントをほぼ同時に複数回送信し、アプリケーションが並行して処理した場合を確認します。アプリケーションコードの存在確認だけでは、このケースを防げないことがあります。

この試験で問題が起きる場合は、データベースのUNIQUE制約、トランザクション、行ロック、更新条件などを見直してください。個人開発のサービスでも、デプロイ後に複数のワーカーが動くことは珍しくありません。ローカル環境で一つのプロセスしか使っていないから安全だとは限らないのです。

ログと再処理の仕組みが障害対応を支える

冪等性は、重複を防ぐだけの仕組みではありません。障害が起きたあとに、安全に再処理できるようにするための土台でもあります。

Webhookのログには、少なくとも次の情報を残しておくとよいでしょう。

  • StripeのイベントID
  • イベントタイプ
  • Checkout SessionやPaymentIntentなどの関連ID
  • 自社の注文ID
  • 対象ユーザーの識別子
  • 受信日時
  • 処理開始日時と完了日時
  • 処理結果
  • 例外の種類とメッセージ

ログに決済情報を残す場合は、カード番号などの機密情報を保存しないように注意してください。調査に必要なIDと状態を中心に記録し、Webhook本文全体を無制限に保存する設計は避けたほうが安全です。

再処理機能では、何でも再実行できるようにするのではなく、対象イベントと業務処理を限定します。たとえば、決済完了イベントの再処理は許可しても、すでに返金済みの注文へ購入権限を戻すことは許可しない、といった業務ルールが必要です。

再処理を行う管理画面やコマンドを作る場合も、処理前に現在の注文状態を確認してください。イベントが古い場合や、後続の状態変更がすでに完了している場合には、そのイベントを再実行しないほうが正しいことがあります。

個人開発の決済実装で優先する順番

個人開発では、理想的な決済基盤を最初からすべて作るのは負担になります。そこで、事故の影響が大きい順に仕組みを整えていくと、実装と運用のバランスを取りやすくなります。

まず、Stripe APIへ決済作成リクエストを送る段階で、注文単位のIdempotency-Keyを使います。ユーザーの連打や通信タイムアウト後の再実行によって、複数の決済を作らないためです。

次に、Webhookでは署名を検証し、event.id による重複排除を行います。イベントIDをデータベースへ保存し、一意制約で同時実行時の競合も抑えます。

そのうえで、注文IDやPaymentIntent IDなど、業務上の重複を防ぐキーを設計します。Webhookイベントが異なっていても、同じ注文を二度作らないことが目的です。

最後に、ポイント付与、権限開放、メール送信など、決済後に発生する処理をそれぞれ冪等化します。すべてを同じテーブルの状態だけで管理しようとせず、再実行されても結果が増殖しないプロセスとして設計してください。

最低限そろえたい構成

サービスの規模がまだ小さい段階であれば、次の構成から始めてもよいでしょう。

  • 注文テーブルに自社注文IDとStripe関連IDを保存する
  • Webhookイベントテーブルに event.id を一意保存する
  • 決済作成APIに注文単位のIdempotency-Keyを付ける
  • ポイントや権限の付与履歴に注文IDの一意制約を付ける
  • Webhookでは重い処理を避け、必要に応じてキューへ渡す
  • 失敗したイベントをログから追跡し、再処理できるようにする

この構成を採用すると、Stripeの再送、ユーザーの連打、アプリケーションの再起動、キューの再試行という異なる失敗要因に対して、それぞれ対応する防御策を置けます。

冪等性は「一度しか実行しない」ためではない

Stripe Webhookの冪等性を考えるとき、「処理を一度だけ実行する」ことを目標にしたくなります。しかし、分散したシステムの中で、処理が本当に一度だけ実行されたと完全に証明するのは簡単ではありません。

現実的な目標は、同じイベントや同じ業務操作が複数回到着しても、ユーザーやサービスに現れる最終結果を一つに保つことです。途中で再試行が発生しても、注文が増えず、ポイントが増えず、権限が壊れない。そのためにイベントID、業務キー、一意制約、トランザクション、ジョブの再実行制御を組み合わせます。

決済処理は、成功時の画面だけを作れば完成する機能ではありません。失敗したとき、遅れたとき、同じ通知が届いたとき、途中でサーバーが停止したときに、どのように整合性を保つかまでが実装の範囲です。

まずはWebhookイベントをデータベースへ保存し、event.id の一意制約を置くところから始めてみましょう。そのあとで注文IDやPaymentIntent IDの扱いを整理し、決済作成側のIdempotency-Key、UIの連打防止、キューの再試行へと広げていくと、無理なく再現性のある決済プロセスを作れます。

個人開発のサービスでは、売上が増えてから決済基盤を作り直すのは大きな負担になります。小さなサービスのうちに、重複を前提とした仕組みを組み込んでおけば、ユーザーが増えたあとも、障害の原因を落ち着いて追跡できます。まずは一つの注文を、一つの業務結果として安全に確定できる設計を、あなたのアプリに置いてみてください。

Related reading: Axiosインターセプターによるトークンリフレッシュ:非同期リクエストが並行処理される内部挙動 and LaravelのAPI連携におけるRESTとGraphQLの長所と個人開発での最適解.

よくある質問

StripeのWebhookはなぜ二重に届くのですか?
StripeのWebhookは最低1回配信が前提で、HTTP 2xx以外の応答、30秒以上のタイムアウト、ネットワーク障害、デプロイや再起動時の不安定さなどをきっかけに再送されます。
StripeのWebhook重複処理はどう防げばよいですか?
Webhookのevent.idをデータベースに保存し、イベントIDに一意制約を付けます。処理済みのイベントが再び届いた場合は業務処理を繰り返さず、HTTP 2xxを返します。
StripeのIdempotency-KeyとWebhookのevent.idの違いは何ですか?
Idempotency-Keyは自社アプリからStripe APIへ送る決済作成リクエストの重複を抑えるためのキーです。event.idはStripeから届いたWebhookイベントの重複処理を防ぐためのキーで、役割が異なります。
Webhookの署名検証だけで二重処理を防げますか?
防げません。署名検証はリクエストの送信元を確認する仕組みであり、正しい署名を持つ同じイベントが再送される可能性があるため、別に冪等性処理が必要です。
LaravelのStripe Webhookでタイムアウトを避けるにはどうすればよいですか?
Webhook内では署名検証、イベント保存、最低限の業務状態の確定に集中し、メール送信や外部システム連携など時間のかかる処理はキューへ渡します。処理が30秒以上かかるとタイムアウトし、Stripeが再送する可能性があります。

参考情報