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

Chat with Us on LINE

iiitum1984

Speak to Us or Whatsapp

(+66) 83001 0222

Related Posts

Our Products