Payment APIにおけるIdempotencyとは何か
冪等な操作(Idempotent operation)とは、何度実行しても結果が同一となる操作のことである。
Payment APIの文脈では、「課金リクエスト」がタイムアウト・クライアントリトライ・Webhookの重複配信によって何度送信されたとしても、課金は必ず一度だけ、同額で、ユーザーの口座から一回だけ引き落とされることを意味する。
この保証を実現するのがIdempotency Keyである。クライアント側で生成する一意識別子で、各Paymentトライに紐づけてリクエストごとに送信する。サーバー側はこのキーを使って、再処理ではなくキャッシュ済みの結果を返す。
要点:Idempotency Key = 1つの決済インテントに対する一意ID。クライアント側で生成し、リトライのたびに送信する。サーバーはこのキーで重複リクエストを検出し、処理をスキップしてキャッシュ済みレスポンスを返す。
なぜECサイト・Fintechシステムにとって冪等性が不可欠なのか
分散システムは3種類の障害パターンを持ち、それぞれIdempotencyの要否が異なる。
- Pre-server failure — リクエストがサーバーに到達しない。この場合のリトライは安全である。
- Mid-processing failure — リクエストはサーバーに到達したが、処理途中で失敗した。Idempotencyなしのリトライ = 二重課金。
- Post-processing failure — サーバーは正常に処理を完了したが、レスポンスがクライアントに到達しなかった。Idempotencyなしのリトライ = 二重課金。
最初のケースのみリトライが安全である。残りの2つ——実際のプロダクション環境で最も頻繁に発生するパターン——はPaymentゲートウェイ側と自社バックエンド側の両方でIdempotencyレイヤーが必要となる。
日本市場特有の観点として、以下も考慮が必要である。
- コンビニ払い・銀行振込:非同期の入金確認Webhookが複数回配信されるケースがある。
- PayPay・楽天ペイなどのQRコード決済:決済完了通知の遅延や重複配信が発生しうる。
- インボイス制度対応システム:取引記録の重複は適格請求書の整合性に直接影響し、消費税申告に誤りが生じるリスクがある。
- 割賦販売法・資金決済法:決済ログの正確性は法令上の要件でもある。
Stripe・Omise・2C2PにおけるIdempotency Keyの実装
3つのPaymentゲートウェイはいずれもIdempotencyをサポートしているが、実装方式が異なる。
Stripe Idempotency Key(Python)
StripeはIdempotency-Keyリクエストヘッダーを使用する。クライアント側でUUIDを生成し、課金リクエストとともに送信する。Stripeは24時間レスポンスをキャッシュし、同じキーによるリトライには元のレスポンスを返す——二度目の課金は発生しない。
import stripe
import uuid
stripe.api_key = "sk_live_..."
idempotency_key = str(uuid.uuid4()) # 1つの決済試行につき1回生成、クライアント側で
charge = stripe.PaymentIntent.create(
amount=150000, # 1,500円(最小単位:円)
currency="jpy",
payment_method="pm_xxxx",
confirm=True,
idempotency_key=idempotency_key,
)
Stripeのコントラクト:同じIdempotency-Keyを異なるパラメーターで送信した場合、400エラーが返される。同じキーは常に同じインテントを意味しなければならない——キーはコミットメントであり、単なるラベルではない。
また、Stripeは失敗もキャッシュする。最初のリクエストが500を返した場合、同じキーでのリトライも同じ500が返される。これはクライアントとサーバー間のステート不整合を防ぐための意図的な設計である。
Omise Idempotency Key(Python)
Omise——タイおよび日本で広く使われている——はStripeと同一のヘッダーパターンを採用している。Idempotency-Keyヘッダーはすべての課金作成エンドポイントでサポートされており、24時間のキャッシュウィンドウが設定されている。
import omise
import uuid
omise.api_secret = "skey_live_..."
idempotency_key = str(uuid.uuid4())
charge = omise.Charge.create(
amount=150000,
currency="jpy",
card="tokn_xxxx",
idempotency_key=idempotency_key,
)
Omise 3DSフローに関する注意:3D Secure認証フローを使用する場合、Idempotency Keyは最初の課金作成にのみ適用される。3DS認証リダイレクトは対象外である。カード発行会社からのReturn URLコールバックはステートレスのため個別に処理する必要がある。元のidempotency_keyをOrderレコードとともに保存し、コールバックと元のインテントを紐付けること。
2C2P IdempotencyとinvoiceNo
2C2Pは異なるアプローチを採用している。Idempotencyはリクエストペイロード内のinvoiceNoフィールドに組み込まれており、マーチャントが取引ごとに一意識別子を指定する。同じinvoiceNoを2回送信すると、最初の試行の結果が返される。
import hashlib
import hmac
import json
def build_2c2p_payment_request(order_id: str, amount_jpy: float, secret_key: str) -> dict:
invoice_no = f"ORDER-{order_id}" # これが2C2PのIdempotency Key
payload = {
"merchantID": "YOUR_MERCHANT_ID",
"invoiceNo": invoice_no,
"description": f"Order {order_id}",
"amount": f"{amount_jpy:.2f}",
"currencyCode": "392", # JPY ISO 4217
"paymentChannel": ["CC"],
}
payload_str = json.dumps(payload, separators=(",", ":"))
signature = hmac.new(
secret_key.encode(), payload_str.encode(), hashlib.sha256
).hexdigest()
payload["signature"] = signature
return payload
invoiceNoが2C2P側の重複排除を担うため、Order IDはは2C2Pを呼び出す前に生成・永続化しておく必要がある。Payment Requestを構築する関数の内部で生成してはならない。
ゲートウェイ側のIdempotencyだけでは不十分である
Paymentゲートウェイが保護するのはPSPレベルでの二重課金のみである。自社データベース側の以下の問題は保護されない。
- タイムアウト後のクライアントリトライによるOrderレコードの重複作成
- 2つの同時リトライによる在庫の二重減算
- Webhookの重複配信による注文確認メールの二重送信
これらに対応するには、自社サーバー側のIdempotencyレイヤーが必要である。
PostgreSQLにおけるDeduplication Tableの設計
基本パターン:Payment関連の書き込みを行う前に、このIdempotency Keyがすでに処理済みかどうかを確認する。処理済みであれば保存済みの結果を返す。未処理であればロックを取得し、処理を実行し、結果を保存する。
CREATE TABLE idempotency_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
request_path TEXT NOT NULL,
request_hash TEXT NOT NULL, -- リクエストボディのSHA-256ハッシュ
response_status INT,
response_body JSONB,
locked_at TIMESTAMPTZ, -- 処理開始時にセット(分散ロック)
completed_at TIMESTAMPTZ, -- 処理完了時にセット
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
);
CREATE INDEX ON idempotency_keys (key);
CREATE INDEX ON idempotency_keys (expires_at); -- 夜間クリーンアップジョブ用
locked_atカラムが核心部分である。これにより、同じキーを持つ2つの同時リクエストが「未処理」チェックを同時に通過することを防ぐ。ON CONFLICT DO NOTHINGインサートとロックチェックの組み合わせが、Redisなしの分散ミューテックスとして機能する。
FastAPI実装:IdempotencyGuard Dependency
以下は、任意のPaymentエンドポイントでIdempotencyを強制するための再利用可能なFastAPI Dependencyである。
中核となる関数群
# app/dependencies/idempotency.py
import hashlib
import json
from typing import Optional
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.db import get_db
async def get_idempotency_record(db: AsyncSession, key: str) -> Optional[dict]:
result = await db.execute(
text("""
SELECT id, request_path, request_hash, response_status,
response_body, locked_at, completed_at
FROM idempotency_keys
WHERE key = :key AND expires_at > now()
"""),
{"key": key},
)
return result.mappings().first()
async def acquire_idempotency_lock(
db: AsyncSession, key: str, request_path: str, request_hash: str
):
"""
INSERT ... ON CONFLICT DO NOTHING により、特定のキーに対して
同時に1つのリクエストのみがロックを取得できることを保証する。
"""
await db.execute(
text("""
INSERT INTO idempotency_keys (key, request_path, request_hash, locked_at)
VALUES (:key, :path, :hash, now())
ON CONFLICT (key) DO NOTHING
"""),
{"key": key, "path": request_path, "hash": request_hash},
)
await db.commit()
async def complete_idempotency_key(
db: AsyncSession, key: str, status_code: int, response_body: dict
):
await db.execute(
text("""
UPDATE idempotency_keys
SET response_status = :status,
response_body = :body,
completed_at = now()
WHERE key = :key
"""),
{"key": key, "status": status_code, "body": json.dumps(response_body)},
)
await db.commit()
class IdempotencyGuard:
"""
FastAPI Dependency。Idempotency-Keyヘッダーを検査し、
リプレイ時はキャッシュ済みレスポンスを返す。
処理中の場合は409を返す。新規リクエストはロックを取得して処理を続行する。
"""
def __init__(self, require: bool = True):
self.require = require
async def __call__(
self,
request: Request,
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
db: AsyncSession = Depends(get_db),
):
if not idempotency_key:
if self.require:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="このPaymentエンドポイントにはIdempotency-Keyヘッダーが必要です。",
)
return None
body = await request.body()
request_hash = hashlib.sha256(body).hexdigest()
record = await get_idempotency_record(db, idempotency_key)
if record:
if record["request_hash"] != request_hash:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="このIdempotency-Keyはすでに異なるリクエストボディで使用されています。",
)
if record["completed_at"]:
# 処理済み — キャッシュ済みレスポンスを返す
return JSONResponse(
content=record["response_body"],
status_code=record["response_status"],
headers={"Idempotency-Replayed": "true"},
)
if record["locked_at"] and not record["completed_at"]:
# 別の同時リクエストが処理中
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="このIdempotency-Keyのリクエストは現在処理中です。しばらく待ってからリトライしてください。",
)
# 新規キー — ロックを取得して処理を続行
await acquire_idempotency_lock(db, idempotency_key, request.url.path, request_hash)
request.state.idempotency_key = idempotency_key
request.state.idempotency_db = db
return idempotency_key
Paymentエンドポイント
# app/routers/payments.py
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from app.dependencies.idempotency import IdempotencyGuard, complete_idempotency_key
from app.services.omise import charge_via_omise
from app.services.orders import create_order_record
from app.db import get_db
router = APIRouter()
idempotency_guard = IdempotencyGuard(require=True)
class CreateChargeRequest(BaseModel):
order_id: str
amount_jpy: Decimal
omise_token: str
@router.post("/payments/charge", dependencies=[Depends(idempotency_guard)])
async def create_charge(
body: CreateChargeRequest,
request: Request,
db=Depends(get_db),
):
idempotency_key = request.state.idempotency_key
try:
# 同じidempotency_keyをOmise側にも転送する
charge = await charge_via_omise(
amount_unit=int(body.amount_jpy), # JPYは最小単位が円
token=body.omise_token,
idempotency_key=idempotency_key,
)
order = await create_order_record(db, body.order_id, charge["id"])
result = {
"order_id": str(order.id),
"charge_id": charge["id"],
"status": charge["status"],
"amount_jpy": str(body.amount_jpy),
}
await complete_idempotency_key(db, idempotency_key, 201, result)
return result, 201
except Exception as e:
# 一時的なエラー(ネットワーク、PSPタイムアウト)→ キーを削除してリトライ可能にする
# 終端エラー(カード拒否)→ エラーレスポンスを保存してリトライループを防ぐ
await db.execute(
text("DELETE FROM idempotency_keys WHERE key = :key"),
{"key": idempotency_key},
)
await db.commit()
raise HTTPException(status_code=502, detail=str(e))
Webhookの重複排除:At-Least-Once配信への対応
StripeとOmiseはいずれもat-least-onceのWebhook配信を保証している——つまり、同一のイベントを複数回受信する可能性がある。WebhookハンドラーもEvent IDをDeduplication Keyとして使用することで冪等にしなければならない。
# app/routers/webhooks.py
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from app.dependencies.idempotency import (
acquire_idempotency_lock,
complete_idempotency_key,
get_idempotency_record,
)
from app.db import get_db
router = APIRouter()
@router.post("/webhooks/omise")
async def omise_webhook(request: Request, db=Depends(get_db)):
payload = await request.json()
event_id = payload.get("id") # 例:"evnt_xxxx"
if not event_id:
raise HTTPException(status_code=400, detail="Event IDが見つかりません")
webhook_key = f"webhook:{event_id}"
record = await get_idempotency_record(db, webhook_key)
if record and record["completed_at"]:
return {"status": "already_processed"}
await acquire_idempotency_lock(
db,
key=webhook_key,
request_path="/webhooks/omise",
request_hash=hashlib.sha256(str(payload).encode()).hexdigest(),
)
event_type = payload.get("key") # 例:"charge.complete"、"charge.expire"
if event_type == "charge.complete":
charge_id = payload["data"]["id"]
await mark_order_paid(db, charge_id)
elif event_type == "charge.expire":
charge_id = payload["data"]["id"]
await mark_order_expired(db, charge_id)
await complete_idempotency_key(db, webhook_key, 200, {"ok": True})
return {"status": "processed"}
同じidempotency_keysテーブルを再利用する。WebhookキーはPaymentエンドポイントキーと名前空間が衝突しないようwebhook:プレフィックスを付与すること。
設計上の重要な判断事項
1. Keyの有効期限ウィンドウ
有効期限をゲートウェイのキャッシュウィンドウに合わせる。StripeとOmiseは24時間保持する。PostgreSQLのレコードも同様に設定する。
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
テーブルの肥大化を防ぐため、夜間クリーンアップを実行する。
DELETE FROM idempotency_keys WHERE expires_at < now();
2. 失敗時のポリシー:Keyを削除するか保持するか
| エラー種別 | 推奨ポリシー | 理由 |
|---|---|---|
| ネットワークタイムアウト / PSP 5xx | Keyを削除 | クライアントが同じKeyでリトライできるようにする |
| カード拒否(終端エラー) | エラーレスポンスを保持 | 回復不可能なエラーでのリトライループを防ぐ |
| 無効なトークン / Bad Request | 4xxレスポンスを保持 | 同上——リトライしても意味がない |
3. 同時リクエストの処理
ON CONFLICT DO NOTHINGインサートにより、同時リクエストのうち1つだけがロックを取得できることが保証される。2つ目のリクエストはlocked_at IS NOT NULL, completed_at IS NULLを検出して409を受け取る。クライアントはExponential Backoffを適用してからリトライすること。
4. KeyはどちらのサイドTで生成するか
Idempotency Keyは必ずクライアント側(フロントエンドまたは呼び出し元のマイクロサービス)で生成すること。サーバー側で生成してはならない。サーバー側で生成した場合、タイムアウト後のリトライで毎回新しいKeyが生成され、Deduplicationレイヤーを完全に迂回してしまう。
Idempotency Keyのライフサイクル
Client Your API Payment Gateway (Omise/Stripe)
| | |
|-- POST /payments/charge | |
| Idempotency-Key: abc123 | |
| |-- INSERT idempotency_keys |
| | (ON CONFLICT DO NOTHING) |
| | |
| |-- POST charge |
| | Idempotency-Key: abc123 -->|
| | |-- 1回だけ処理
| |<-- charge response |
| | |
| |-- UPDATE completed_at |
|<-- 201 Order作成完了 | |
| | |
| [ネットワーク断 -- クライアントがリトライ] |
| | |
|-- POST /payments/charge | |
| Idempotency-Key: abc123 | |
| |-- SELECT idempotency_keys |
| | completed_at IS NOT NULL |
|<-- 201 (キャッシュ済みレスポンス) |
| Idempotency-Replayed: true |
まとめ
| レイヤー | ゲートウェイ / ツール | Idempotencyの仕組み |
|---|---|---|
| PSP — Stripe | Idempotency-Keyヘッダー |
24時間レスポンスキャッシュ |
| PSP — Omise | Idempotency-Keyヘッダー |
24時間レスポンスキャッシュ |
| PSP — 2C2P | ペイロード内invoiceNo |
マーチャントInvoiceによる重複排除 |
| 自社API | FastAPI + PostgreSQL | idempotency_keysテーブル + ON CONFLICT DO NOTHINGロック |
| Webhook | Omise / Stripe Event ID | 同一Deduplicationテーブルのプレフィックス付きKey |
決済システムにおいて冪等性はオプションではない。Deduplication Tableパターンがリクエストに追加するオーバーヘッドは2ms未満(インデックス付きの1回のルックアップ)であり、デバッグが最も困難でかつ顧客の信頼を最も損なう二重課金バグのクラス全体を排除できる。
Simplicoは、タイ・日本・グローバル市場のクライアント向けに、Payment Integration・ERPコネクター・AIを活用したバックエンドシステムの開発を行っている。simplico.net
Get in Touch with us
Related Posts
- 重要インフラへの攻撃:ウクライナ電力網から学ぶIT/OTセキュリティの教訓
- LM Studioのコーディング向けシステムプロンプト設計:`temperature`・`context_length`・`stop`トークン徹底解説
- LlamaIndex + pgvector:日本語・タイ語ビジネス文書に対応したRAGの本番運用
- simpliShop:受注生産・多言語対応のタイ向けECプラットフォーム
- ERPプロジェクトが失敗する理由と成功のための実践的アプローチ
- Agentic AI × SOCワークフロー:プレイブックを超えた自律防御【2026年版ガイド】
- SOCをゼロから構築する:Wazuh + IRIS-web 現場レポート
- ECと基幹システムの二重入力をなくす:受注から仕訳までの自動化アーキテクチャ
- SIerのブラックボックスから脱却する:オープンソースで構築する中小企業向けSOCアーキテクチャ
- リサイクル工場管理システム:日本のリサイクル事業者が見えないところで損をしている理由
- エネルギー管理ソフトウェアのROI:電気代を15〜40%削減できる理由
- Wazuh + オープンソースで構築する軽量SOC:実践ガイド(2026年版)
- ECサイトとERPを正しく連携する方法:実践ガイド(2026年版)
- AI コーディングアシスタントが実際に使うツールとは?(Claude Code・Codex CLI・Aider)
- 燃費を本気で改善する:高負荷・低回転走行の物理学
- タイ産ドリアン・青果物デポ向け倉庫管理システム(WMS)— ERP連携・輸出書類自動化
- 現代のドリアン集荷場:手書き台帳をやめて、システムでビジネスを掌握する
- AI System Reverse Engineering:AIでレガシーソフトウェアシステムを理解する(Architecture・Code・Data)
- 人間の優位性:AIが代替できないソフトウェア開発サービス
- ゼロからOCPPへ:ホワイトラベルEV充電プラットフォームの構築













