Skip to content

Deposit statuses ​

Three rules first ​

  1. Only status = "settled" means you may credit. Every other value means release nothing. No exceptions.
  2. expired and canceled are not always the end. Look at is_terminal, not at the status name.
  3. Webhooks are primary, GET is the fallback — not the other way around.

All nine statuses ​

statusMeaningFinal💰 Where the money isWhat you do
awaiting_liquidityOrder created, but no destination to transfer to yet. No QR, no account number.NoNot transferredShow a "finding a transfer route" screen and offer liquidity.suggested_amounts_minor[] · 🔴 no QR, do not tell the user to transfer
awaiting_paymentMatched. legs[] and expires_at are set.NoNot transferred (still with the user)Show the QR/account number, a countdown from expires_at, and the "transfer from this account only" box
slip_processingSlip received, verification runningNoTransferred, awaiting verification"Checking your slip" · disable the upload button · 🔴 release nothing
under_reviewA non-amount dimension failed / policy hold / bank auto-verification off / duplicate slip / ambiguity; an amount mismatch alone never enters this stateNoTransferred, awaiting a humanShow review.reasons[0].display_th verbatim · yellow/blue, not red · 🔴 release nothing, no "transfer again" button
settledCredited; clean short-pay carries partial:true, settled_via:'auto'; may carry an overpayment{} flagAlmost (the only exit is reversed)Only amount_received_minor enters the order; excess is in a claimCredit with amount_received_minor; show the actual accepted short-pay; if flagged, show the excess/claim and contact-support action
expiredexpires_at passed with no slip · 🔴 not a rejection of moneyNot until the late-slip window closesUsually not transferred · if a late slip arrives, transferredKeep an "I already transferred but it expired" button until late_slip_accepted_until
canceledClosed before payment — by you, by the user, or by the system when no destination could be foundDepends on late_slip_accepted_untilUsually not transferredIf late_slip_accepted_until is set, keep the upload button exactly as for expired
rejectedA reviewer decided this slip is not evidence for this order✅Not creditedShow rejection.display_th and make deposit_id copyable
reversedWas settled, then reversed✅Debited back🔴 You must debit the user's balance back and always notify them · handlers for deposit.reversed + balance.debited are not optional

rejected does not mean refunded

rejected means "this slip is not evidence for this order". If real money did arrive but belongs to a different order, that is handled on the claim path, not by this status. Never write code that auto-refunds when it sees rejected.


is_terminal, not the status name ​

js
// ❌ wrong — closes the order while the user may have paid and is about to upload a slip
if (['expired', 'canceled', 'rejected', 'reversed'].includes(d.status)) closeOrder()

// ✅ correct
if (d.is_terminal) closeOrder()

expired and canceled orders that carry late_slip_accepted_until have is_terminal: false. When the window really closes we fire deposit.expired / deposit.canceled a second time with is_terminal: true and a new version — that is the point at which you can close the order.


Reading a single order ​

bash
curl https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Timestamp: 1755749900" \
  -H "PG-Nonce: 01JQA5TB4N6Q8S0U2W4Y6A8C0E" \
  -H "PG-Version: 2026-08-01" \
  -H "PG-Signature: v1=8e0b...cd72"

An example response for an order that has been credited:

