Skip to content

Idempotency ​

Idempotency-Key is a required header on every POST, not an option. Omit it and you get 400 idempotency_key_required before any business logic runs.

The one rule to remember

Write the key into your own database first, then send the request. Every retry must read that same key back from the database — never generate a new one.

Merchants who generate a fresh key on retry get duplicate orders, two QR codes, and — on the withdrawal side — money locked twice.

Basics ​

topicvalue
endpoints that require itevery POST
key lifetime (our TTL)7 days
your TTL🔴 must not be shorter than 7 days
where to store ita database that is immediately consistent (we use Postgres) 🔴 not an eventually consistent KV store
length / formatany string you choose, unique per logical operation

Why 7 days — the webhook retry window runs ~92 hours 36 minutes (just under 4 days). We deliberately set the TTL longer, so the final retry still collides with your idempotency record. If your TTL is shorter than 7 days, there is a window where the two systems disagree about what happened.


Four cases your code has to handle ​

situationwhat we answer
same key + identical body + first request finishedthe original response, verbatim (including the original HTTP status) plus the header Idempotent-Replay: true
same key + different body409 idempotency_key_reused + details.original_request_id
same key + first request still running409 idempotency_in_flight + retry_after_seconds: 1 — 🔴 we do not block and we do not wait for you
expired key (> 7 days) sent againtreated as a brand-new request ⇒ a real second order is created

/slip exception: logical evidence, not raw body

The table uses raw bodies for normal merchant APIs. POST slip_upload.upload_url instead compares a versioned evidence_fingerprint over decoded evidence + canonical fields: different multipart boundaries, JSON key order, or base64 formatting still replay when the evidence is the same; the same key returns 409 only for genuinely different logical evidence.


Case 1 — same key, same body, first request finished ​

This is the case you want on a retry.

bash
# first attempt: network timeout before the response arrives
curl -sS -X POST "$PG_API/v1/pay/deposits" \
  -H "Idempotency-Key: ORD-9981-topup" ... --data-binary @body.json

# sent again with the same key and the same 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"]
  }
}

Same deposit_id, no new order.

The replay includes the original capability snapshot too. Treat it as a bearer secret: never log it, and consume the returned upload_url, max_bytes, and content_types directly instead of constructing an upload host.

A replayed response is a photograph of the past, not the current state

If the first request answered 202 awaiting_liquidity, the replay returns that same 202 with the same legs: [] — even if the order has since reached awaiting_payment and has a QR.

⇒ Read the real state from GET /v1/pay/deposits/{id} or wait for the webhook. Never read state out of a replayed body.

How to detect it in code

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) {
  // do not trust order.status here — go and read the real thing
  return await getDeposit(order.id);
}
return order;

Case 2 — same key, different 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" }
  }
}

This is not a bug on our side — it is a signal that your code has one. It usually comes from one of two causes.

  1. The user changed the amount from 5,000 to 3,000 and submitted again, but your code still reuses a key tied to the session.
  2. Your body contains a field that changes on every send, such as requested_at, or a client.ip that changed when the retry went out over a mobile network.

client.ip can change between retries

If you read the live IP on every send, the body is no longer identical, so the retry gets 409 idempotency_key_reused instead of a replay. The fix: snapshot the whole client{} object when you create the key and store it in your DB alongside the key. Retries send the stored body.

Rule: a changed body requires a changed key. That is the whole rule.


Case 3 — same key while the first request is still running ​

Caused by a double click, or by retry code that fires again the moment the client times out while we are still processing.

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

We do not block for you, and that is the right behaviour

Some gateways "wait" for the first request to finish before answering, which holds the connection open and exhausts your thread pool exactly when the system is under strain. We answer 409 immediately and tell you how long to wait.

How to handle it

js
async function postWithIdempotency(url, rawBody, headersFor, key, { maxAttempts = 5 } = {}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    // 🔴 new nonce per attempt, same key throughout
    const res = await fetch(url, { method: 'POST', headers: headersFor(rawBody, key), body: rawBody });

    if (res.status !== 409) return res;                     // includes 201 / 202 / other errors

    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;
    }
    // any other 409 — idempotency_key_reused / duplicate_open_deposit — is a bug, not something to wait out
    return res;
  }
  // attempts exhausted — do not mint a new key, go and ask for the real status instead
  throw new Error('idempotency_in_flight_exhausted');
}

