Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
GET/PUT/DELETEはHTTP上べき等、POSTは通常そうではない。決済・注文作成は Idempotency-Key で重複実行を防ぎ、サーバーは PostgreSQL UPSERT で結果を永続化、Redis SET NX で処理中の競合を抑える。Stripeと同様「同じキーなら同じレスポンス」を返す設計が、二重決済インシデントを最も効果的に減らす。
べき等性とは:再送が当たり前のWeb API
べき等性(Idempotency)とは、同じリクエストを何度送っても、サーバー上の最終状態が1回だけ成功したときと同じになる性質のことです。
REST APIの世界では「1リクエスト = 1回だけ副作用」という前提が成り立ちません。モバイル回線の瞬断、ロードバランサのタイムアウト、axios-retry や fetch の自動再試行、ユーザーによる購入ボタンの連打——いずれも正常な利用パターンです。サーバーが「これは初回か再送か」を判別できなければ、注文が2件、在庫が2回引かれ、決済が2回走るという事故につながります。
べき等性設計のゴールは、クライアントに「再送してよい安全なAPI」を提供することです。再送を禁止するのではなく、再送されても結果が変わらないようにサーバー側を組み立てます。
HTTPメソッドごとのべき等性
HTTP仕様では、メソッドごとにべき等性の期待値が定義されています。設計レビューの出発点として、次の表を頭に入れておくと判断が速くなります。
| HTTPメソッド | べき等性・典型用途・再送時の挙動 |
|---|---|
| GET | べき等。リソース取得のみ。何度呼んでもサーバー状態は変わらない(キャッシュ可能)。 |
| HEAD | べき等。GETと同様だがボディなし。メタデータ確認用。 |
| PUT | べき等。同じURL・同じボディで上書き。2回目以降も最終リソース状態は同じ。 |
| DELETE | べき等。1回目で削除、2回目は404でも「存在しない」という最終状態は同じ。 |
| POST | 通常は非べき等。新規作成・決済・通知送信など、呼ぶたびに副作用が増える。 |
| PATCH | 部分更新のため一般には非べき等。「+1」「トグル」型の更新は特に注意。 |
POSTが非べき等とされる理由は、毎回新しいリソースIDやトランザクションが生まれるからです。PUTで「ユーザー名を田中に更新する」操作は2回実行しても最終的な名前は「田中」のままですが、POSTで「注文を1件作成する」を2回送ると注文が2件になります。
実装の落とし穴として、PUTやPATCHでも「カウンタを+1する」「ステータスをトグルする」ような累積型の更新を書くと非べき等になります。HTTPメソッドのラベルだけで安心せず、副作用が増えるかどうかで判断してください。
二重決済が起きる典型シナリオ
決済APIはべき等性設計の代表例です。次のタイムラインは、ECサイトでよく起きる二重チャージの流れです。
- ユーザーが「¥9,800で購入する」をクリックする
- フロントエンドが
POST /api/checkoutを送信する(Idempotency-Key なし) - サーバーはStripeに
PaymentIntentを作成し、DBに注文をpendingで保存する - Stripe側の処理は成功するが、レスポンス返却前にALBのタイムアウト(30秒)が発生する
- フロントは「通信エラー」と判断し、同じボディでPOSTを再送する
- サーバーは「新しいリクエスト」とみなし、再度Stripe APIを呼ぶ → 2回目の課金が走る
- ユーザーにはエラー表示、カード明細には2件の引き落とし——サポート対応と返金処理が発生する
この事故は「悪意ある攻撃」ではなく、タイムアウトと再送の組み合わせだけで再現します。防ぐには、(1) クライアントが操作単位の Idempotency-Key を付ける、(2) サーバーが同じキーの再送に対して最初の結果を返す、(3) 外部決済API呼び出しにも同じキーを渡す——の3層が必要です。
POST /v1/payment_intents HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_live_xxx
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/x-www-form-urlencoded
amount=9800¤cy=jpy&customer=cus_abc123
Stripeに渡した Idempotency-Key は、自前API側のキーと同じ値に揃えるのが実務的です。自前DBとStripeの両方で重複が防げ、どちらか一方だけが二重実行されるギャップを減らせます。
StripeのIdempotency-Keyフロー(実際の挙動)
Stripeは決済業界のデファクトスタンダードとして、Idempotency-Key の挙動がよく文書化されています。自前APIを設計するときの参照実装として、そのフローをそのまま真似するのが最短ルートです。
クライアント側のルール
- 1回の「ビジネス操作」(1回の購入、1回の返金)につき UUID v4 などの一意キーを生成する
- ネットワークエラー・5xx・タイムアウトで再送するときは 同じキーを使い回す
- 別の操作(別商品の購入)では 必ず新しいキー を発行する
- キーは 最大255文字(Stripeの制限)。英数字とハイフンが無難
サーバー(Stripe)側の状態遷移
Stripeはおおよそ次の状態機械でリクエストを処理します。
Idempotency-Key ヘッダー付きPOSTを受信する
キーが未登録ならリクエストを実行し、HTTPステータスとレスポンスボディをキーに紐づけて保存する(24時間保持)
同じキーで再送が来たら、保存済みのレスポンスをそのまま返す(決済ロジックは再実行しない)
同じキーだがリクエストパラメータが異なる場合は idempotency_key_in_use エラー(409相当)を返す
前回リクエストが処理中(in-flight)のとき同じキーで来た場合、完了まで待つか処理中エラーを返す
重要なのは 「同じキー + 同じパラメータ → 同じレスポンス」 と 「同じキー + 異なるパラメータ → エラー」 の2点です。キーだけ一致してボディが違うのに黙って処理すると、意図しない金額での決済が走ります。
{
"error": {
"type": "idempotency_error",
"message": "Keys for idempotent requests can only be used with the same parameters they were first used with."
}
}
自前APIでも、この2分岐を実装に含めるのがStripe互換の実務ラインです。
PostgreSQL UPSERTによるサーバー実装
Redisだけに頼ると、障害や再起動でキーが消えた瞬間に再送が二重処理されます。決済系では PostgreSQLを正(source of truth) にし、Idempotency-Key とレスポンスを永続化するのが無難です。
テーブル設計例
CREATE TABLE idempotency_keys (
key TEXT NOT NULL,
account_id UUID NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'processing',
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account_id, key)
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at);
request_hash は正規化したリクエストボディの SHA-256 などです。キーが同じでもボディが違えば409を返す判定に使います。
UPSERT + トランザクションの流れ
-- 1. 処理開始: processing 行を INSERT(競合時は既存行を取得)
INSERT INTO idempotency_keys (key, account_id, request_hash, status, expires_at)
VALUES ($1, $2, $3, 'processing', now() + interval '72 hours')
ON CONFLICT (account_id, key) DO NOTHING
RETURNING *;
DO NOTHING で0行返った場合は既存キーです。既存行の request_hash を比較し、一致すれば保存済み response_code / response_body を返します。不一致なら409 Conflictです。
-- 2. ビジネス処理成功後: レスポンスを UPDATE
UPDATE idempotency_keys
SET status = 'completed',
response_code = $4,
response_body = $5
WHERE account_id = $2 AND key = $1;
ビジネス処理(Stripe API呼び出し、在庫引当、注文INSERT)は DBトランザクション内 で行い、外部API成功後に completed を記録します。処理中にプロセスが落ちた場合、再送時は status = 'processing' の行を検出し、Stripe側の Idempotency-Key があれば同じ PaymentIntent ID が返るため、二重課金を防げます。
ワーカーが落ちると processing が残ります。TTLクリーンアップに加え、再送時に「processing が5分以上経過」なら再実行を許可するか、Stripe等の外部IDで状態を復元する設計が必要です。無限に processing を返すとクライアントが再送を止められません。
Redis SET NX パターン(高速な重複検出)
PostgreSQL UPSERT だけでも動きますが、高トラフィックでは Redis SET NX で「処理中ロック」を取り、DBへの書き込み競合を減らす構成がよく使われます。
SET NX の基本
SET idempotency:acct_123:7c9e6679-7425-40de-944b-e07fc1f90ae7 "processing" NX EX 86400
- NX — キーが存在しないときだけ SET する(原子操作)
- EX 86400 — 24時間TTL(Stripeに合わせるなら86400秒)
- 戻り値が
OKなら初回リクエスト、nilなら重複
推奨フロー(Redis + PostgreSQL)
Redis SET NX でロック取得。nil なら DB から completed レスポンスを読み返す
ロック取得後、PostgreSQL UPSERT で processing 行を INSERT
ビジネス処理(Stripe API 等)を実行
成功レスポンスを PostgreSQL UPDATE + Redis に completed JSON を SET(TTL付き)
再送時: Redis に completed があれば即返却、なければ DB を参照
Redisはあくまで キャッシュと短期ロック。正はPostgreSQLに置くと、Redis全落ち後の再送でもDBから復元できます。
const redisKey = `idempotency:${accountId}:${idempotencyKey}`;
const acquired = await redis.set(redisKey, 'processing', 'NX', 'EX', 86400);
if (!acquired) {
const cached = await redis.get(redisKey);
if (cached && cached !== 'processing') {
return JSON.parse(cached); // 保存済みレスポンス
}
return loadFromPostgres(accountId, idempotencyKey);
}
SET NX 単体では「処理完了後のレスポンスキャッシュ」までは担えないため、NXで取ったロックのあとDBに結果を書き、Redisにもミラーする二段構えが定石です。
Webhook・非同期経路でのべき等性
同期APIだけべき等にしても、Webhookやメッセージキュー経由の重複で二重処理が起きます。SQSやRedis Streamsは at-least-once 配信が前提で、同じイベントが2回届くのは正常動作です。
Webhook受信側では、Stripeの event.id(例: evt_1ABC...)をユニークキーに INSERT ... ON CONFLICT DO NOTHING し、既存なら200を返して処理をスキップします。署名検証、即時2xx返却、指数バックオフ付き再送、イベントIDによる重複排除——これらは WebHook設計のベストプラクティス で詳しく扱っています。
同期API(Idempotency-Key)と非同期Webhook(event ID)の両方にキー戦略が必要です。片方だけ実装すると、「APIは1回、Webhook処理で2回在庫引き」のようなすり抜けが残ります。
クライアント側の再送設計
べき等性はサーバーだけの問題ではありません。クライアントが毎回新しいキーを送れば、サーバー側の防御は無効化されます。
フロントエンド
- 購入ボタンクリック時に UUID を生成し、その決済フローが終了するまで同じキーを保持する
- ボタン連打防止(disabled + ローディング)と Idempotency-Key は併用する(UIだけではタイムアウト再送を防げない)
- sessionStorage 等にキーを一時保存し、ページリロード後の再送にも同じキーを使えるようにする(任意)
SDK・モバイルアプリ
- リトライライブラリに「同じ Idempotency-Key を付与する」フックを入れる
- 指数バックオフ(1s → 2s → 4s)で再送し、429 の
Retry-Afterを尊重する - 成功レスポンスを受け取るまでキーを破棄しない
const idempotencyKey = crypto.randomUUID();
async function checkoutWithRetry(payload: CheckoutPayload) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // 再送でも同じキー
},
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
if (res.status >= 500 || res.status === 408) {
await sleep(Math.min(1000 * 2 ** attempt, 16000));
continue;
}
throw new Error(await res.text());
}
throw new Error('checkout failed after retries');
}
実装チェックリストと落とし穴
本番投入前に、次の項目を確認してください。
| チェック項目 | 期待する挙動 |
|---|---|
| 同じ Idempotency-Key + 同じボディ | 初回と同じHTTPステータス・JSONを返す |
| 同じキー + 異なるボディ | 409 Conflict(またはStripe互換の idempotency エラー) |
| キーなしのPOST | 400 Bad Request、またはビジネスキーで自然べき等 |
| 処理中の再送 | 完了待ち、または processing レスポンス |
| Redis/DB障害後の再送 | PostgreSQL から復元し二重決済しない |
| Webhook重複 | event ID でスキップ |
よくある落とし穴:
- 500を返してしまう — 再送が止まらずサポート対応が複雑化。処理済みなら200+同じボディを返す
- TTLが短すぎる — 24時間未満だと、翌日の遅延再送で二重処理される
- キーをユーザー入力にする — 推測可能だと衝突・悪用のリスク。UUID v4をサーバーまたはクライアントで生成
- Outbox未実装 — DB更新前にプロセス死亡すると、再実行時に外部APIだけ二重呼び出し。pending → completed の状態管理かOutboxパターンを検討
「注文番号 ORD-20260620-001 は1回だけ」というユニーク制約も有効です。ただしクライアントが再送のたびに新しい注文番号を生成すると防げません。Natural Key は Idempotency-Key の補助線として使い、再送シナリオの主防御は Idempotency-Key に置くのが安全です。
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
まとめ
べき等性は教科書の概念ではなく、タイムアウトと再送が日常にあるWeb APIにおける必須設計です。HTTPメソッド表を起点に非べき等なPOSTを特定し、Stripeと同様 Idempotency-Key で重複を束ね、PostgreSQL UPSERT で結果を永続化、Redis SET NX で競合を抑えます。決済の二重チャージシナリオはALBタイムアウトと再送だけで再現するため、クライアントの「同じキーで再送」とサーバーの「同じキーなら同じレスポンス」をセットで徹底してください。Webhook経路は WebHook設計のベストプラクティス のイベントID戦略と合わせ、同期・非同期の両方で重複を塞ぐと、信頼性の高い決済・注文APIになります。