Idempotency ใน Payment API คืออะไร?
ลองนึกภาพนี้ — ลูกค้ากดปุ่ม "ชำระเงิน" แล้ว connection หลุดกลางคัน แอปค้าง ไม่มี response กลับมา แล้วระบบของคุณจะทำอะไรต่อ?
ถ้าไม่ได้ออกแบบ idempotency ไว้ คำตอบที่น่ากลัวคือ: ลูกค้าอาจถูกตัดเงินสองครั้ง หรือ order ถูกสร้างซ้ำ หรือ webhook ยิงมา server crash กลางทาง inventory ลดไปแล้วแต่ order row ไม่เคยถูก commit
เรื่องพวกนี้ไม่ใช่ edge case ที่หายาก — มันเกิดจริงตอน flash sale สินค้าหมด เกิดตอน payment gateway ช้า เกิดตอนสัญญาณมือถือลูกค้าหลุด หรือตอน Kubernetes pod ถูก evict ระหว่างที่ระบบกำลัง write ข้อมูลค้างอยู่
บทความนี้จะพาคุณออกแบบ idempotent payment flow อย่างถูกต้อง พร้อมโค้ดจริงใน FastAPI + PostgreSQL และตัวอย่างสำหรับ Omise, 2C2P และ Stripe ซึ่งเป็น payment gateway ที่นิยมใช้กันในไทย
Idempotent operation คือการดำเนินการที่ไม่ว่าจะเรียกกี่ครั้ง ผลลัพธ์ก็เหมือนเดิมเสมอ
ในบริบทของ payment API หมายความว่า: ไม่ว่า request "สร้าง charge" จะถูก retry กี่ครั้ง — จากการ timeout, client retry, หรือ webhook ยิงซ้ำ — ระบบต้องตัดเงินเพียงครั้งเดียว จำนวนเดิม และบัญชีลูกค้าถูกหักเงินแค่หนึ่งครั้งเท่านั้น
กลไกที่ทำให้เป็นไปได้คือ idempotency key — unique identifier ที่ฝั่ง client สร้างขึ้น แนบไปกับทุก request ของ payment attempt นั้น และส่งไปทุกครั้งที่มีการ retry
สรุปสั้น ๆ: Idempotency key = unique ID ต่อ 1 การชำระเงิน, สร้างฝั่ง client, ส่งทุกครั้งที่ retry — server ใช้ key นี้คืน cached result แทนการประมวลผลซ้ำ
ทำไม Idempotency ถึงสำคัญสำหรับระบบ ecommerce และ fintech ในไทย
ระบบแบบ distributed มักล้มเหลวใน 3 รูปแบบที่ต้องการ idempotency:
- Pre-server failure — request ไม่เคยถึง server เลย ในกรณีนี้ retry ได้ปลอดภัย
- Mid-processing failure — request ถึง server แล้ว เริ่มประมวลผล แต่ล้มกลางคัน Retry โดยไม่มี idempotency = ตัดเงินซ้ำ
- Post-processing failure — server ประมวลผลสำเร็จ แต่ response ไม่ถึง client Retry โดยไม่มี idempotency = ตัดเงินซ้ำ
สถานการณ์แรกเท่านั้นที่ retry ได้ปลอดภัย สองสถานการณ์หลัง — ซึ่งพบบ่อยที่สุดใน production — ต้องการ idempotency layer ทั้งฝั่ง payment gateway และฝั่ง backend ของคุณ
ในบริบทของไทย ความเสี่ยงยิ่งสูงขึ้นเพราะ:
- ผู้ใช้จำนวนมากชำระผ่านมือถือที่สัญญาณไม่เสถียร
- QR Payment และ PromptPay มี webhook callback ที่อาจยิงซ้ำเมื่อ bank ยืนยันช้า
- ตาม PDPA การ log transaction ที่ผิดพลาดซ้ำอาจกระทบข้อมูลส่วนบุคคลของผู้ใช้โดยไม่จำเป็น
วิธีที่ Stripe, Omise และ 2C2P รองรับ Idempotency Key
Payment gateway ทั้งสามรองรับ idempotency แต่กลไกต่างกัน
Stripe Idempotency Key (Python)
Stripe ใช้ HTTP header ชื่อ Idempotency-Key — สร้าง UUID ฝั่ง client แล้วส่งไปพร้อม request สร้าง charge Stripe จะ cache response ไว้ 24 ชั่วโมง ถ้า retry ด้วย key เดิม จะได้ response เดิมกลับมา — ไม่มีการตัดเงินซ้ำ
import stripe
import uuid
stripe.api_key = "sk_live_..."
idempotency_key = str(uuid.uuid4()) # สร้างครั้งเดียวต่อ payment attempt, ฝั่ง client
charge = stripe.PaymentIntent.create(
amount=150000, # 1,500.00 บาท (หน่วยเป็นสตางค์)
currency="thb",
payment_method="pm_xxxx",
confirm=True,
idempotency_key=idempotency_key,
)
ข้อตกลงของ Stripe: ถ้าส่ง Idempotency-Key เดิมแต่ parameter ต่างกัน จะได้ error 400 กลับมา key เดิมต้องหมายถึง intent เดิมเสมอ
Stripe ยัง cache ความล้มเหลวด้วย — ถ้า attempt แรก return 500 การ retry ด้วย key เดิมก็จะได้ 500 เหมือนเดิม พฤติกรรมนี้ตั้งใจออกแบบมาเพื่อป้องกัน state divergence ระหว่าง client กับ server
Omise Idempotency Key (Python)
Omise — ที่นิยมใช้กันมากในไทย — ใช้ pattern เดียวกับ Stripe ทุกประการ header Idempotency-Key รองรับบน endpoint สร้าง charge ทุกตัว โดย cache 24 ชั่วโมงเช่นกัน
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 3DS: เมื่อใช้ flow 3D Secure idempotency key ครอบคลุมแค่ขั้นตอนสร้าง charge เท่านั้น — ไม่รวม redirect ของ 3DS authorization การ callback จาก return URL ของ card issuer เป็น stateless ต้องจัดการแยก ควรเก็บ idempotency_key เดิมไว้คู่กับ order record เพื่อ correlate callback กลับมาหา intent เดิม
2C2P Idempotency ผ่าน invoiceNo
2C2P ใช้วิธีต่างออกไป: idempotency ฝังอยู่ใน request payload ผ่าน field invoiceNo — unique identifier ที่ merchant กำหนดเองต่อ transaction ถ้าส่ง invoiceNo เดิมสองครั้ง 2C2P จะ return ผลลัพธ์ของครั้งแรก
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}" # นี่คือ idempotency key ของ 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
เพราะ invoiceNo คือกลไก deduplication ของ 2C2P order ID ต้องถูก generate และ persist ก่อน เรียก 2C2P เสมอ — อย่า generate มันภายใน function ที่สร้าง payment request
Idempotency ฝั่ง Gateway ยังไม่พอ
Payment gateway ป้องกันการตัดเงินซ้ำที่ระดับ PSP เท่านั้น แต่ ไม่ได้ป้องกัน database ฝั่งคุณจาก:
- การสร้าง order row ซ้ำเมื่อ client retry หลัง timeout
- การลด inventory สองครั้งจาก concurrent retry สองตัว
- การส่ง email ยืนยัน order สองฉบับจาก webhook ที่ยิงซ้ำ
สำหรับกรณีเหล่านี้ คุณต้องมี idempotency layer ฝั่ง server ของตัวเอง
ออกแบบ Deduplication Table ใน PostgreSQL
Pattern หลัก: ก่อน write ข้อมูลที่เกี่ยวกับ payment ทุกครั้ง ตรวจสอบก่อนว่าเคยประมวลผล idempotency key นี้ไปแล้วหรือยัง ถ้าใช่ คืน result ที่เก็บไว้ ถ้าไม่ใช่ lock, ประมวลผล, และเก็บผลลัพธ์
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 ของ request body
response_status INT,
response_body JSONB,
locked_at TIMESTAMPTZ, -- set ตอนเริ่มประมวลผล (distributed lock)
completed_at TIMESTAMPTZ, -- set ตอนประมวลผลเสร็จ
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); -- สำหรับ cleanup job รายคืน
column locked_at คือหัวใจของระบบ: มันป้องกัน concurrent request สองตัวที่ใช้ key เดียวกันไม่ให้ผ่านการตรวจสอบ "ยังไม่ได้ประมวลผล" พร้อมกัน การ insert แบบ ON CONFLICT DO NOTHING คือ distributed mutex โดยไม่ต้องพึ่ง Redis
FastAPI Implementation: IdempotencyGuard Dependency
ด้านล่างคือ FastAPI dependency แบบ reusable ที่บังคับ idempotency บน payment endpoint ทุกตัว
ฟังก์ชันหลัก
# 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 การันตีว่ามีเพียง request เดียว
ที่ชนะการ lock สำหรับ 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 — ตรวจสอบ idempotency key header,
คืน cached response ถ้า replay, คืน 409 ถ้ากำลัง in-flight,
lock สำหรับ request ใหม่
"""
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 endpoint นี้ต้องการ Idempotency-Key header",
)
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 นี้ถูกใช้ไปแล้วกับ request body ที่ต่างกัน",
)
if record["completed_at"]:
# ประมวลผลแล้ว — คืน 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"]:
# กำลังถูกประมวลผลโดย concurrent request อื่น
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Request นี้กำลังถูกประมวลผลอยู่ กรุณา retry หลังจากรอสักครู่",
)
# Key ใหม่ — lock และดำเนินการต่อ
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
# 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:
# ส่ง idempotency_key เดิมไปหา Omise ด้วย
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 timeout, PSP 5xx) → ลบ key ให้ client retry ได้
# Terminal error (บัตรถูกปฏิเสธ) → เก็บ error response ไว้ป้องกัน retry loop
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: รับมือกับ At-Least-Once Delivery
ทั้ง Stripe และ Omise การันตี at-least-once webhook delivery — endpoint ของคุณอาจได้รับ event เดียวกันหลายครั้ง webhook handler จึงต้องเป็น idempotent ด้วยเช่นกัน โดยใช้ 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"}
ใช้ table idempotency_keys เดิมได้เลย — แค่ prefix key ด้วย webhook: เพื่อแยก namespace จาก payment endpoint key
การตัดสินใจออกแบบที่สำคัญ
1. ระยะเวลา Expiry ของ Key
ตั้ง expiry ให้ตรงกับ cache window ของ gateway Stripe และ Omise เก็บไว้ 24 ชั่วโมง ตั้ง PostgreSQL row ให้ match:
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '24 hours'
รัน cleanup job รายคืนเพื่อป้องกัน table บวม:
DELETE FROM idempotency_keys WHERE expires_at < now();
2. นโยบายเมื่อล้มเหลว: ลบ key หรือเก็บไว้?
| ประเภท Error | นโยบายที่แนะนำ | เหตุผล |
|---|---|---|
| Network timeout / PSP 5xx | ลบ key | ให้ client retry ด้วย key เดิมได้ |
| บัตรถูกปฏิเสธ (terminal error) | เก็บ error response | ป้องกัน retry loop บน error ที่ไม่สามารถแก้ได้ |
| Invalid token / bad request | เก็บ response 4xx | เหมือนกัน — retry ไปก็ไม่มีประโยชน์ |
3. การจัดการ Concurrent Request
ON CONFLICT DO NOTHING การันตีว่ามีเพียง request เดียวที่ lock ได้ Request ที่สองจะเห็น locked_at IS NOT NULL, completed_at IS NULL แล้วได้ 409 กลับไป — client ควร apply exponential backoff ก่อน retry
4. ใครเป็นคนสร้าง Key?
สร้าง idempotency key ฝั่ง client เสมอ (frontend หรือ microservice ที่เรียก) — ไม่ใช่ฝั่ง server ถ้า server เป็นคนสร้าง key การ retry หลัง timeout จะได้ key ใหม่ทุกครั้งและ bypass 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 -->|
| | |-- ประมวลผลครั้งเดียว
| |<-- charge response |
| | |
| |-- UPDATE completed_at |
|<-- 201 สร้าง order แล้ว | |
| | |
| [network หลุด — client retry] |
| | |
|-- POST /payments/charge | |
| Idempotency-Key: abc123 | |
| |-- SELECT idempotency_keys |
| | completed_at IS NOT NULL |
|<-- 201 (cached response) | |
| Idempotency-Replayed: true |
สรุป
| Layer | Gateway / Tool | กลไก Idempotency |
|---|---|---|
| PSP — Stripe | Idempotency-Key header |
Cache response 24 ชั่วโมง |
| PSP — Omise | Idempotency-Key header |
Cache response 24 ชั่วโมง |
| PSP — 2C2P | invoiceNo ใน payload |
Deduplication โดย merchant invoice |
| Your API | FastAPI + PostgreSQL | ตาราง idempotency_keys + lock ด้วย ON CONFLICT DO NOTHING |
| Webhooks | Omise / Stripe event ID | Prefixed key ในตาราง deduplication เดียวกัน |
Idempotency ไม่ใช่ optional สำหรับระบบ payment — มันคือข้อกำหนดพื้นฐาน Pattern deduplication table เพิ่ม overhead ไม่ถึง 2ms ต่อ request (indexed lookup ครั้งเดียว) แต่กำจัด bug ประเภท double-charge ซึ่งทั้งยากที่สุดจะ debug และทำลาย trust ของลูกค้ามากที่สุด
Simplico รับพัฒนาระบบ payment integration, ERP connector และ AI-powered backend สำหรับลูกค้าในไทย ญี่ปุ่น และตลาดโลก simplico.net
Get in Touch with us
Related Posts
- โครงสร้างพื้นฐานสำคัญภายใต้การโจมตี: บทเรียน OT Security จากสงครามยูเครน สู่องค์กรไทย
- System Prompt Engineering ใน LM Studio สำหรับการเขียนโค้ด: อธิบาย `temperature`, `context_length` และ `stop` tokens
- LlamaIndex + pgvector: RAG ระดับ Production สำหรับเอกสารธุรกิจไทยและญี่ปุ่น
- simpliShop: แพลตฟอร์มอีคอมเมิร์ซไทย รองรับสินค้าทำตามสั่งและหลายภาษาในระบบเดียว
- ทำไม ERP ถึงล้มเหลว (และจะทำให้โครงการของคุณสำเร็จได้อย่างไร)
- Agentic AI ใน SOC Workflows: เกินกว่า Playbook สู่การป้องกันอัตโนมัติ (คู่มือ 2026)
- สร้าง SOC ตั้งแต่ศูนย์: บันทึกจากสนามจริงด้วย Wazuh + IRIS-web
- ซอฟต์แวร์โรงงานรีไซเคิล: ระบบจัดการครบวงจรสำหรับธุรกิจรีไซเคิลไทย
- คืนทุนจากซอฟต์แวร์พลังงาน: ลดต้นทุนค่าไฟได้ 15–40% จริงหรือ?
- วิธีสร้าง SOC แบบ Lightweight ด้วย Wazuh + Open Source
- วิธีเชื่อมต่อร้านค้าออนไลน์กับระบบ ERP อย่างถูกต้อง: คู่มือปฏิบัติจริง (2026)
- AI Coding Assistant ใช้เครื่องมืออะไรอยู่เบื้องหลัง? (Claude Code, Codex CLI, Aider)
- ประหยัดน้ำมันอย่างได้ผล: ฟิสิกส์ของการขับด้วยโหลดสูง รอบต่ำ
- ระบบบริหารคลังทุเรียนและผลไม้ — WMS เชื่อมบัญชี สร้างเอกสารส่งออกอัตโนมัติ
- ล้งทุเรียนยุคใหม่: หยุดนับสต็อกด้วยกระดาษ เริ่มควบคุมธุรกิจด้วยระบบ
- AI System Reverse Engineering: ใช้ AI ทำความเข้าใจระบบซอฟต์แวร์ Legacy (Architecture, Code และ Data)
- ความได้เปรียบของมนุษย์: บริการพัฒนาซอฟต์แวร์ที่ AI ไม่อาจทดแทนได้
- จาก Zero สู่ OCPP: สร้างแพลตฟอร์มชาร์จ EV แบบ White-Label
- Wazuh Decoders & Rules: โมเดลความเข้าใจที่หายไป
- การสร้างระบบติดตาม OEE แบบเรียลไทม์สำหรับโรงงานอุตสาหกรรม













