Every error code
Every endpoint uses the same error shape. No exceptions.
{
"error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": {
"available_minor": 1002000,
"required_minor": 1003000,
"pending_clearing_minor": 500000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}| Field | How to use it |
|---|---|
code | 🔴 Branch on this and nothing else. Never parse message, and never branch on the HTTP status alone. |
message_th | Thai text you may show to your own staff · 🔴 not written for end users — user-facing text lives in details.public_message_th or review.reasons[].display_th. |
retryable | true = retrying may succeed · false = retrying gives the same answer forever; fix the cause. |
retry_after_seconds | Present when we know the exact wait (429, 503, 409 idempotency_in_flight, 409 withdrawal_has_pending_fill). |
request_id | 🔴 Log it every time. When you open a ticket, this is the one value we search on. |
details | Shape varies by code. See the "what's in details" column in each section below. |
2xx is not an error — and we return 202 more often than other gateways
Code written like this breaks both the deposit flow and the slip-upload flow.
if (res.status !== 200) throw new Error('failed'); // ❌ brokenPOST /v1/pay/depositsanswers201when a destination was matched immediately, and202when no destination is available yet (the order is always created either way).POST /v1/pay/deposits/{id}/slipanswers202after capability auth/validation and confirmed durable storage + queue admission; unconfirmed intake returns a retryable503.
The correct check is if (!res.ok) { ... }, then branch on body.error.code.
Slip upload has no immediate 409 duplicate_slip — this differs from the rest of the market
Most providers check for duplicate slips synchronously and return 409 in the same call. We do not.
We answer 202 after object storage and queue confirmation succeed. Duplicate, amount, and name checks happen afterwards, so the verdict reaches you only through a webhook or GET /v1/pay/deposits/{id}. On 503 slip_intake_unavailable, retry the same logical evidence and key.
- A duplicate slip surfaces as
deposit.under_reviewwithreview.reasons[0].code = "DUPLICATE_SLIP". - Why it is built this way: by the time a slip is uploaded, the user has already moved real money. The system has to be able to accept the evidence even when the database is slow or down. Returning an error here makes users transfer a second time.
- What this costs you: the screen after a slip upload must say "checking", not "success", and you need a
deposit.under_reviewhandler from day one.
Details at Uploading a slip — successful durable intake is 202.
Quick index — every code, alphabetical
code | HTTP | Retryable | Section |
|---|---|---|---|
alloc_state_conflict | 409 | ✅ | State conflicts |
amount_not_supported | 422 | ❌ | Bad request shape |
api_key_invalid | 401 | ❌ | Authentication |
api_key_revoked | 401 | ❌ | Authentication |
beneficiary_not_verified | 409 | ✅ | Withdrawal side |
cursor_filter_mismatch | 400 | ❌ | Bad request shape |
deposit_not_cancelable | 409 | ❌ | Deposit side |
duplicate_open_deposit | 409 | ❌ | Deposit side |
idempotency_in_flight | 409 | ✅ | Idempotency |
idempotency_key_required | 400 | ❌ | Bad request shape |
idempotency_key_reused | 409 | ❌ | Idempotency |
insufficient_balance | 402 | ✅ after a wait | Not enough balance |
internal_error | 500 | ✅ | Capacity and outages |
late_slip_window_closed | 410 | ❌ | Window already closed |
malformed_json | 400 | ❌ | Bad request shape |
nonce_replayed | 401 | ❌ | Authentication |
rate_limited | 429 | ✅ | Capacity and outages |
resource_not_found | 404 | ❌ | Not found |
review_note_required | 422 | ❌ | Bad request shape |
route_unavailable | 503 | ✅ | Capacity and outages |
scope_denied | 403 | ❌ | Authentication |
sender_bank_unavailable | 409 | ✅ | Deposit side |
sender_required | 422 | ❌ | Bad request shape |
service_unavailable | 503 | ✅ | Capacity and outages |
signature_invalid | 401 | ❌ | Authentication |
tenant_suspended | 403 | ❌ | Authentication |
timestamp_out_of_window | 401 | ✅ after clock sync | Authentication |
too_many_open_deposits | 409 | ❌ | Deposit side |
unsupported_media_type | 400 | ❌ | Bad request shape |
validation_error | 422 | ❌ | Bad request shape |
withdrawal_has_pending_fill | 409 | ✅ | Withdrawal side |
withdrawal_not_cancelable | 409 | ❌ | Withdrawal side |
withdrawal_state_conflict | 409 | ✅ | State conflicts |
Authentication and permissions (401 · 403)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
signature_invalid | 401 | The canonical string was built wrong, or the signature does not match. | ❌ | Compare against all three test vectors before changing anything else. The most common miss is leaving Idempotency-Key and PG-Version out of the canonical string. |
timestamp_out_of_window | 401 | PG-Timestamp is more than ±300 seconds from real time. | ✅ | Sync the machine clock over NTP and send again · 🔴 do not "fix" this by shifting the timestamp yourself. |
nonce_replayed | 401 | The PG-Nonce was already used within the last 10 minutes. | ❌ | Generate a new nonce on every attempt · 🔴 but keep the same Idempotency-Key. The two headers do different jobs. |
api_key_invalid | 401 | The API key does not exist. | ❌ | Check you are using the key for the right environment (sk_live_* and sk_test_* are not interchangeable). |
api_key_revoked | 401 | The key has been revoked (checked on every merchant-HMAC request, across the whole account chain). | ❌ | Ask your brand provider to issue a new key. Orders already open do not move because of this. |
tenant_suspended | 403 | An account somewhere in your chain is suspended. | ❌ | Contact your brand provider. Stop creating new orders, but keep accepting webhooks for existing ones. |
scope_denied | 403 | This key lacks the scope for that endpoint. | ❌ | Request the scope · 🔴 /v1/bo/* is the back-office surface, not part of an integration. |
What details contains: signature_invalid has no details (deliberate — we do not say which part was wrong, so it cannot be used as a guessing oracle) · 🔴 Do not write code that depends on details for any 401 — compare against all three test vectors instead, and sync your clock with NTP.
Inbound and outbound signing are completely different mechanisms
Normal merchant requests you send us use HMAC-SHA256 in PG-Signature; the POST slip_upload.upload_url exception uses the order capability and accepts no PG-* headers. Webhooks we send you are signed with Ed25519 in the X-PG-Signature header.
The same code cannot do both · Request authentication · Verifying Ed25519 signatures
Bad request shape (400 · 422)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
idempotency_key_required | 400 | No Idempotency-Key header on a POST. | ❌ | Add the header · 🔴 generate the key and persist it before you send, not at send time. |
malformed_json | 400 | The body is not parseable JSON. | ❌ | Fix the code. |
unsupported_media_type | 400 | Content-Type is not one this endpoint accepts. | ❌ | JSON uses application/json; file-based slip upload uses multipart/form-data. |
cursor_filter_mismatch | 400 | Filters changed while paging through a cursor. | ❌ | You may change limit mid-cursor. You may not change from/to/type/status/order — start the listing again. |
validation_error | 422 | One or more fields failed validation. | ❌ | Read details.fields[], which names each field and the reason. |
sender_required | 422 | Neither sender{} nor sender_id was sent when creating the deposit. | ❌ | 🔴 You need a sender-account picker in your UI. See below. |
amount_not_supported | 422 | The amount is below or above policy limits, or has satang remainder. | ❌ | Use details.min_minor / details.max_minor · 🔴 amounts must be round — no satang remainder. |
review_note_required | 422 | The reviewer's note is shorter than 20 characters (back-office endpoint). | ❌ | Write a note of at least 20 characters. |
422 sender_required — different from the market, and easy to "fix" the wrong way
Our money lands directly in the withdrawer's personal bank account. There is no house account in the normal path, so the system has to know before the transfer which slip belongs to which order. Matching is done by who the sender is, not by a satang-level unique amount.
The most common wrong fix: stuffing whatever account-holder name is in your own database into the request just to get past the 422. The name then does not match the real slip, every order lands in under_review, and the review queue overflows on day one.
There are two correct fixes.
GET /v1/pay/senders?end_user_ref=u_88213, then let the user pick an already-verified account → sendsender_id. Guaranteed not to queue for a name or account mismatch.- New user with no account on file → have them fill in
sender{bank_code, account_number, account_name_th}· 🔴 no honorific prefix (นาย/นาง/น.ส./คุณ — Mr./Mrs./Ms./generic polite prefix) in the name.
Full detail and example screens at Sender account (required).
A complete 422 validation_error body:
{
"error": {
"code": "validation_error",
"type": "invalid_request_error",
"message": "One or more fields failed validation.",
"message_th": "ข้อมูลบางฟิลด์ไม่ผ่านการตรวจสอบ",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3M7Y8ZC4N0PQR2STU6VWX8Y",
"details": {
"fields": [
{ "path": "sender.account_name_th", "reason": "must_not_contain_title", "hint": "ตัดคำนำหน้า เช่น นาย นาง น.ส. ออกก่อนส่ง" },
{ "path": "amount_minor", "reason": "must_be_integer_minor_units" }
]
}
}
}The hint above is Thai and reads: "strip the honorific — นาย / นาง / น.ส. — before sending."
Not enough balance (402)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
insufficient_balance | 402 | Available balance is lower than amount plus fee. | ✅ after details.pending_clearing_available_at | Release the hold on the user's balance immediately, then explain using the time the funds become available. |
The two things that make 402 confusing
- Our withdrawal fee is added on top; it is not deducted from the withdrawal amount. You need
available_minor ≥ amount_minor + fee_total_minor. Check ahead of time withGET /v1/pay/quote?channel=withdrawal&amount_minor=1000000. - Money that just arrived from a deposit is not withdrawable yet. It sits in
pending_clearingfor 72 hours, then moves toavailable(you get abalance.clearedevent at that moment). Seeing the money on the dashboard while a withdrawal returns402is normal, not a bug.
🔴 On 402, no order is created and no funds are locked. If your code puts a hold on the user's balance before calling, this branch must always release it — otherwise the hold stays forever.
Not found (404)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
resource_not_found | 404 | The id does not exist, or it is outside your account scope. | ❌ | Check the id · 🔴 we deliberately do not distinguish the two cases (otherwise it becomes an id-enumeration oracle). |
Idempotency (409)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
idempotency_key_reused | 409 | Same key, different body from the first call. | ❌ | Changing the body means changing the key · details.original_request_id points at the first request. |
idempotency_in_flight | 409 | Same key, and the first request has not finished. | ✅ (retry_after_seconds: 1) | Wait as told, then send again with the same key · 🔴 we do not block and hold the connection, so a double-click gets an immediate answer instead of hanging. |
POST slip_upload.upload_url is the exception: idempotency_key_reused compares the logical evidence_fingerprint, not raw multipart/JSON bytes. Different boundaries, key order, or base64 formatting replay when decoded evidence is unchanged.
Same key + same body is not an error
Send the same key with an identical body and we return the entire original response, including the original HTTP status, plus the header Idempotent-Replay: true.
⇒ If the first call returned 202, the replay returns 202 even if the order has since moved to awaiting_payment. Real state comes from GET /v1/pay/deposits/{id} or from webhooks, never from a replayed body.
Our TTL is 7 days, so yours must not be shorter · 429 does not consume the key, so you can retry with the same key as soon as Retry-After elapses · Idempotency in detail
Deposit side (409)
code | HTTP | Meaning | Retry | What's in details | What to do next |
|---|---|---|---|---|---|
sender_bank_unavailable | 409 | The sender's bank is temporarily not accepting new orders. | ✅ | alternative_banks[] · public_message_th · retry_after_hint_seconds | Offer another bank from alternative_banks[] · 🔴 slips for orders already open can always still be uploaded — never disable the upload button. |
duplicate_open_deposit | 409 | An awaiting_payment order with the same amount and the same sender already exists. | ❌ | open_deposit_ids[] | Send the user back to the existing order (open_deposit_ids[0]). This is how AMBIGUOUS_MATCH is prevented at the source. |
too_many_open_deposits | 409 | Too many unpaid deposits are open at once. | ❌ | open_deposit_ids[] | Have the user finish or cancel an existing order first · 🔴 if you omit client{} the cap drops to 1. |
deposit_not_cancelable | 409 | Cannot cancel — a slip already exists, or the status no longer allows it. | ❌ | the current status | Let it run and wait for the webhook verdict. This is what stops you from seizing money a user just transferred. |
client{} is not really optional
POST /v1/pay/deposits has a client{device_fingerprint, ip, user_agent} field marked optional. Omit it and:
- the cap on simultaneously open orders drops from 2 to 1 per user;
- rate limiting falls back to a coarse bucket shared across all of your users, so a mid-sized merchant hits
429within the first minute.
⇒ In practice, always send it.
Withdrawal side (409)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
withdrawal_has_pending_fill | 409 | Cannot cancel right now — a fill is in progress (reserved_minor > 0). | ✅ (retry_after_seconds) | Wait and try again · 🔴 this exists to protect someone who is mid-transfer. It is not a bug. |
withdrawal_not_cancelable | 409 | This withdrawal is already settled / expired / canceled. | ❌ | Read the current state from GET /v1/pay/withdrawals/{id}. |
beneficiary_not_verified | 409 | The beneficiary account has not passed bank name verification yet. | ✅ once verified | Wait for the beneficiary.verified webhook (usually under 30 seconds), then create the withdrawal · 🔴 no order is created and no funds are locked. |
State conflicts mid-request (409)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
alloc_state_conflict | 409 | The underlying fill changed state while your request was running. | ✅ | Re-read the current state with GET and decide again. |
withdrawal_state_conflict | 409 | The withdrawal changed state while your request was running. | ✅ | Same as above. |
Why these are 409 and not 500
Because nothing failed — this is normal contention in a system where many people transfer at the same time. We map it to 409 with retryable: true on purpose, so your code re-reads and retries instead of paging someone at 3am.
Window already closed (410)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
late_slip_window_closed | 410 | The slip was uploaded after the 24-hour late-slip window closed. | ❌ | Open a case through details.claim_url → POST /v1/bo/claims · 🔴 do not end the story at an error message — the user's money really did leave their account. |
{
"error": {
"code": "late_slip_window_closed",
"type": "invalid_request_error",
"message": "The late-slip window for this deposit has closed.",
"message_th": "หมดหน้าต่างอัปโหลดสลิปย้อนหลังของรายการนี้แล้ว",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01K3PB6QW2ZR9YT5N4KDXCVH8M",
"details": {
"deposit_id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"late_slip_accepted_until": "2026-08-22T04:30:47.902Z",
"claim_url": "https://pay.example-wl.com/claims/new?deposit_id=dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B"
}
}
}Capacity and outages (429 · 503 · 500)
code | HTTP | Meaning | Retry | What to do next |
|---|---|---|---|---|
rate_limited | 429 | You exceeded your own quota bucket. | ✅ | Honour Retry-After plus ±20% jitter · 🔴 no tight retry loops — the penalty compounds · check details.bucket and details.scope to see which bucket you hit. |
route_unavailable | 503 | No route is currently available to open a new deposit. | ✅ | Show "ระบบหนาแน่น ลองใหม่อีกครั้ง" (System busy, please try again) and use Retry-After · 🔴 not a bug, and not a 500. |
service_unavailable | 503 | The system is shedding load. | ✅ | Same as above. |
internal_error | 500 | Something on our side genuinely failed. | ✅ | Send the request_id to support. |
429 and 503 have different causes and different fixes
429 = you went over quota — fix it on your side (poll less, lean on webhooks, send client{} so you do not fall into the shared bucket). 503 = the system is shedding load — nothing you can fix; wait and retry.
Retrying a 429 in a tight loop puts your whole API key into backoff, which means none of your users can create orders. This is the most common way a merchant takes itself down.
Load-shedding order under pressure — most protected first, first sacrificed last.
| Priority | Work | Shed? |
|---|---|---|
| P0 | Slip upload | 🔴 Never load-shed — but unconfirmed storage/queue intake returns retryable 503, never a false 202. |
| P1 | Crediting and ledger posting | No |
| P2 | Status reads (GET) | Almost never |
| P3 | Creating new deposits | ✅ First to go — the user has not transferred yet, so nobody loses money. |
Every response (not just 429) carries these headers so you can see it coming:
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
Retry-After: 41Error-handling code that actually works
curl — see the full error body
curl -sS -i -X POST 'https://api.example-wl.com/v1/pay/withdrawals' \
-H 'Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'PG-Timestamp: 1755749553' \
-H 'PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: wdr-ORD-9981-attempt-1' \
-H 'PG-Signature: v1=6f1b9c0d…' \
-d '{
"amount_minor": 1000000,
"currency": "THB",
"merchant_reference": "WDR-4471",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2G5R8XT4M0QWD7HZP3NC6VB",
"client": { "device_fingerprint": "df_9a1c7e", "ip": "203.0.113.44", "user_agent": "Mozilla/5.0" }
}'Node (fetch) — branch on code, honour retry_after_seconds
const RETRY_BUDGET = 4;
async function callApi(path, init, attempt = 1) {
const res = await fetch(`https://api.example-wl.com${path}`, init);
const text = await res.text();
const body = text ? JSON.parse(text) : {};
if (res.ok) return { status: res.status, body }; // 200 · 201 · 202 all count as success
const err = body.error ?? { code: 'internal_error', retryable: true };
// wait time: retry_after_seconds if present, else Retry-After, else exponential + jitter
const headerWait = Number(res.headers.get('retry-after'));
const waitMs = (err.retry_after_seconds ?? (Number.isFinite(headerWait) ? headerWait : 2 ** attempt))
* 1000 * (0.8 + Math.random() * 0.4); // ±20% jitter
switch (err.code) {
// safe to resend immediately with the SAME Idempotency-Key (nonce must be new)
case 'idempotency_in_flight':
case 'alloc_state_conflict':
case 'withdrawal_state_conflict':
case 'withdrawal_has_pending_fill':
case 'rate_limited':
case 'route_unavailable':
case 'service_unavailable':
case 'internal_error':
if (attempt > RETRY_BUDGET) throw new PermanentError(err);
await new Promise((r) => setTimeout(r, waitMs));
return callApi(path, withFreshNonce(init), attempt + 1); // 🔴 new nonce · same key
// release the user's hold first, then explain when it will be possible
case 'insufficient_balance':
await releaseUserHold(init.body);
throw new BalanceError(err.details.pending_clearing_available_at, err);
// must be fixed at the source; retrying never helps
case 'sender_required':
case 'validation_error':
case 'amount_not_supported':
case 'duplicate_open_deposit':
case 'too_many_open_deposits':
case 'idempotency_key_reused':
case 'late_slip_window_closed':
throw new PermanentError(err);
// 🔴 new codes can appear without counting as a breaking change — always keep a default
default:
if (err.retryable && attempt <= RETRY_BUDGET) {
await new Promise((r) => setTimeout(r, waitMs));
return callApi(path, withFreshNonce(init), attempt + 1);
}
throw new PermanentError(err);
}
}PHP — same shape
<?php
function callApi(string $path, array $opts, int $attempt = 1): array
{
$ch = curl_init("https://api.example-wl.com{$path}");
curl_setopt_array($ch, $opts + [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = $raw === '' ? [] : json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 200 && $status < 300) {
return ['status' => $status, 'body' => $body]; // 201 and 202 are successes
}
$err = $body['error'] ?? ['code' => 'internal_error', 'retryable' => true];
$retryable = [
'idempotency_in_flight', 'alloc_state_conflict', 'withdrawal_state_conflict',
'withdrawal_has_pending_fill', 'rate_limited', 'route_unavailable',
'service_unavailable', 'internal_error',
];
// 🔴 also honour the server's own retryable flag, for codes not yet in the list
$canRetry = in_array($err['code'], $retryable, true) || ($err['retryable'] ?? false);
if ($canRetry && $attempt <= 4) {
$wait = ($err['retry_after_seconds'] ?? 2 ** $attempt) * (0.8 + mt_rand(0, 400) / 1000);
usleep((int) ($wait * 1_000_000));
return callApi($path, withFreshNonce($opts), $attempt + 1); // same key, new nonce
}
error_log(sprintf('[pg] %s request_id=%s', $err['code'], $err['request_id'] ?? '-'));
throw new RuntimeException($err['message_th'] ?? $err['code']);
}What must change on every retry
Generate a new PG-Nonce on every attempt (otherwise you get 401 nonce_replayed), but keep Idempotency-Key identical for the whole logical operation (otherwise you get duplicate orders and duplicate fund locks).
PG-Timestamp also has to be new — and once the nonce and timestamp change, PG-Signature has to be recomputed.
The other direction — what your endpoint returns to us
When we deliver a webhook, this is how we read the HTTP status you send back.
| You answer | We read it as | Consequence |
|---|---|---|
200 201 202 204 | Success | Retries stop. |
3xx | Failure | 🔴 We never follow redirects (SSRF protection, and to keep signatures from being replayed elsewhere). |
410 Gone | Permanent failure | 🔴 Retries stop immediately and the endpoint is flagged. Do not answer 410 if you only want us to pause. |
429 and any other 4xx/5xx | Failure | Retried on the fixed schedule: 11 attempts over ~92 hours 36 minutes. We do not read your Retry-After — the schedule is fixed so it stays predictable. |
| No answer within 15 seconds | Failure | Retried · answer within 3 seconds and do the heavy work asynchronously. |
A duplicate event must get 200, not 409
If you see an event.id you have already processed, answer 200 and do nothing. A non-2xx answer makes us retry, which means you flood yourself.
Details at Retries and endpoint outages.
Next
| If you are… | Go to |
|---|---|
Fixing 401 signature_invalid | Request authentication |
Hitting 409 idempotency_* | Idempotency |
Hitting 422 sender_required | Sender account (required) |
Hitting 402 insufficient_balance | Wallet and balances |
Hitting 410 late_slip_window_closed | Claims |
Wondering what a field inside details means | Field dictionary |
| Wanting to reproduce each error in sandbox | Sandbox |