Never solve this by generating a new key

in_flight means the first request may be succeeding right now. A new key means a duplicate order. If you run out of attempts, search by merchant_reference through GET /v1/pay/deposits?... instead.


Case 4 — reusing an expired key ​

After 7 days our record is gone and the system treats the call as entirely new ⇒ a real second order is created, answered with 201/202 as normal and without 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"]
  }
}

Note the same merchant_reference but a different id — a different order.

This is why your TTL must not be shorter than 7 days

A job that retries stuck work with no age limit — a dead-letter queue that is drained weekly, say — will create duplicate orders with nothing to stop it.

You need two layers: layer 1 is UNIQUE(merchant_reference) in your database; layer 2 is never retrying work older than 7 days, sending it to reconciliation instead.


Choosing the key value ​

The principle: the key is a function of the content, not of the session or the attempt.

✅ works❌ neverwhy
dep-ORD-9981 (tied to merchant_reference)crypto.randomUUID() at send timea fresh random per attempt = duplicate orders
dep-ORD-9981-r2 when the user changes the amount and starts overthe user's session_idone user creates several orders in one session
sha256(merchant_reference + amount_minor + sender_id)end_user_ref on its ownthe same user tops up many times
wdr-<withdrawal_ref>a timestampit changes every second

Recommended recipe

js
import crypto from 'node:crypto';

// The key is tied to the "intent" of the order — a changed amount or a changed
// sender account is a new intent, and therefore a new 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 increases when the user deliberately starts a new order (cancelled and created again, or changed the sender account) — never when the network fails.

The correct order of operations ​

sql
-- 1) write before sending — inside your own transaction
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 ⇒ someone already created it → SELECT the existing values, never mint a new key

-- 2) send the request using the idempotency_key and request_body read from that row

-- 3) update only after a 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,          -- duplicate guard, layer 1
  idempotency_key    text NOT NULL UNIQUE,      -- duplicate guard, layer 2
  request_body       jsonb NOT NULL,            -- 🔴 the exact body to reuse on retry
  deposit_id         text,
  http_status        int,
  status             text NOT NULL,             -- pending | done | failed
  created_at         timestamptz NOT NULL DEFAULT now(),
  responded_at       timestamptz
);
-- 🔴 never delete a row before it is 7 days old
CREATE INDEX ON pg_requests (created_at);
php
<?php
// PHP — same structure
$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);   // 🔴 always use the values from this row, never in-memory variables
$pdo->commit();

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

Things people get wrong ​

429 does not consume your Idempotency-Key

After a 429 rate_limited you can send again with the same key as soon as Retry-After elapses. The key was never "used", because the request never reached business logic.

Idempotency-Key is part of the signature's canonical string

Change the key and the signature must change with it. Forgetting that line while signing gives you 401 signature_invalid, not 400 — see authentication.

PG-Nonce and Idempotency-Key do opposite jobs

when it changeswhat goes wrong if you get it backwards
PG-Nonceevery attempt401 nonce_replayed
Idempotency-Keyonly when the intent changesduplicate orders

Never keep idempotency records in an eventually consistent cache

A globally distributed KV store can take ~60 seconds to converge. The second request may not see the row the first one just wrote, and creates another order. Store them in a database that reads back immediately.


Checklist ​

  • [ ] every POST carries an Idempotency-Key
  • [ ] the key is written to your DB before sending, with a UNIQUE constraint
  • [ ] retries read the same key and the same body back from the DB, never generating new ones
  • [ ] your TTL is ≥ 7 days
  • [ ] 409 idempotency_in_flight is handled by waiting retry_after_seconds + jitter
  • [ ] 409 idempotency_key_reused raises an alert, it is not retried
  • [ ] sending POST /v1/pay/deposits twice with the same key returns the same deposit_id (tested for real on sandbox)
  • [ ] amount 115.00 tested on sandbox to see the idempotency_key_reused path

Next ​

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