Skip to content

Idempotency ​

Idempotency-Key เป็น header บังคับของทุก POST ไม่ใช่ตัวเลือก · ไม่ส่ง = 400 idempotency_key_required ตั้งแต่ก่อนแตะ business logic

กฎข้อเดียวที่ต้องจำ

เขียน key ลงฐานข้อมูลของคุณให้สำเร็จก่อน แล้วค่อยยิง request · การ retry ทุกครั้งต้อง อ่าน key เดิมจากฐานข้อมูล ไม่ใช่ gen ใหม่

merchant ที่ gen key ใหม่ตอน retry จะได้ออร์เดอร์ซ้ำ QR สองใบ และฝั่งถอนคือล็อกเงินสองรอบ

ข้อกำหนดพื้นฐาน ​

เรื่องค่า
endpoint ที่บังคับทุก POST
อายุของ key (TTL ฝั่งเรา)7 วัน
TTL ฝั่งคุณ🔴 ต้องไม่สั้นกว่า 7 วัน
ที่เก็บฐานข้อมูลที่ consistent ทันที (เราใช้ Postgres) 🔴 อย่าเก็บใน KV ที่ eventual
ความยาว/รูปแบบสตริงที่คุณกำหนดเอง ต้องไม่ซ้ำข้าม logical operation

ทำไม TTL ถึงเป็น 7 วัน — หน้าต่าง retry ของ webhook ยาว ~92 ชั่วโมง 36 นาที (ไม่ถึง 4 วัน) เราตั้ง TTL ให้ยาวกว่าโดยตั้งใจ เพื่อให้ retry ครั้งสุดท้ายยังชนกับ idempotency record ฝั่งคุณได้เสมอ · ถ้าคุณตั้ง TTL สั้นกว่า 7 วัน จะมีช่องเวลาที่ระบบสองฝั่งเห็นความจริงคนละแบบ


สี่เคสที่ต้องเขียนโค้ดรองรับ ​

สถานการณ์เราตอบอะไร
key เดิม + body เหมือนเดิม + request แรกจบแล้วคืน response เดิมทั้งดุ้น (รวม HTTP status เดิม) + header Idempotent-Replay: true
key เดิม + body ต่าง409 idempotency_key_reused + details.original_request_id
key เดิม + request แรก ยังไม่จบ409 idempotency_in_flight + retry_after_seconds: 1 — 🔴 เราไม่ block ไม่รอให้
key หมดอายุ (> 7 วัน) แล้วยิงซ้ำทำงานเหมือน request ใหม่ ⇒ สร้างออร์เดอร์ใหม่จริง ๆ

ข้อยกเว้นของ /slip: เทียบ logical evidence ไม่เทียบ raw body

ตารางนี้ใช้ raw body กับ merchant API ปกติ แต่ POST slip_upload.upload_url เทียบ versioned evidence_fingerprint จาก decoded image + canonical fields: multipart boundary, ลำดับ JSON key หรือรูปแบบ base64 ต่างกันแต่หลักฐานเดิมยังเป็น replay; key เดิมจะได้ 409 เฉพาะเมื่อ logical evidence ต่างจริง


เคสที่ 1 — key เดิม body เดิม request แรกจบแล้ว ​

นี่คือเคสที่คุณ อยาก ให้เกิดตอน retry

bash
# ยิงครั้งที่ 1 แล้ว network timeout ไปก่อนได้ response
curl -sS -X POST "$PG_API/v1/pay/deposits" \
  -H "Idempotency-Key: ORD-9981-topup" ... --data-binary @body.json

# ยิงซ้ำด้วย key เดิม body เดิม
curl -sSi -X POST "$PG_API/v1/pay/deposits" \
  -H "Idempotency-Key: ORD-9981-topup" ... --data-binary @body.json
http
HTTP/1.1 201 Created
Idempotent-Replay: true
Content-Type: application/json
json
{
  "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
  "object": "deposit_order",
  "version": 2,
  "status": "awaiting_payment",
  "is_terminal": false,
  "merchant_reference": "ORD-9981",
  "amount_minor": 500000,
  "currency": "THB",
  "created_at": "2026-08-21T04:10:47.902Z",
  "expires_at": "2026-08-21T04:30:47.902Z",
  "checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
  "slip_upload": {
    "token": "pgsut_eyJraWQiOiJvcmRlc...",
    "expires_at": "2026-08-22T04:30:47.902Z",
    "upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
    "max_bytes": 4194304,
    "content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
  }
}

ได้ deposit_id เดิม ไม่มีออร์เดอร์ใหม่เกิดขึ้น

