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

Chat with Us on LINE

iiitum1984

Speak to Us or Whatsapp

(+66) 83001 0222

Related Posts

Our Products