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
| topic | value |
|---|---|
| endpoints that require it | every POST |
| key lifetime (our TTL) | 7 days |
| your TTL | 🔴 must not be shorter than 7 days |
| where to store it | a database that is immediately consistent (we use Postgres) 🔴 not an eventually consistent KV store |
| length / format | any 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
| situation | what we answer |
|---|---|
| same key + identical body + first request finished | the original response, verbatim (including the original HTTP status) plus the header Idempotent-Replay: true |
| same key + different body | 409 idempotency_key_reused + details.original_request_id |
| same key + first request still running | 409 idempotency_in_flight + retry_after_seconds: 1 — 🔴 we do not block and we do not wait for you |
| expired key (> 7 days) sent again | treated 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.
# 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.jsonHTTP/1.1 201 Created
Idempotent-Replay: true
Content-Type: application/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
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/1.1 409 Conflict{
"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.
- 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.
- Your body contains a field that changes on every send, such as
requested_at, or aclient.ipthat 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/1.1 409 Conflict{
"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
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/1.1 201 Created{
"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 | ❌ never | why |
|---|---|---|
dep-ORD-9981 (tied to merchant_reference) | crypto.randomUUID() at send time | a fresh random per attempt = duplicate orders |
dep-ORD-9981-r2 when the user changes the amount and starts over | the user's session_id | one user creates several orders in one session |
sha256(merchant_reference + amount_minor + sender_id) | end_user_ref on its own | the same user tops up many times |
wdr-<withdrawal_ref> | a timestamp | it changes every second |
Recommended recipe
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
-- 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;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 — 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 changes | what goes wrong if you get it backwards | |
|---|---|---|
PG-Nonce | every attempt | 401 nonce_replayed |
Idempotency-Key | only when the intent changes | duplicate 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
POSTcarries anIdempotency-Key - [ ] the key is written to your DB before sending, with a
UNIQUEconstraint - [ ] 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_flightis handled by waitingretry_after_seconds+ jitter - [ ]
409 idempotency_key_reusedraises an alert, it is not retried - [ ] sending
POST /v1/pay/depositstwice with the same key returns the samedeposit_id(tested for real on sandbox) - [ ] amount 115.00 tested on sandbox to see the
idempotency_key_reusedpath
Next
- Test the
409cases for real on sandbox → Sandbox - How
Idempotency-Keyenters the signature → Request authentication - Send your first
POST→ Quickstart - Every error code → Error reference