response ที่ replay รวม capability snapshot เดิมด้วย ถือเป็น bearer secret: ห้าม log และต้องใช้ upload_url, max_bytes, content_types ที่ replay คืนมาโดยตรง ห้ามประกอบ upload host เอง

response ที่ replay คือภาพถ่ายของอดีต ไม่ใช่สถานะปัจจุบัน

ถ้า request แรกตอบ 202 awaiting_liquidity การ replay จะคืน 202 เดิมพร้อม legs: [] เดิม แม้ตอนนี้ออร์เดอร์จะเดินไปถึง awaiting_payment และมี QR แล้วก็ตาม

⇒ อ่านสถานะจริงจาก GET /v1/pay/deposits/{id} หรือรอ webhook เท่านั้น ห้ามอ่านจาก body ของ replay

เช็คได้จากโค้ด

js
const res = await fetch(url, { method: 'POST', headers, body: rawBody });
const isReplay = res.headers.get('Idempotent-Replay') === 'true';
const order = await res.json();

if (isReplay) {
  // อย่าเชื่อ order.status จากตรงนี้ — ไปอ่านของจริง
  return await getDeposit(order.id);
}
return order;

เคสที่ 2 — key เดิม body ต่าง ​

http
HTTP/1.1 409 Conflict
json
{
  "error": {
    "code": "idempotency_key_reused",
    "type": "invalid_request_error",
    "message": "This Idempotency-Key was already used with a different request body.",
    "message_th": "Idempotency-Key นี้ถูกใช้ไปแล้วกับ body คนละชุด",
    "retryable": false,
    "retry_after_seconds": null,
    "request_id": "req_01K3M8QW7Z4N2VYB6H0RSTA9CD",
    "details": { "original_request_id": "req_01K3M7YA5B2CDEFGH0JKLMN4PQ" }
  }
}

นี่ไม่ใช่บั๊กของเรา เป็นสัญญาณว่าโค้ดคุณมีบั๊ก — ปกติเกิดจากสองสาเหตุ

  1. ผู้ใช้กดแก้ยอดจาก 5,000 เป็น 3,000 แล้วกดใหม่ แต่โค้ดยัง reuse key เดิมที่ผูกกับ session
  2. โค้ดเติม field ที่เปลี่ยนค่าทุกครั้งลงใน body เช่น requested_at หรือ client.ip ที่เปลี่ยนตอน retry ผ่าน mobile network

client.ip เปลี่ยนได้ระหว่าง retry

ถ้าคุณอ่าน IP สดตอนยิงทุกครั้ง body จะไม่เหมือนเดิม ⇒ retry ได้ 409 idempotency_key_reused แทนที่จะได้ replay วิธีที่ถูก: snapshot client{} ทั้งก้อนตอนสร้าง key แล้วเก็บลง DB คู่กับ key · retry ส่ง body ตัวเดิมจาก DB

กฎ: เปลี่ยน body = ต้องเปลี่ยน key · จบ


เคสที่ 3 — key เดิม แต่ request แรกยังไม่จบ ​

เกิดจากผู้ใช้ดับเบิลคลิก หรือโค้ด retry ที่ยิงซ้ำทันทีเมื่อ client-side timeout ทั้งที่ฝั่งเรายังประมวลผลอยู่

http
HTTP/1.1 409 Conflict
json
{
  "error": {
    "code": "idempotency_in_flight",
    "type": "invalid_request_error",
    "message": "A request with this Idempotency-Key is still being processed.",
    "message_th": "request ที่ใช้ Idempotency-Key นี้ยังประมวลผลไม่เสร็จ",
    "retryable": true,
    "retry_after_seconds": 1,
    "request_id": "req_01K3M8QX8Z4N2VYB6H0RSTA9CE"
  }
}

เราไม่ block ให้ และนั่นคือสิ่งที่ถูก

gateway บางเจ้าจะ "รอ" ให้ request แรกจบแล้วค่อยตอบ ซึ่งทำให้ connection ค้างและ thread pool ฝั่งคุณตันเมื่อระบบตึง · เราตอบ 409 ทันทีพร้อมบอกว่าให้รอกี่วินาที

วิธี handle ที่ถูก

js
async function postWithIdempotency(url, rawBody, headersFor, key, { maxAttempts = 5 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    // 🔴 nonce ใหม่ทุก attempt · key เดิมตลอด
    const res = await fetch(url, { method: 'POST', headers: headersFor(rawBody, key), body: rawBody });

    if (res.status !== 409) return res;                     // รวม 201 / 202 / error อื่น

    const body = await res.json();
    if (body.error?.code === 'idempotency_in_flight') {
      const wait = (body.error.retry_after_seconds ?? 1) * 1000;
      await new Promise((r) => setTimeout(r, wait + Math.random() * 250));   // jitter
      continue;
    }
    // 409 อื่น เช่น idempotency_key_reused / duplicate_open_deposit — เป็นบั๊กของเรา ไม่ใช่เรื่องรอ
    return res;
  }
  // ครบรอบแล้วยังไม่จบ — ห้ามสร้าง key ใหม่ ให้ไปถามสถานะจริงแทน
  throw new Error('idempotency_in_flight_exhausted');
}

