判断の出発点
決済事業者の画面には承認履歴があるのに、顧客の注文一覧は空です。運用担当者が注文を再処理すると、今度は利用券が二枚発行されました。これは設計例ですが、どこまで成功したか分からないまま全工程を再実行すると起こり得ます。
必要なのは決済ボタンを押し直す機能ではありません。注文で何を約束し、決済がどこまで進み、商品や権限を実際に提供したかを別々に記録し、確認済みの事実に基づいて欠けた段階だけを処理する構造です。外部決済と自社DBを一度にコミットできると仮定せず、その間で切れた処理を発見して復旧するよう設計します。[9]
決済結果を確認できなければ、失敗ではなく未確定として残します。決済が成功して商品提供だけが失敗したなら再決済しません。提供の応答だけを失ったなら、実際の提供を確認するまで再発行しません。この区別が安全な再処理の出発点です。
「成功」という一つの状態だけでは不十分です
本稿はToss Paymentsの決済画面で認証後、サーバーで承認する流れを主な例にします。内部モデルはPostgreSQL 17を基準とする設計例で、特定加盟店の実装や実障害ではありません。定期請求方針、部分返金計算、精算・銀行入金額の照合は別の問題とします。
注文は購入内容と履行方針、決済記録は事業者が確認した取引事実、提供記録は在庫減算・利用券発行・配送依頼などの実処理を担当します。三状態を結ぶ必要はありますが、同じ値で上書きしません。
| 状況 | 内部注文状態の例 | Toss決済状態または確認結果 | 実際の提供状態の例 | 次の判断 |
|---|---|---|---|---|
| 決済画面認証後、サーバー承認前 | 決済待ち | IN_PROGRESS | 未開始 | 注文検証後に承認手順へ進む |
| 仮想口座発行後、入金前 | 入金待ち | WAITING_FOR_DEPOSIT | 未開始 | 発行を入金完了と扱わない |
| 決済承認と内部反映完了 | 注文確定 | DONE | 提供待ち | 提供処理を別途実行 |
| 承認済みだが注文DB反映失敗 | 復旧必要 | 再照会でDONE確認 | 未開始 | 決済せず内部反映を復旧 |
| 外部利用券発行の応答喪失 | 注文確定 | 決済確認済み | 結果未確定 | 発行結果を照会して次を決める |
| 注文・提供とも完了 | 履行完了 | 決済確認済み | 提供完了 | 完了証拠を保存し後続訂正は別処理 |
| 決済取消を確認 | 全部・一部取消の検討/反映 | CANCELED / PARTIAL_CANCELED | 対象範囲の停止・回収を検討 | 取消対象と提供済み範囲を区別 |
内部状態名は提案であり、PGの状態値ではありません。TossのDONEは承認完了ですが、カード売上の取得状態は別フィールドで、精算情報も区別されます。直ちに「販売者口座に入金済み」と解釈してはいけません。[2]
状態に番号を付けて大きな値だけ採用する方法も安全ではありません。現行Toss Webhook文書は、仮想口座の入金エラーでDONEがWAITING_FOR_DEPOSITへ変わる場合を説明し、1.4以前の動作を別に示しています。古い通知による誤った巻き戻しを防ぐことと、事業者が実際に訂正した結果を反映することは別です。[4]
注文と決済試行は同じ識別子ではありません
まず次のつながりを決めることを推奨します。
内部注文 → 決済試行 → PG注文識別子・決済識別子 → 商品単位の提供処理
内部注文に商品、数量、サーバー計算の予定決済額、通貨、購入主体、適用方針を記録します。決済試行には、どのPG・加盟店アカウント・試験/本番環境へどの要求を送ったか残します。TossのorderIdとpaymentKeyも試行に紐付けます。[1][2]
設計例:一つの内部注文で本当に新しい決済試行を認めるなら、別の試行レコードとPG注文識別子を紐付けます。しかし応答喪失後の同一処理の再試行は新しい決済試行ではありません。既存識別子と処理記録を維持します。前の試行が未確定の間に新決済を許すかは、別方針で制限します。
一方、提供の重複防止基準は決済試行ではなく、購入した商品・権限の単位です。一注文で二回決済が成功しても、利用券を二枚発行して誤りを覆ってはいけません。二つの決済事実を保存し、余分に受け取った決済は別の検討対象にします。
注文から承認・返金まで状態を接続 — 決済手段を注文フローにつなぎ、応答遅延、再試行、取消し、返金の例外を検証します。
成功画面ではなくサーバーが注文を確定します
Tossのこの流れで成功URLに戻るのは購入者認証の結果です。サーバー決済承認は別です。公式ガイドは要求前に注文番号と金額を保存し、承認前に戻り値と比較するよう説明しています。[1]
「クライアントから来た金額をサーバーに保存したので安全」として終えません。以下はガイドに加えて適用するサーバー検証設計です。
承認前に、その注文へのアクセス権、注文がまだ決済可能か、価格・割引・数量からサーバーが計算した額と要求額の一致を確認します。PG・加盟店アカウント・環境・通貨も注文の期待値に結び付けます。ブラウザーから別の注文番号を送り、処理対象を変えられるようにしてはいけません。
検証済み試行にだけPOST /v1/payments/confirmを呼び、返された決済識別子・注文番号・金額・状態を期待値と照合します。結果を失ったら、paymentKeyまたはorderIdによる公式照会経路で確認します。[2]
承認応答が仮想口座発行結果なら、直ちに商品提供へ分岐しません。画面もサーバーの注文・決済・提供状態を読み、「入金待ち」「決済確認中」「決済完了・商品準備中」を分けます。ブラウザーを閉じたり成功ページを開き直したりしても、実処理の結果は維持される必要があります。
冪等キー一つですべての重複は防げません
冪等性は、同じ論理処理を繰り返し要求しても結果を重複させない性質です。重要なのは、どのシステムのどの処理を同一とみなすかです。決済事業者の承認重複防止と自社の利用券二重発行防止は別の責任です。
決済APIでは同じ処理のキーを維持します
TossはIdempotency-Key、APIキー、APIアドレス、HTTPメソッドの組み合わせで同じ要求を区別します。文書上の有効期間は初回使用日から15日で、最初の応答を再び返します。エラーを理由にキーを変えて同じ要求を繰り返すことは危険と案内しています。[3]
これに合わせ、承認処理を作る時点でキーと要求内容を永続保存します。金額・対象・処理種別が変わったのに同じキーで送ろうとすれば内部で遮断します。同一処理の再試行は保存内容をそのまま使います。
キーの期限切れや認証APIキーの交換だけを理由に、未確定決済を新処理として実行しません。先に既存取引を確認します。また、初回応答を再び受け取ることと、現在の決済状態を照会することは別です。 承認後に取消があった可能性があるなら、再送された古い承認応答だけで注文を再確定しません。これは文書のキー範囲と応答再利用方式から導く設計上の注意点です。[3]
自社DBには別の一意制約を置きます
次は重複防止責任を分ける設計例です。
| 保護対象 | 同じ処理とみなす基準例 | 保護できないもの |
|---|---|---|
| PG承認・取消要求 | 論理処理ID、要求内容、PGの冪等キー範囲 | 自社DB反映・商品発行 |
| 内部決済レコード | PG+加盟店アカウント+環境+PG決済識別子 | 後続の取消・訂正イベント |
| Webhook受信記録 | 供給者が保証するイベント識別子と範囲 | 別イベントIDで表現された同一業務効果 |
| 内部商品・権限提供 | 注文商品単位+提供行為+必要な履行版 | 外部発行システムで既に実行された処理 |
| 外部商品発行要求 | 発行元が対応する固定処理識別子・冪等キー | 発行元の重複防止範囲外の処理 |
これは商品単位ごとに一回提供するモデルです。複数個購入や分割配送なら、提供単位を先に分けます。正常な追加購入まで重複として除去してはいけません。
PostgreSQLの一意制約は列の組み合わせの重複防止に使えます。ただし既定動作でNULL同士は同じ値とみなされないため、キーが空でも安全とは仮定しません。キー未取得の決済試行と識別子確定済みの決済レコードを分け、必要な列にNOT NULLなどを適用します。[8]
アプリケーションで「なければ作成」と事前照会するだけでは並行要求を防ぎにくくなります。一意制約と状態遷移条件を併用し、衝突した要求は既存結果を読み直します。決済を一度見たことを理由に後の取消まで無視しないよう、決済識別子と個別処理・イベント識別子を区別します。
DBに残す成功と次の作業を一緒にコミットします
自社DBトランザクションは、その中の変更をまとめて確定・取消する境界です。外部PGの承認まで自動的に戻す境界ではありません。[7][9]
以下はローカルトランザクションと外部呼び出しを分離した設計例です。
- 呼び出し前:注文と決済試行、承認要求内容、冪等キーを保存します。外部決済の応答を待つ間、注文行のDBロックを保持し続ける構造は避けます。
- PG呼び出し後:確認した取引事実で短いローカルトランザクションを開きます。決済反映、許可された注文変更、提供ジョブ登録、発行イベント記録を一緒にコミットします。
- コミット後:別ワーカーがイベントを届けます。受信側は処理記録と業務変更をまとめ、処理済みなら既存結果を返します。
outboxは業務変更と一緒に保存する「届けるイベント」の記録です。決済反映だけコミットされ、ジョブ登録が抜ける二重書き込み問題を減らします。inboxは受信メッセージの受付・処理結果を管理します。受付完了と業務完了は区別します。AWSのtransactional outbox解説とParticularのNServiceBus実装文書も、ローカル変更とメッセージ記録を結び、重複処理を別途扱う構造を説明しています。[9][10]
送信直後にワーカーが止まると、送信済みの記録が残らず同じイベントが再配信されます。outboxを付けたという理由で「必ず一度だけ配信」と説明しません。重複配信でも同じ業務効果が繰り返されない受信側を設計します。[9][10]
同時コールバックと古い照会結果も同じ規則で扱います
ブラウザーの後続要求、Webhook処理、定期復旧、運用者の再処理がそれぞれ直接注文を変更すると、相互に上書きできます。本稿はこれらを一つの状態遷移規則と重複防止機構へ通すことを提案します。
例えば照会開始時の内部状態版と反映時の版を比較します。その間に取消などが入れば、古い観測値をそのまま適用せず再判断します。到着時刻だけで最新取引を決めたり、PG取引IDを並べ替え可能な版番号と仮定したりしません。
内部競合を防いでも、PGと外部発行元を同時にロックできるわけではありません。最後の決済確認直後に取消や入金訂正が起きる区間は残ります。不可逆な提供直前の確認、自社取消要求と提供ジョブの調整、提供後訂正への停止・回収・補償を併せて定めます。この補償は方針に基づく後続処理であり、元決済を過去に戻すDBロールバックではありません。
外部発行の応答喪失を再実行だけで解決しません
自社DBに利用券行を作るなら、重複防止記録と権限付与を同じトランザクションに入れられます。同じDBの在庫減算も設計した境界内で扱えます。しかし外部APIによる利用券発行や配送受付には、新しい失敗境界が生まれます。
設計例:発行元は利用券を作りましたが、応答中に接続が切れ、ワーカーはタイムアウトだけを見ました。内部の「処理中」レコードは、外部発行の成功も失敗も証明しません。ワーカーのリースやロック期限が切れただけで新規発行を許してもいけません。
発行元が対応するなら、同じ処理IDで結果照会するか、同じ冪等キーで同一要求を繰り返します。その機能がなく他の確認証拠もなければ、提供結果未確定として人が確認します。自動再発行を止めるべき境界です。Particular文書も、outboxトランザクション外の外部副作用には保証が拡張されないと説明しています。[10]
そのため再処理ボタンは「注文全体を再実行」一つより、「決済再照会」「内部反映復旧」「提供結果照会」「未提供と確認済みの処理を再実行」に分ける方が安全です。
Webhookは検証して受け付け、重複に耐えて処理します
重要なのは、誰が送ったか、どの取引を指すか、今その結果を適用してよいかです。認証済みの過去通知でも、現在状態を上書きしてよい意味ではありません。
TossのDEPOSIT_CALLBACKは、承認応答のsecretと比較する方式を文書化しています。コアAPIも決済状態WebhookのPaymentオブジェクトについてsecret照合を説明し、フィールドをnullableと定義します。信頼できる経路から得た値のあるsecretを比較します。null == nullを認証成功と扱いません。この値はAPI認証用シークレットキーとも別です。[2][4]
また、tosspayments-webhook-signatureは文書でpayout.changedとseller.changedに限定して説明されています。一般の決済状態Webhookへそのまま要求する実装は適切ではありません。[4]
対応認証方式や比較値を確保できない場合の安全な設計代案は、通知を状態変更の根拠ではなく再照会の合図として扱うことです。既知の注文・加盟店・環境に限定してサーバーからPGを照会し、確認結果だけで業務を進めます。本文の金額・状態だけで商品を提供しません。この受付経路にも要求サイズ制限、有効な取引IDの確認、照会量制限が必要です。
Toss Webhookガイドは受信後10秒以内のHTTP 200と、失敗時最大7回の再送を案内しています。遅い商品提供まで要求内で終えるより、可能な検証を行い、後続処理できる受付記録を永続保存してから応答する構造が適します。保存失敗まで成功と応答してはいけません。不正認証要求は正常受付と区別します。[5]
比較するとStripeは、本番の自動再送、イベント順序非保証、重複処理を別々に文書化しています。署名検証には生の要求本文、Stripe-Signature、エンドポイント秘密値による公式方式を使います。これをTossやほかのPGの保証として転用しません。[6]
イベントIDは追跡と重複受信検出に使いますが、範囲と再送時の維持は供給者の契約を確認します。本稿では、Toss送信識別子が全再試行で同一の業務イベントIDを保つとは仮定しません。配信重複を一部見逃しても、最終提供制約が二重効果を防ぐ必要があります。
受付記録には取引接続用ID、受付時刻、検証結果、処理状態を残し、secret・カード情報・顧客の生データを一般ログへそのまま複製しない設計にします。PGで観測した時刻と内部反映時刻も分けると、遅延と再処理の経路を確認しやすくなります。
取引異常から対応・決済事業者との協議へ — 症状別の確認手順で取引状態を調べ、障害、返金、繰り返す問題を共通の運用手順で処理します。
切断箇所に応じて再処理する仕事を変えます
以下はすべて設計例です。製品の自動復旧機能や特定PGの保証ではなく、状態分離・冪等性・ローカルトランザクションを実際の失敗点へ適用する判断表です。
| 失敗箇所 | 検出する差 | 安全な次の行動 | 重複防止・停止条件 |
|---|---|---|---|
| PG承認後の注文DBコミット失敗 | 承認はあるが注文反映・提供ジョブがない | 同じ取引を照会し内部反映トランザクションを復旧 | 新規承認要求を作らない |
| 承認応答喪失 | 呼び出し記録はあるが結果未確定 | PG照会または既存冪等処理の許可された再試行 | 新注文・新キーで決済しない |
| コールバックとWebhookの同時到着 | 同じ注文・取引への複数反映試行 | 共通遷移経路で直列化し、衝突後に再照会 | 決済一意性+商品単位提供制約 |
| 取消後の古い承認通知 | 観測順序と内部変更履歴が不一致 | 現在取引を確認し許可された遷移だけ適用 | 過去成功応答で提供再開しない |
| outbox発行直後にワーカー停止 | 配信有無が未確定 | 同じイベントを再配信できる設計 | 受信側処理記録と業務制約 |
| 外部発行応答喪失 | 発行要求はあるが結果証拠がない | 同じ処理の発行結果照会 | 確認不能なら自動再発行停止 |
| 注文失効後の遅い入金 | 決済事実と履行可能性が不一致 | 入金事実を保存し在庫・期限・取消方針を検討 | 決済記録削除や無条件提供をしない |
| 仮想口座の入金訂正 | 過去確認と新PG結果が不一致 | 現在状態を反映し、進行中提供を停止、提供済みを検討 | 過去のDONEだけで提供継続しない |
| 一注文の別試行が両方承認 | 複数取引と一つの購入義務 | 全取引を記録し追加決済と提供範囲を検討 | 試行ごとの同一商品再発行を禁止 |
未確定案件を探す復旧は双方向で行います
内部注文から始める処理だけでは、注文との接続自体を失った決済を発見できない場合があります。本稿は次の双方向を提案します。
内部からPGへ確認します。 承認結果未確定、決済確認後の内部未反映、長期提供待ち、外部提供結果未確定を探します。最終確認時刻と担当処理を記録し、進行中の処理と衝突させません。
PGから内部へも確認します。 TossコアAPI取引照会のような承認・取消履歴を取得する公式手段で、対応する内部記録のない取引を探します。ここでの照合は決済・注文・提供の接続確認であり、手数料や銀行入金を合わせる精算照合ではありません。[2]
収集設計では照会区間とページ進行位置を保存し、再収集区間が重複しても二重反映しないようにします。IDで結べない取引を金額一致だけで注文へ紐付けません。一回の照会失敗や空結果で「入金されていない」と断定せず、対象・加盟店・環境・応答エラーを先に確認します。
HTTPコードだけで再試行を分類しません
Tossのエラー文書では、ALREADY_PROCESSED_PAYMENTと一時的問題を示すPROVIDER_ERRORがともにHTTP 400に含まれます。「4xxはすべて恒久失敗」という規則より、処理種別とエラーコード別の対応が必要です。[11]
処理済み決済は実取引を照会し、入力・権限問題は原因修正後に判断します。一時エラーには既存処理の同一性を保って限定再試行します。TossのIDEMPOTENT_REQUEST_PROCESSINGは前の冪等要求が処理中という意味で、直ちに新決済を作る根拠ではありません。[3]
再試行は回数と総時間を制限し、間隔拡大とランダム遅延を使う設計にできます。具体的な上限は決済手段、提供期限、PG方針、運用対応力に合わせます。例示数値を全サービスの正解にしません。
金額・通貨・注文接続の不一致、認証失敗、未確定中の冪等範囲変更、外部提供の確認不能、反復失敗は手動検討へ渡す条件です。担当者、次回確認時点、今禁止する行動も残します。手動状態変更でも理由・根拠・実行者・前後状態・処理IDを記録し、同じ重複防止経路を通します。
正常決済より失敗の境界で検証します
成功画面を一度見るだけでは設計を確認できません。以下は自社所有または許可済み試験環境で行うテスト設計案です。
承認直後に内部コミットを失敗させ、追加決済なしに注文だけ復旧するか確認します。コールバックとWebhookの同時処理、同一イベントの反復配信でも、商品単位の提供効果が増えないか見ます。内部トランザクション失敗時にinboxだけ完了になったり、outboxだけ消えたりしないことも確認します。
取消後の過去承認通知、注文失効後入金、仮想口座入金訂正、外部発行成功後の応答喪失を個別に試します。特に外部発行を確認できない条件では、自動再発行でなく未確定・検討待ちで止まることが重要です。
保存期間も検証対象です。イベント重複記録を削除後、古い通知を再受信しても二重提供が発生してはいけません。承認応答喪失中のAPI認証キー交換も別シナリオにします。Particularのoutbox文書も、遅延・再試行可能性とともに重複除去記録の保存期間を考慮するよう説明しています。[10]
運用画面では、決済確認済み未提供件数、最古の未確定経過時間、未接続PG取引、反復失敗ジョブと担当者を併せて表示することを推奨します。目的は失敗回数そのものより、顧客に残る未解決状態を減らすことです。
小さなシステムは小さく始められます
この構造に必ずメッセージブローカーやマイクロサービスが必要なわけではありません。一つのDBとワーカーなら、永続的な決済試行・処理記録、業務に合う一意制約、トランザクション登録の後続作業、定期状態確認から始められます。構成要素の数より、失敗後に続ける情報と責任が残るかが重要です。
複数PG・注文システム・発行元が接続され、照会できない外部副作用や手動変更が多いなら、部分的なコード修正では済みにくくなります。どのシステムが何の事実の基準で、誰が例外を判断するかを先に整理します。
再処理は最初からやり直すことではありません。確認済み決済事実を保ち、まだ実行していないと確認できた次の作業だけを続けることです。 一注文を選び、三状態と接続キー、最終実行結果を説明できるかから点検します。
現在の決済・注文フローと失敗シナリオを外部と検討する必要があれば、IXCのPG連携・決済システム構築サービスで支援範囲を確認できます。



