Idempotency in Payment APIs: Prevent Double Charges with Stripe, Omise, and 2C2P

When a customer clicks "Pay Now" and the request times out — what happens next?

Without idempotency in your payment API, the answer might be: the customer gets charged twice. Or the order gets created twice. Or the webhook fires, your server crashes mid-process, and now the inventory is decremented but the order row was never committed.

These aren’t edge cases. They’re the failure modes that hit during flash sales, slow payment gateway responses, mobile connection drops, or Kubernetes pod evictions between a database write and an API response.

In this guide, you’ll learn how to implement idempotency in payment APIs — with real code in FastAPI and PostgreSQL, and concrete examples for Stripe, Omise, and 2C2P.


What is idempotency in a payment API?

An operation is idempotent if executing it multiple times produces the same result as executing it once.

For a payment API, this means: no matter how many times a "create charge" request is retried — due to network timeouts, client retries, or duplicate webhook deliveries — exactly one charge is created, for exactly one amount, and the customer’s card is debited exactly once.

The mechanism that makes this possible is the idempotency key: a unique identifier generated by the client, attached to each payment attempt, and sent with every retry of the same request.

TL;DR: Idempotency key = unique ID per payment intent, generated client-side, sent with every retry. The server uses it to return a cached result instead of processing the request again.


Why idempotency matters for ecommerce and fintech APIs

Distributed systems fail in three ways that require idempotency:

  • Pre-server failure — the request never reaches the server. Safe to retry without protection.
  • Mid-processing failure — the request reaches the server, processing starts, but fails before completion. Retry without idempotency = duplicate charge.
  • Post-processing failure — the server processes successfully but the confirmation never reaches the client. Retry without idempotency = duplicate charge.

Only the first scenario is safe to retry naively. The second and third — which are the most common in production — require an idempotency layer on both the payment gateway side and your own backend.


How Stripe, Omise, and 2C2P implement idempotency keys

All three payment gateways support idempotency, but the mechanics differ.

Stripe idempotency key (Python)

Stripe uses the Idempotency-Key request header. Generate a UUID per payment attempt on the client, send it with the charge creation, and Stripe caches the response for 24 hours. Any retry with the same key returns the original response — no second charge.

import stripe
import uuid

stripe.api_key = "sk_live_..."

idempotency_key = str(uuid.uuid4())  # generated once per payment attempt, client-side

charge = stripe.PaymentIntent.create(
    amount=150000,       # THB 1,500.00 in satang
    currency="thb",
    payment_method="pm_xxxx",
    confirm=True,
    idempotency_key=idempotency_key,
)

Stripe’s contract: sending the same Idempotency-Key with different parameters returns a 400 error. The same key must always carry the same intent — the key is a commitment, not just a label.

Stripe also caches failures. If the first attempt returned a 500, retries with the same key return that same 500. This is intentional — it prevents silent state divergence between the client and server.

Omise idempotency key (Python)

Omise — widely used in Thailand and Japan — follows an identical header pattern to Stripe. The Idempotency-Key header is supported on all charge creation endpoints, with a 24-hour cache window.

import omise
import uuid

omise.api_secret = "skey_live_..."

idempotency_key = str(uuid.uuid4())

charge = omise.Charge.create(
    amount=150000,
    currency="thb",
    card="tokn_xxxx",
    idempotency_key=idempotency_key,
)

Omise-specific note: when using 3DS flows, the idempotency key applies to the initial charge creation — not the 3DS authorization redirect. The return URL callback from the card issuer is stateless and must be handled separately. Store the original idempotency_key alongside your order record so you can correlate the callback to the original intent.

2C2P idempotency via invoiceNo

2C2P takes a different approach: idempotency is built into the request payload via the invoiceNo field — a merchant-supplied unique identifier per transaction. Submitting the same invoiceNo twice returns the result of the first attempt.

import hashlib
import hmac
import json

def build_2c2p_payment_request(order_id: str, amount_thb: float, secret_key: str) -> dict:
    invoice_no = f"ORDER-{order_id}"  # this IS the idempotency key for 2C2P

    payload = {
        "merchantID": "YOUR_MERCHANT_ID",
        "invoiceNo": invoice_no,
        "description": f"Order {order_id}",
        "amount": f"{amount_thb:.2f}",
        "currencyCode": "764",         # THB 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

Because invoiceNo drives deduplication at 2C2P’s end, your order ID must be stable and persisted before you call 2C2P — never generate it inside the function that builds the payment request.


Gateway-level idempotency is not enough

The payment gateway protects against duplicate charges at the PSP layer. But it does nothing to protect your own database from:

  • Creating two order rows when a timeout causes a client retry
  • Decrementing inventory twice from two concurrent retries
  • Sending two order confirmation emails from duplicate webhook deliveries

For this, you need your own server-side idempotency layer.


Deduplication table design in PostgreSQL

The core pattern: before processing any payment-related write, check whether you’ve already processed this idempotency key. If yes, return the stored result. If no, acquire a lock, process, and store.

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 of serialized request body
    response_status INT,
    response_body   JSONB,
    locked_at       TIMESTAMPTZ,        -- set when processing starts (distributed lock)
    completed_at    TIMESTAMPTZ,        -- set when processing finishes
    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);  -- for nightly cleanup job

