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
Related Posts
- ERP项目为何失败(以及如何让你的项目成功)
- Why ERP Projects Fail (And How to Make Yours Succeed)
- Payment API幂等性设计:用Stripe、支付宝、微信支付和2C2P防止重复扣款
- Agentic AI in SOC Workflows: Beyond Playbooks, Into Autonomous Defense (2026 Guide)
- 从零构建SOC:Wazuh + IRIS-web 真实项目实战报告
- Building a SOC from Scratch: A Real-World Wazuh + IRIS-web Field Report
- 中国品牌出海东南亚:支付、物流与ERP全链路集成技术方案
- 再生资源工厂管理系统:中国回收企业如何在不知不觉中蒙受损失
- 如何将电商平台与ERP系统打通:实战指南(2026年版)
- AI 编程助手到底在用哪些工具?(Claude Code、Codex CLI、Aider 深度解析)
- 使用 Wazuh + 开源工具构建轻量级 SOC:实战指南(2026年版)
- 能源管理软件的ROI:企业电费真的能降低15–40%吗?
- The ROI of Smart Energy: How Software Is Cutting Costs for Forward-Thinking Businesses
- How to Build a Lightweight SOC Using Wazuh + Open Source
- How to Connect Your Ecommerce Store to Your ERP: A Practical Guide (2026)
- What Tools Do AI Coding Assistants Actually Use? (Claude Code, Codex CLI, Aider)
- How to Improve Fuel Economy: The Physics of High Load, Low RPM Driving
- 泰国榴莲仓储管理系统 — 批次追溯、冷链监控、GMP合规、ERP对接一体化
- Durian & Fruit Depot Management Software — WMS, ERP Integration & Export Automation
- 现代榴莲集散中心:告别手写账本,用系统掌控你的生意