ห้ามแก้ปัญหานี้ด้วยการ gen key ใหม่

in_flight แปลว่า request แรก อาจสำเร็จอยู่ · gen key ใหม่ = ออร์เดอร์ซ้ำ · ถ้ารอครบรอบแล้วยังไม่ได้คำตอบ ให้ค้นด้วย merchant_reference ผ่าน GET /v1/pay/deposits?... แทน


เคสที่ 4 — key หมดอายุแล้วยิงซ้ำ ​

หลัง 7 วัน record ฝั่งเราหายไป ระบบจะมองว่าเป็น request ใหม่ทั้งหมด ⇒ สร้างออร์เดอร์ใหม่จริง ๆ ตอบ 201/202 ตามปกติ ไม่มี Idempotent-Replay

http
HTTP/1.1 201 Created
json
{
  "id": "dep_01K4RB2QX8ZM5TN0WHE7KJ4C7D",
  "object": "deposit_order",
  "version": 2,
  "status": "awaiting_payment",
  "merchant_reference": "ORD-9981",
  "amount_minor": 500000,
  "currency": "THB",
  "created_at": "2026-08-28T09:14:02.117Z",
  "expires_at": "2026-08-28T09:34:02.117Z",
  "slip_upload": {
    "token": "pgsut_eyJraWQiOiJuZXctb3JkZXI...",
    "expires_at": "2026-08-29T09:34:02.117Z",
    "upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K4RB2QX8ZM5TN0WHE7KJ4C7D/slip",
    "max_bytes": 4194304,
    "content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
  }
}

สังเกตว่า merchant_reference เดิม แต่ id เป็นคนละใบ

นี่คือเหตุผลที่ TTL ฝั่งคุณต้องไม่สั้นกว่า 7 วัน

job ที่ retry งานค้างแบบไม่จำกัดอายุ (เช่น dead-letter queue ที่ไล่ใหม่ทุกสัปดาห์) จะสร้างออร์เดอร์ซ้ำโดยไม่มีอะไรมากั้น

ต้องมีสองชั้น: ชั้นที่ 1 UNIQUE(merchant_reference) ในฐานข้อมูลของคุณ · ชั้นที่ 2 อย่า retry งานที่เก่ากว่า 7 วัน ให้ไปเข้ากระบวนการ reconcile แทน


วิธีเลือกค่า key ​

หลักการ: key เป็นฟังก์ชันของ เนื้อหา ไม่ใช่ของ session หรือ attempt

✅ ใช้ได้❌ ห้ามใช้เพราะ
dep-ORD-9981 (ผูกกับ merchant_reference)crypto.randomUUID() ตอนยิงสุ่มใหม่ทุก attempt = ออร์เดอร์ซ้ำ
dep-ORD-9981-r2 เมื่อผู้ใช้แก้ยอดแล้วสร้างใหม่session_id ของผู้ใช้ผู้ใช้คนเดียวสร้างหลายรายการใน session เดียว
sha256(merchant_reference + amount_minor + sender_id)end_user_ref เพียว ๆผู้ใช้เติมเงินหลายครั้ง
wdr-<withdrawal_ref>timestampเปลี่ยนทุกวินาที

สูตรที่แนะนำ

js
import crypto from 'node:crypto';

// key ผูกกับ "เจตนา" ของรายการ — ผู้ใช้แก้ยอดหรือเปลี่ยนบัญชีผู้โอน = เจตนาใหม่ = key ใหม่
function idempotencyKeyFor({ merchantReference, amountMinor, senderId, attemptOfIntent = 1 }) {
  const material = `${merchantReference}|${amountMinor}|${senderId}|${attemptOfIntent}`;
  return 'dep-' + crypto.createHash('sha256').update(material).digest('hex').slice(0, 32);
}

attemptOfIntent เพิ่มขึ้นเมื่อผู้ใช้ ตั้งใจ สร้างรายการใหม่ (กดยกเลิกแล้วสร้างใหม่ / เปลี่ยนบัญชีผู้โอน) ไม่ใช่เมื่อ network พัง

ลำดับที่ถูกต้องในโค้ด ​

