Skip to content

Every error code ​

Every endpoint uses the same error shape. No exceptions.

json
{
  "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"
    }
  }
}
FieldHow to use it
code🔴 Branch on this and nothing else. Never parse message, and never branch on the HTTP status alone.
message_thThai 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.
retryabletrue = retrying may succeed · false = retrying gives the same answer forever; fix the cause.
retry_after_secondsPresent 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.
detailsShape 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.

js
if (res.status !== 200) throw new Error('failed');   // ❌ broken
  • POST /v1/pay/deposits answers 201 when a destination was matched immediately, and 202 when no destination is available yet (the order is always created either way).
  • POST /v1/pay/deposits/{id}/slip answers 202 after capability auth/validation and confirmed durable storage + queue admission; unconfirmed intake returns a retryable 503.

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_review with review.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_review handler from day one.

Details at Uploading a slip — successful durable intake is 202.


Quick index — every code, alphabetical ​

codeHTTPRetryableSection
alloc_state_conflict409✅State conflicts
amount_not_supported422❌Bad request shape
api_key_invalid401❌Authentication
api_key_revoked401❌Authentication
beneficiary_not_verified409✅Withdrawal side
cursor_filter_mismatch400❌Bad request shape
deposit_not_cancelable409❌Deposit side
duplicate_open_deposit409❌Deposit side
idempotency_in_flight409✅Idempotency
idempotency_key_required400❌Bad request shape
idempotency_key_reused409❌Idempotency
insufficient_balance402✅ after a waitNot enough balance
internal_error500✅Capacity and outages
late_slip_window_closed410❌Window already closed
malformed_json400❌Bad request shape
nonce_replayed401❌Authentication
rate_limited429✅Capacity and outages
resource_not_found404❌Not found
review_note_required422❌Bad request shape
route_unavailable503✅Capacity and outages
scope_denied403❌Authentication
sender_bank_unavailable409✅Deposit side
sender_required422❌Bad request shape
service_unavailable503✅Capacity and outages
signature_invalid401❌Authentication
tenant_suspended403❌Authentication
timestamp_out_of_window401✅ after clock syncAuthentication
too_many_open_deposits409❌Deposit side
unsupported_media_type400❌Bad request shape
validation_error422❌Bad request shape
withdrawal_has_pending_fill409✅Withdrawal side
withdrawal_not_cancelable409❌Withdrawal side
withdrawal_state_conflict409✅State conflicts

Authentication and permissions (401 · 403) ​

codeHTTPMeaningRetryWhat to do next
signature_invalid401The 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_window401PG-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_replayed401The 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_invalid401The 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_revoked401The 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_suspended403An account somewhere in your chain is suspended.❌Contact your brand provider. Stop creating new orders, but keep accepting webhooks for existing ones.
scope_denied403This 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) ​

codeHTTPMeaningRetryWhat to do next
idempotency_key_required400No Idempotency-Key header on a POST.❌Add the header · 🔴 generate the key and persist it before you send, not at send time.
malformed_json400The body is not parseable JSON.❌Fix the code.
unsupported_media_type400Content-Type is not one this endpoint accepts.❌JSON uses application/json; file-based slip upload uses multipart/form-data.
cursor_filter_mismatch400Filters 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_error422One or more fields failed validation.❌Read details.fields[], which names each field and the reason.
sender_required422Neither sender{} nor sender_id was sent when creating the deposit.❌🔴 You need a sender-account picker in your UI. See below.
amount_not_supported422The 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_required422The 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.

  1. GET /v1/pay/senders?end_user_ref=u_88213, then let the user pick an already-verified account → send sender_id. Guaranteed not to queue for a name or account mismatch.
  2. 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:

json
{
  "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) ​

codeHTTPMeaningRetryWhat to do next
insufficient_balance402Available balance is lower than amount plus fee.✅ after details.pending_clearing_available_atRelease the hold on the user's balance immediately, then explain using the time the funds become available.

The two things that make 402 confusing

  1. 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 with GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000.
  2. Money that just arrived from a deposit is not withdrawable yet. It sits in pending_clearing for 72 hours, then moves to available (you get a balance.cleared event at that moment). Seeing the money on the dashboard while a withdrawal returns 402 is 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) ​

codeHTTPMeaningRetryWhat to do next
resource_not_found404The 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) ​

codeHTTPMeaningRetryWhat to do next
idempotency_key_reused409Same 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_flight409Same 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) ​

codeHTTPMeaningRetryWhat's in detailsWhat to do next
sender_bank_unavailable409The sender's bank is temporarily not accepting new orders.✅alternative_banks[] · public_message_th · retry_after_hint_secondsOffer another bank from alternative_banks[] · 🔴 slips for orders already open can always still be uploaded — never disable the upload button.
duplicate_open_deposit409An 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_deposits409Too 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_cancelable409Cannot cancel — a slip already exists, or the status no longer allows it.❌the current statusLet 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 429 within the first minute.

⇒ In practice, always send it.


Withdrawal side (409) ​

codeHTTPMeaningRetryWhat to do next
withdrawal_has_pending_fill409Cannot 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_cancelable409This withdrawal is already settled / expired / canceled.❌Read the current state from GET /v1/pay/withdrawals/{id}.
beneficiary_not_verified409The beneficiary account has not passed bank name verification yet.✅ once verifiedWait 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) ​

codeHTTPMeaningRetryWhat to do next
alloc_state_conflict409The underlying fill changed state while your request was running.✅Re-read the current state with GET and decide again.
withdrawal_state_conflict409The 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) ​

codeHTTPMeaningRetryWhat to do next
late_slip_window_closed410The 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.
json
{
  "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) ​

codeHTTPMeaningRetryWhat to do next
rate_limited429You 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_unavailable503No 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_unavailable503The system is shedding load.✅Same as above.
internal_error500Something 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.

PriorityWorkShed?
P0Slip upload🔴 Never load-shed — but unconfirmed storage/queue intake returns retryable 503, never a false 202.
P1Crediting and ledger postingNo
P2Status reads (GET)Almost never
P3Creating 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: 41

Error-handling code that actually works ​

curl — see the full error body ​

bash
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 ​

js
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
<?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 answerWe read it asConsequence
200 201 202 204SuccessRetries stop.
3xxFailure🔴 We never follow redirects (SSRF protection, and to keep signatures from being replayed elsewhere).
410 GonePermanent 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/5xxFailureRetried 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 secondsFailureRetried · 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_invalidRequest authentication
Hitting 409 idempotency_*Idempotency
Hitting 422 sender_requiredSender account (required)
Hitting 402 insufficient_balanceWallet and balances
Hitting 410 late_slip_window_closedClaims
Wondering what a field inside details meansField dictionary
Wanting to reproduce each error in sandboxSandbox

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