json
{
  "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
  "object": "deposit_order",
  "version": 7,
  "status": "settled",
  "is_terminal": false,
  "merchant_reference": "ORD-9981",
  "amount_minor": 500000,
  "ordered_amount_minor": 500000,
  "currency": "THB",
  "created_at": "2026-08-21T04:10:47.902Z",
  "settled_at": "2026-08-21T04:19:33.204Z",
  "settled_via": "auto",
  "partial": false,
  "liquidity_source": "internal",
  "transferred_amount_minor": 500000,
  "amount_received_minor": 500000,
  "fee_total_minor": 7500,
  "fee_breakdown": [
    { "component": "processing_fee", "amount_minor": 5000, "rate_bps": 100 },
    { "component": "fixed_fee", "amount_minor": 2000 },
    { "component": "vat", "amount_minor": 500 }
  ],
  "credited_amount_minor": 492500,
  "overpayment": null,
  "balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
  "clearing_available_at": "2026-08-24T04:19:33.204Z",
  "sender": {
    "bank_code": "004",
    "account_number_masked": "xxx-x-x7890",
    "account_name_th_masked": "ส*** ใ***"
  },
  "metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}

The transferred and accepted amounts mean different things.

fieldUse it for
transferred_amount_minorBank-verified settlement evidence; it may exceed A on same-slip overpayment · a later transfer is not added cumulatively here
amount_received_minorCrediting the end user — what the gateway accepted into this order
fee_total_minorThe fee charged to you
credited_amount_minorWhat lands in your balance = amount_received_minor − fee_total_minor

Crediting the user with credited_amount_minor shorts every user by the fee. Applying amount_received_minor to your balance overstates your books by the total of all fees.

Store every amount separately and assert amount_received_minor = min(transferred_amount_minor, ordered_amount_minor), 0 ≤ fee_total_minor ≤ amount_received_minor, and amount_received_minor − fee_total_minor = credited_amount_minor.

When a same-slip transferred_amount_minor > amount_received_minor, the order is still settled and carries overpayment{cause:'same_transfer_excess',claim_id,amount_minor,action_required:'contact_support'}. Credit only amount_received_minor and ask the user to contact support with the claim id. A transfer made after settlement uses cause:'additional_transfer'; transferred_amount_minor remains the original evidence amount and is not cumulative.

credited_amount_minor is not withdrawable straight away

Newly received funds sit in pending_clearing first and move to available at clearing_available_at (T+72h). Creating a withdrawal against uncleared funds returns 402 insufficient_balance even though the dashboard shows the money.


Reading many orders at once ​

bash
curl -G https://api.example-wl.com/v1/pay/deposits \
  --data-urlencode "status=settled" \
  --data-urlencode "updated_after=2026-08-21T00:00:00.000Z" \
  --data-urlencode "limit=100" \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Version: 2026-08-01" \
  -H "PG-Signature: v1=1d47...9f30"
json
{
  "data": [
    {
      "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B", "object": "deposit_order", "version": 7,
      "status": "settled", "is_terminal": false, "merchant_reference": "ORD-9981",
      "amount_minor": 500000, "ordered_amount_minor": 500000, "currency": "THB",
      "created_at": "2026-08-21T04:10:47.902Z", "transferred_amount_minor": 500000,
      "amount_received_minor": 500000, "fee_total_minor": 7500,
      "fee_breakdown": [{ "component": "processing_fee", "amount_minor": 7500 }],
      "credited_amount_minor": 492500, "partial": false, "settled_via": "auto",
      "liquidity_source": "internal", "overpayment": null,
      "balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
      "settled_at": "2026-08-21T04:19:33.204Z",
      "clearing_available_at": "2026-08-24T04:19:33.204Z"
    },
    {
      "id": "dep_01K3MB2K8T4WQZ6XR0NHD7YV5A", "object": "deposit_order", "version": 5,
      "status": "settled", "is_terminal": false, "merchant_reference": "ORD-9984",
      "amount_minor": 120000, "ordered_amount_minor": 120000, "currency": "THB",
      "created_at": "2026-08-21T04:35:10.128Z", "transferred_amount_minor": 120000,
      "amount_received_minor": 120000, "fee_total_minor": 3200,
      "fee_breakdown": [{ "component": "processing_fee", "amount_minor": 3200 }],
      "credited_amount_minor": 116800, "partial": false, "settled_via": "auto",
      "liquidity_source": "internal", "overpayment": null,
      "balance_transaction_id": "btxn_01K3MB2K9V5XR0Q7NHD8YWC6FA",
      "settled_at": "2026-08-21T04:41:02.771Z",
      "clearing_available_at": "2026-08-24T04:41:02.771Z"
    }
  ],
  "has_more": true,
  "next_cursor": "cur_01K3MB9Q0R2T4V6X8Z0B2D4F6H"
}
ItemRule
limit1..200 — may be changed mid-pagination
cursoropaque value from next_cursor
Changing filters mid-pagination🔴 changing status or the time range while reusing the old cursor → 400 cursor_filter_mismatch. Start a fresh page.

Do not poll hard — the real numbers ​

What actually trips the rate limit

Every GET /v1/pay/* shares one bucket: 1,200 requests per 60 seconds per API key (burst 2×).

Polling strategyConcurrent open ordersRequests/minResult
Every 1s, per order201,200🔴 exactly at the limit — order 21 puts the whole key into 429
Every 1s, per order1006,000🔴 limited from second 12 of every minute
Every 5s, per order1001,200🔴 exactly at the limit, leaving no room for any other GET
Backoff 3 → 5 → 10 → 30 seconds100~200–400✅ safe
GET /v1/pay/deposits?status= every 15s (200 per call)1,000+4✅ very safe

🔴 A 429 blocks every GET on that key, not just the order you were polling, and retry loops carry a compounding penalty.

Safe rules ​

  1. Webhooks are the primary notification path, not the source of truth. State/ledger read through GET /v1/pay/* plus reconciliation decides when records disagree.
  2. Per-order polling uses backoff 3 → 5 → 10 → 30 seconds, and stops once is_terminal is true or status = "settled".
  3. Do not keep polling under_review orders — that time belongs to a human reviewer. Wait for the webhook; if you must check, use a batch call a few times a day.
  4. Daily reconciliation uses GET /v1/pay/events?since_sequence= plus GET /v1/pay/deposits?updated_after=, not a per-order GET sweep.
  5. Respect the headers we return on every response, not just on 429.
RateLimit-Limit: 1200
RateLimit-Remaining: 1187
RateLimit-Reset: 41
RateLimit-Policy: 1200;w=60
js
// Node — backoff polling that stops in the right places
const BACKOFF_MS = [3000, 5000, 10000, 30000]

async function waitForDeposit(depositId, { maxMs = 15 * 60 * 1000 } = {}) {
  const deadline = Date.now() + maxMs
  let step = 0

  while (Date.now() < deadline) {
    const d = await getDeposit(depositId)

    if (d.status === 'settled') return d
    if (d.is_terminal) return d
    // Waiting on a human reviewer — asking repeatedly changes nothing. Let the webhook tell you.
    if (d.status === 'under_review') return d

    await sleep(BACKOFF_MS[Math.min(step++, BACKOFF_MS.length - 1)])
  }
  return getDeposit(depositId)
}
php
<?php
// PHP — batch instead of walking order by order
function syncRecentDeposits(string $since, array $headers): int
{
    $cursor = null;
    $count  = 0;

    do {
        $query = http_build_query(array_filter([
            'updated_after' => $since,
            'limit'         => 200,
            'cursor'        => $cursor,
        ]));

        $res = httpGet("https://api.example-wl.com/v1/pay/deposits?{$query}", $headers);

        foreach ($res['data'] as $d) {
            // 🔴 Overwrite only when version is higher than what you hold, never unconditionally
            applyDepositIfNewer($d['id'], $d['version'], $d);
            $count++;
        }

        $cursor = $res['has_more'] ? $res['next_cursor'] : null;
    } while ($cursor !== null);

    return $count;
}

Can a GET return something older than a webhook? ​

Yes. If the webhook for a newer version reaches you first and your GET then hits a replica that is behind, you will read stale data. The fix is the same in both directions: only overwrite state when the incoming version is higher than the one you stored, whether it came from a webhook or from a GET.

sql
UPDATE deposits
   SET status = $2, version = $3, payload = $4, updated_at = now()
 WHERE deposit_id = $1
   AND version < $3;      -- rowCount = 0 means you already hold something newer; skip it

Full details in webhook order is not guaranteed.


Next ​

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