sql
-- 1) เขียนก่อนยิง — ในทรานแซกชันของคุณเอง
INSERT INTO pg_requests (merchant_reference, idempotency_key, request_body, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (merchant_reference) DO NOTHING
RETURNING idempotency_key, request_body;
-- rowCount = 0 ⇒ มีคนสร้างไปแล้ว → SELECT ค่าเดิมมาใช้ ห้าม gen ใหม่

-- 2) ยิง request ด้วย idempotency_key และ request_body ที่อ่านมาจากแถวนี้

-- 3) ได้ 2xx แล้วค่อยอัปเดต
UPDATE pg_requests
   SET status = 'done', deposit_id = $2, http_status = $3, responded_at = now()
 WHERE merchant_reference = $1;
sql
CREATE TABLE pg_requests (
  merchant_reference text PRIMARY KEY,          -- ชั้นกันซ้ำชั้นที่ 1
  idempotency_key    text NOT NULL UNIQUE,      -- ชั้นกันซ้ำชั้นที่ 2
  request_body       jsonb NOT NULL,            -- 🔴 body ตัวเดิมที่ใช้ retry
  deposit_id         text,
  http_status        int,
  status             text NOT NULL,             -- pending | done | failed
  created_at         timestamptz NOT NULL DEFAULT now(),
  responded_at       timestamptz
);
-- 🔴 ห้ามลบแถวก่อน 7 วัน
CREATE INDEX ON pg_requests (created_at);
php
<?php
// PHP — โครงเดียวกัน
$pdo->beginTransaction();
$st = $pdo->prepare(
  'INSERT INTO pg_requests (merchant_reference, idempotency_key, request_body, status)
   VALUES (:ref, :key, :body, \'pending\')
   ON CONFLICT (merchant_reference) DO NOTHING'
);
$st->execute([':ref' => $ref, ':key' => $key, ':body' => $rawBody]);

$row = $pdo->prepare('SELECT idempotency_key, request_body FROM pg_requests WHERE merchant_reference = :ref');
$row->execute([':ref' => $ref]);
$saved = $row->fetch(PDO::FETCH_ASSOC);   // 🔴 ใช้ค่าจากแถวนี้เสมอ ไม่ใช่ตัวแปรในหน่วยความจำ
$pdo->commit();

$res = pg_post('/v1/pay/deposits', json_decode($saved['request_body'], true), $saved['idempotency_key']);

เรื่องที่คนมักเข้าใจผิด ​

429 ไม่ยึด Idempotency-Key

โดน 429 rate_limited แล้วยิงซ้ำด้วย key เดิมได้ทันทีหลังพ้น Retry-After · key ไม่ถูก "ใช้ไป" เพราะ request ไม่เคยถึง business logic

Idempotency-Key อยู่ใน canonical string ของลายเซ็น

เปลี่ยนค่า key แล้วลายเซ็นต้องเปลี่ยนตาม · ลืมใส่บรรทัดนี้ตอนเซ็น = 401 signature_invalid ไม่ใช่ 400 — ดู authentication

PG-Nonce กับ Idempotency-Key ทำหน้าที่ตรงข้ามกัน

เปลี่ยนเมื่อไหร่ถ้าทำผิด
PG-Nonceทุก attempt401 nonce_replayed
Idempotency-Keyเฉพาะเมื่อเจตนาเปลี่ยนออร์เดอร์ซ้ำ

อย่าเก็บ idempotency record ไว้ใน cache ที่ eventual consistent

ระบบ KV ที่กระจายทั่วโลกใช้เวลา sync ได้ถึง ~60 วินาที · request ที่สองอาจอ่านไม่เจอแถวที่ request แรกเพิ่งเขียน แล้วสร้างออร์เดอร์ใหม่ · เก็บในฐานข้อมูลที่อ่านเห็นทันทีเท่านั้น


เช็กลิสต์ ​

  • [ ] ทุก POST มี Idempotency-Key
  • [ ] key ถูกเขียนลง DB ก่อน ยิง request และมี UNIQUE
  • [ ] retry อ่าน key และ body ตัวเดิมจาก DB ไม่ gen ใหม่
  • [ ] TTL ฝั่งคุณ ≥ 7 วัน
  • [ ] handle 409 idempotency_in_flight ด้วยการรอตาม retry_after_seconds + jitter
  • [ ] handle 409 idempotency_key_reused เป็น alert ไม่ใช่ retry
  • [ ] ยิง POST /v1/pay/deposits ซ้ำด้วย key เดิม แล้วได้ deposit_id เดิม (ทดสอบจริงบน sandbox)
  • [ ] ทดสอบยอด 115.00 บน sandbox เพื่อดูเส้นทาง idempotency_key_reused

ขั้นต่อไป ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01