Stripe決済を壊さず実装する:AWS(Lambda + API Gateway + DynamoDB + PostgreSQL)構成での冪等性・Webhook・整合性設計【一般化・詳細版】
はじめに
Stripe Checkout は、最初の導入だけを見るとかなり簡単です。
フロントから API を呼び出して Checkout Session を作成し、Stripe の決済画面へ遷移させれば、一見それらしく動きます。
ただし、本番運用で重要なのは「決済画面に遷移できたか」ではありません。
本当に難しいのは、異常系を含めても 決済・注文・後続処理が壊れないこと です。
たとえば次のようなことは普通に起こります。
- ユーザーが購入ボタンを連打する
- API リクエストが再送される
- Lambda がタイムアウトや再実行を起こす
- Stripe の Webhook が再送される
- DB 更新と通知処理の一部だけ成功する
- フロントでは成功に見えるのに、サーバー側では注文が確定していない
この手の問題は、正常系だけを見ていると設計に入ってきません。
その結果、あとから以下のような事故が起きます。
- 二重決済
- 二重登録
- 決済成功なのに注文がない
- 通知だけ飛んで注文が未確定
- 在庫更新だけ漏れる
- 原因調査ができない
本記事では、AWS 上の一般的なサーバレス構成を前提として、以下を整理します。
- Checkout Session 作成 API の設計
- API 側冪等性の持ち方
- Webhook を唯一の真実にする理由
- DB 側での最終整合性担保
- 後続処理の切り分け方
- どのイベントを起点に注文確定するか
- なぜその設計にするのかという判断根拠
特定サービス固有の名称や実運用の内部情報には依存しないよう一般化していますが、
設計思想自体はそのまま実務で使える内容です。
この記事の対象範囲
この記事で扱うのは、主に以下の領域です。
想定構成
まず前提となる全体構成です。
- フロントエンド: 任意の SPA / Web フロント
- API: API Gateway
- バックエンド: AWS Lambda
- 決済: Stripe Checkout
- API 側冪等性: DynamoDB
- 注文データ: PostgreSQL
- 必要に応じた後続処理: Queue / Workflow
全体構成図
この構成で重要なのは、役割を明確に分けることです。
| レイヤ | 主な責務 |
|---|---|
| Frontend | ユーザー入力、UI制御、遷移 |
| checkout-api | Checkout Session 作成、API側冪等性 |
| Stripe | 決済実行 |
| webhook-handler | 決済完了イベントの受信、注文確定 |
| DynamoDB | 同一 API リクエストの重複抑止 |
| PostgreSQL | 注文の永続化、最終整合性の保証 |
| Queue / Workflow | 通知、在庫、帳票などの後続処理 |
まず結論:Stripe 決済設計の原則
本題に入る前に、設計原則を先にまとめます。
1. フロントの success URL を信用しない
決済完了後にブラウザが success URL に戻ってきたとしても、それをもって注文確定としてはいけません。
理由は、success URL は クライアント側の挙動に依存する からです。
- ユーザーが戻る前にタブを閉じる
- ネットワーク断で画面遷移が失敗する
- JavaScript エラーで後続処理が走らない
- リロードや多重遷移で API が重複実行される
2. 注文確定は Webhook に寄せる
注文確定は Stripe からのサーバー間通知である Webhook を起点にするのが基本です。
決済完了の真実 = Webhook
3. 冪等性は 1 箇所では足りない
- API リクエストの重複
- Webhook の再送
- アプリ内での二重更新
- DB への同時書き込み
これらが別々に起こるため、冪等性は多層で持つ必要があります。
4. DB を最後の砦にする
アプリケーションコードは必ずどこかで抜けます。
最後に重複登録を止めるのは DB 制約です。
Stripe 決済で守るべき責務分離
設計を崩しやすいのは、「どこで何を確定するか」が曖昧なときです。
最初に責務分離を固定すると、かなり壊れにくくなります。
役割の考え方
- Frontend は UX 担当
- checkout-api は「決済を始める」ための入口
- webhook-handler は「決済が終わった」という事実を確定する場所
- PostgreSQL は最後の整合性を守る場所
- Queue / Workflow は後続処理を安全に流す場所
つまり、開始・確定・後続処理を分離する のが重要です。
よくある失敗パターン
Stripe の導入でありがちな失敗を先に整理します。
失敗1: success URL 側で注文登録している
以下のような流れです。
この設計だと、次の問題が起きます。
- success URL に戻る前にブラウザを閉じると注文が作られない
- フロント API が失敗すると決済成功なのに注文が残らない
- リロードや多重呼び出しで二重登録しやすい
- サーバー側が決済成功を直接確認していない
失敗2: Webhook で注文登録しているが重複排除がない
Stripe の Webhook は再送されます。
このため、Webhook ハンドラで単純に INSERT しているだけだと、同じイベントが複数回処理されて二重登録されます。
失敗3: API と Webhook の両方で注文確定している
- success URL 側でも注文登録
- Webhook 側でも注文登録
この設計は責任点が分散するため危険です。
片方だけ失敗したり、両方成功して重複したりします。
失敗4: Stripe の Idempotency-Key だけで十分だと思っている
Stripe API に Idempotency-Key を付けるのは有効ですが、それが効くのは Stripe API 呼び出しの重複抑止 までです。
防げないものは以下です。
- Webhook の再送
- 自アプリ内の重複更新
- DB の race condition
- 後続処理の多重実行
まず見るべき全体フロー
責務分離を前提にすると、正しい全体フローは次のようになります。
この中で重要なのは、注文確定が webhook-handler にしか存在しない ことです。
API 側冪等性設計
まずは Checkout Session 作成 API の設計です。
API 側で起きる問題
Checkout Session 作成 API は、次のような理由で重複実行されます。
- ユーザーが購入ボタンを連打する
- フロント側がタイムアウトで再送する
- API Gateway / Lambda の再試行が起こる
- ユーザーが「失敗した」と思って何度も押す
このとき、「同一内容の注文要求なら同じ結果を返す」ように設計する必要があります。
DynamoDB テーブル例
Table: idempotency_store
Partition Key:
- id (String)
Attributes:
- status (String) # PROCESSING / COMPLETED / FAILED
- response_body (String)
- created_at (String)
- updated_at (String)
- expiration (Number) # TTL
なぜ DynamoDB を使うのか
- TTL で自然削除しやすい
- 低レイテンシ
- Lambda との相性がよい
- 単純な key-value 管理に向いている
- RDB の本体トランザクションと分離できる
Idempotency Key の作り方
冪等性キーはかなり重要です。
適当に作ると別注文が同一扱いされたり、逆に同一注文を別物として扱ってしまいます。
たとえば以下のような要素を使います。
- ユーザー識別子
- 商品一覧
- 数量
- 配送方法
- 支払方法
- 金額
- 配送先
- 受取方法
import hashlib
import json
def build_idempotency_key(payload: dict) -> str:
normalized = {
"user": payload["user"],
"items": sorted(
[
{
"item_id": item["item_id"],
"qty": item["qty"],
}
for item in payload["items"]
],
key=lambda x: (x["item_id"], x["qty"]),
),
"delivery_type": payload.get("delivery_type"),
"payment_type": payload.get("payment_type"),
"destination": payload.get("destination"),
"amount": payload.get("amount"),
}
raw = json.dumps(normalized, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
API 側の処理イメージ
実装イメージ
item = get_item(idempotency_key)
if item and item["status"] == "COMPLETED":
return item["response_body"]
put_processing_item_if_absent(idempotency_key)
session = create_checkout_session(...)
save_completed_response(idempotency_key, session)
return session
単純な exists チェックだけでは足りない理由
以下のようなコードは race condition に弱いです。
# NG
if not exists(key):
create_checkout_session()
save(key)
これだと同時に 2 リクエスト来た場合、
- A も未存在と判定
- B も未存在と判定
- A も B も Stripe Session を作る
となり、重複生成されます。
そのため、
- 先に
PROCESSINGを条件付きで保存する - 1 リクエストだけ処理を進める
という流れが必要です。
Stripe 側にも同じキーを渡す
session = stripe.checkout.Session.create(
...,
idempotency_key=idempotency_key,
)
ただしこれは Stripe API 呼び出し重複抑止 であり、Webhook や DB の整合性まで守るものではありません。
Webhook 設計
ここが本番の中核です。
Webhook では、最低限次のことを行います。
- 署名検証
- 受信イベント種別の確認
-
event_idの重複排除 - 注文の登録または更新
- 必要に応じて後続処理への連携
Webhook の責務
署名検証
これは必須です。
stripe.Webhook.construct_event(...)
署名検証をしないと、第三者が適当な HTTP リクエストを投げて注文確定処理を偽装できる可能性があります。
どのイベントを起点にするか
Checkout ベースの構成では、最初は checkout.session.completed を起点にすることが多いです。
理由は以下です。
- Checkout フロー全体の完了通知として扱いやすい
-
metadataに注文情報を持たせやすい - 注文番号との紐付けがしやすい
ただし、決済方式やビジネス要件によっては payment_intent.succeeded などを中心に設計することもあります。
重要なのは、「どれを真実とするか」を最初に明確にすることです。
イベント重複排除テーブル
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
payload JSONB
);
カラムの意味
-
event_id: Stripe イベント ID -
event_type: イベント種別 -
status:PROCESSING,PROCESSED,FAILED -
payload: 障害調査用 -
processed_at: 完了時刻
注文テーブル例
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
payment_session_id TEXT,
payment_reference_id TEXT,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'jpy',
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
event_id と order_id UNIQUE の役割分担
-
event_id: 同じ Webhook イベントの重複処理を防ぐ -
order_id UNIQUE: アプリ上の同一注文重複を防ぐ
両方必要です。
Webhook 実装イメージ
event_id = stripe_event["id"]
insert_processed_event_if_absent(event_id)
upsert_order(...)
mark_processed(event_id)
SELECT してから INSERT が危険な理由
以下のようなコードは race condition に弱いです。
# NG
if not exists(event_id):
insert(event_id)
そのため、一意制約 + INSERT ... ON CONFLICT に寄せるほうが強いです。
先に event_id を押さえる理由
悪い流れ
よい流れ
つまり、Webhook の重複排除情報を先に確保してから本処理に入る のが基本です。
UPSERT を使う理由
注文処理は単純 INSERT より UPSERT のほうが安全なことが多いです。
たとえば次のようなケースです。
- 事前に注文ドラフトがある
- 決済前仮状態の注文レコードがある
- Session ID や支払い参照 ID を後から埋めたい
- 既存注文を
PENDINGからPAIDに進めたい
そのため、Webhook では
- 新規なら作成
- 既存なら必要項目だけ更新
という設計が相性よいです。
状態管理設計
Stripe 決済を扱う場合、状態を SUCCESS / FAIL の 2 値だけで持つとすぐ破綻します。
例
状態の意味
-
CREATED: 注文の骨格を作成 -
SESSION_CREATED: 決済セッション作成済み -
PENDING_PAYMENT: 決済待ち -
PAID: 決済成功 -
CONFIRMED: 後続処理まで完了
なぜ PAID と CONFIRMED を分けるのか
決済成功直後でも、後続処理はまだ失敗する可能性があります。
- 在庫確保
- 購入履歴保存
- メール通知
- SMS 通知
- 帳票生成
- ワークフロー起動
そのため、
- Stripe 側では支払い成功
- でも自システム全体では未完了
という状態を表現できるほうが安全です。
後続処理を Webhook に詰め込みすぎない
Webhook Lambda で全部やるのは危険です。
たとえば以下をすべて同期実行すると壊れやすいです。
- 注文更新
- 明細更新
- 在庫更新
- 通知送信
- 帳票生成
- 外部 API 通知
- 分析イベント送信
推奨方針
Webhook では最小限だけやります。
-
event_idを押さえる - 注文状態を
PAIDにする - 必要なら Queue / Workflow に投げる
重い後続処理は切り出します。
この分離のメリット
- Webhook の責務が明確になる
- 再試行戦略を分離できる
- タイムアウトしにくい
- 後続処理だけ個別再実行しやすい
失敗時の扱い
Stripe 決済では「失敗しないようにする」より、「失敗しても壊れないようにする」が大事です。
ケース1: API 側で PROCESSING だけ残った
- 冪等性レコードは
PROCESSING - Session は未作成
この場合、永久に PROCESSING のままだと困ります。
対策
-
updated_atを持たせる - 一定時間 stale なら再処理可能にする
- TTL で自然解消する
ケース2: Session は作れたが応答保存前に落ちた
- Stripe 側には Session が存在
- 自アプリ側には未完了
対策
- Stripe 側にも同じ Idempotency-Key を渡す
- ログに
idempotency_keyとsession_idを必ず出す - 再実行時の挙動を事前に決める
ケース3: 注文更新後に通知処理で落ちた
理想は次の状態です。
- 注文は
PAID - 通知だけが未完了
- 通知だけ再試行可能
このためにも、注文確定と通知は分けるべきです。
失敗時の考え方を図にすると
ログ設計
本番運用では、コードよりログ設計のほうが調査効率を左右することもあります。
API 側で出したいもの
- 冪等性キー
- ユーザー識別子
- 注文識別子
- Session ID
- 入力の要約
- 既存ヒット有無
- 処理結果
Webhook 側で出したいもの
event_idevent_typeorder_idsession_idpayment_reference_id- DB 更新結果
- 後続処理投入有無
- エラー内容
例
print(json.dumps({
"level": "INFO",
"action": "create_checkout_session",
"idempotency_key": idempotency_key,
"order_id": body["order_id"],
"user": body["user"],
"session_id": session.id,
}, ensure_ascii=False))
ログの見方
ログに必要な識別子がなければ、障害時に追跡できません。
特に決済では、1 リクエスト単位で何が起きたか を追えることが重要です。
フロント側でやるべきこと / やらないこと
やるべきこと
- ボタン連打の一時抑止
- エラー時の適切なメッセージ表示
- 「注文確定はサーバー側で進行中」という説明
- success / cancel 時の画面制御
やらないこと
- success URL 到達をもって注文確定にする
- フロントだけで支払い結果を真実扱いする
- 重要な整合性判断をクライアントで行う
フロントの責務は UX です。
正しさはサーバーで担保します。
最終的な多層防御の全体像
ここまでの内容をまとめると、Stripe 決済を壊れにくくする防御は次のようになります。
これを別の見方で書くと、
さらに堅くするなら
ここまででも十分実務的ですが、さらに強化するなら次があります。
1. stale PROCESSING 検知
長時間処理中の冪等性レコードを監視する
2. Queue + DLQ
後続処理失敗を退避・再実行可能にする
3. Workflow 化
PAID → 在庫 → 通知 → 帳票 の進行を見える化する
4. メトリクス化
- Webhook 受信件数
- 重複イベント件数
- API 側冪等性ヒット率
- 決済成功から確定までの時間
5. 監査テーブル
「誰がいつ何を更新したか」を追えるようにする
まとめ
Stripe 決済で本当に重要なのは、「動くこと」ではなく 壊れないこと です。
ポイントをまとめると次の通りです。
1. 注文確定は Webhook に寄せる
success URL ではなく、サーバー間通知を真実にする
2. API 側冪等性を持つ
同一注文の連打やリトライに対して同じ結果を返せるようにする
3. Webhook は event_id で重複排除する
再送前提で設計する
4. DB に一意制約を置く
最後は DB で止める
5. 後続処理は分離する
Webhook に責務を詰め込みすぎない
設計全体のまとめ図
Stripe 決済でハマる理由の多くは、正常系だけを見て設計してしまうことにあります。
でも本番で重要なのは、異常系・再送・途中失敗・再実行時にどう振る舞うか です。
この観点で設計すると、決済まわりはかなり安定します。
おわりに
この記事では、Stripe Checkout をサーバレス構成で安全に扱うための基本設計を整理しました。
実際にはここからさらに、
checkout.session.expiredpayment_intent.payment_failed- 注文ドラフトとの統合
- 後続処理の再実行戦略
- 実運用時の障害調査手順
まで掘ることができます。
Stripe は「簡単に始められるが、正しく運用するのは難しい」領域です。
だからこそ、最初に 冪等性・Webhook・DB 制約・責務分離 をセットで設計しておく価値があります。