The locked_at column is the critical piece: it prevents two concurrent requests with the same key from both passing the "not yet processed" check simultaneously. The ON CONFLICT DO NOTHING insert + lock check is your distributed mutex without Redis.


FastAPI implementation: IdempotencyGuard dependency

Here is a reusable FastAPI dependency that enforces idempotency across any payment endpoint.

Core idempotency functions

# 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 ensures only one concurrent
    request wins the lock for a given key.
    """
    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. Checks idempotency key header, returns cached
    response on replay, returns 409 if in-flight, acquires lock for
    new requests.
    """

    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="Idempotency-Key header is required for payment endpoints.",
                )
            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 reused with a different request body.",
                )
            if record["completed_at"]:
                # Already processed — return the cached result
                return JSONResponse(
                    content=record["response_body"],
                    status_code=record["response_status"],
                    headers={"Idempotency-Replayed": "true"},
                )
            if record["locked_at"] and not record["completed_at"]:
                # In-flight by a concurrent request
                raise HTTPException(
                    status_code=status.HTTP_409_CONFLICT,
                    detail="A request with this Idempotency-Key is currently being processed. Retry after a short backoff.",
                )

        # New key — acquire lock and proceed
        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 endpoint using the guard

# 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_thb: 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:
        # Forward the same idempotency_key to Omise as well
        charge = await charge_via_omise(
            amount_satang=int(body.amount_thb * 100),
            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_thb": str(body.amount_thb),
        }

        await complete_idempotency_key(db, idempotency_key, 201, result)
        return result, 201

    except Exception as e:
        # Transient error (network, PSP timeout) → delete key so client can retry
        # Terminal error (card declined) → complete_idempotency_key with error response
        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 deduplication: handling at-least-once delivery

Both Stripe and Omise guarantee at-least-once webhook delivery — meaning your endpoint may receive the same event multiple times. Your webhook handler must also be idempotent, using the event ID as the 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="Missing 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", etc.

    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"}

The same idempotency_keys table is reused — just prefix webhook keys (webhook:evnt_xxxx) to avoid namespace collision with payment endpoint keys.


Key design decisions

1. Idempotency key expiry window

Match your expiry to the gateway’s cache window. Stripe and Omise retain for 24 hours. Set your PostgreSQL row to match:

expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'

Run a nightly cleanup to prevent table bloat:

DELETE FROM idempotency_keys WHERE expires_at < now();

2. Failure policy: delete or keep the key?

Error type Recommended policy Reason
Network timeout / PSP 5xx Delete the key Allow client to retry with same key
Card declined (terminal) Keep with error response Prevent retry loops on unrecoverable errors
Invalid token / bad request Keep with 4xx response Same — no point retrying

3. Concurrent request handling

The ON CONFLICT DO NOTHING insert guarantees only one concurrent request acquires the lock. The second request sees locked_at IS NOT NULL, completed_at IS NULL and receives a 409. The client should apply exponential backoff before retrying.

4. Key generation responsibility

Always generate the idempotency key client-side (frontend or the calling microservice) — never server-side. If the server generates the key, a retry after a timeout would produce a new key and completely bypass the deduplication layer.


Idempotency key lifecycle: quick reference

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 -->|
  |                           |                              |-- process once
  |                           |<-- charge response           |
  |                           |                              |
  |                           |-- UPDATE completed_at        |
  |<-- 201 order created      |                              |
  |                           |                              |
  | [network drop — client retries]                          |
  |                           |                              |
  |-- POST /payments/charge   |                              |
  |   Idempotency-Key: abc123 |                              |
  |                           |-- SELECT idempotency_keys    |
  |                           |   completed_at IS NOT NULL   |
  |<-- 201 (cached response)  |                              |
  |   Idempotency-Replayed: true                             |

Summary

Layer Gateway / Tool Idempotency mechanism
PSP — Stripe Idempotency-Key header 24-hour response cache
PSP — Omise Idempotency-Key header 24-hour response cache
PSP — 2C2P invoiceNo in payload Deduplication by merchant invoice
Your API FastAPI + PostgreSQL idempotency_keys table + ON CONFLICT DO NOTHING lock
Webhooks Omise / Stripe event ID Prefixed key in same deduplication table

Idempotency is not optional for payment systems. The deduplication table pattern adds less than 2ms overhead per request (a single indexed lookup), and it eliminates an entire class of double-charge bugs that are simultaneously the hardest to debug and the most damaging to customer trust.

Simplico builds payment integrations, ERP connectors, and AI-powered backend systems for Thai, Japanese, and global clients. 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