Deposit statuses
Three rules first
- Only
status = "settled"means you may credit. Every other value means release nothing. No exceptions. expiredandcanceledare not always the end. Look atis_terminal, not at the status name.- Webhooks are primary,
GETis the fallback — not the other way around.
All nine statuses
status | Meaning | Final | 💰 Where the money is | What you do |
|---|---|---|---|---|
awaiting_liquidity | Order created, but no destination to transfer to yet. No QR, no account number. | No | Not transferred | Show a "finding a transfer route" screen and offer liquidity.suggested_amounts_minor[] · 🔴 no QR, do not tell the user to transfer |
awaiting_payment | Matched. legs[] and expires_at are set. | No | Not transferred (still with the user) | Show the QR/account number, a countdown from expires_at, and the "transfer from this account only" box |
slip_processing | Slip received, verification running | No | Transferred, awaiting verification | "Checking your slip" · disable the upload button · 🔴 release nothing |
under_review | A non-amount dimension failed / policy hold / bank auto-verification off / duplicate slip / ambiguity; an amount mismatch alone never enters this state | No | Transferred, awaiting a human | Show review.reasons[0].display_th verbatim · yellow/blue, not red · 🔴 release nothing, no "transfer again" button |
settled | Credited; clean short-pay carries partial:true, settled_via:'auto'; may carry an overpayment{} flag | Almost (the only exit is reversed) | Only amount_received_minor enters the order; excess is in a claim | Credit with amount_received_minor; show the actual accepted short-pay; if flagged, show the excess/claim and contact-support action |
expired | expires_at passed with no slip · 🔴 not a rejection of money | Not until the late-slip window closes | Usually not transferred · if a late slip arrives, transferred | Keep an "I already transferred but it expired" button until late_slip_accepted_until |
canceled | Closed before payment — by you, by the user, or by the system when no destination could be found | Depends on late_slip_accepted_until | Usually not transferred | If late_slip_accepted_until is set, keep the upload button exactly as for expired |
rejected | A reviewer decided this slip is not evidence for this order | ✅ | Not credited | Show rejection.display_th and make deposit_id copyable |
reversed | Was 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
// ❌ 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
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:
{
"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.
| field | Use it for |
|---|---|
transferred_amount_minor | Bank-verified settlement evidence; it may exceed A on same-slip overpayment · a later transfer is not added cumulatively here |
amount_received_minor | Crediting the end user — what the gateway accepted into this order |
fee_total_minor | The fee charged to you |
credited_amount_minor | What 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
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"{
"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"
}| Item | Rule |
|---|---|
limit | 1..200 — may be changed mid-pagination |
cursor | opaque 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 strategy | Concurrent open orders | Requests/min | Result |
|---|---|---|---|
| Every 1s, per order | 20 | 1,200 | 🔴 exactly at the limit — order 21 puts the whole key into 429 |
| Every 1s, per order | 100 | 6,000 | 🔴 limited from second 12 of every minute |
| Every 5s, per order | 100 | 1,200 | 🔴 exactly at the limit, leaving no room for any other GET |
Backoff 3 → 5 → 10 → 30 seconds | 100 | ~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
- Webhooks are the primary notification path, not the source of truth. State/ledger read through
GET /v1/pay/*plus reconciliation decides when records disagree. - Per-order polling uses backoff 3 → 5 → 10 → 30 seconds, and stops once
is_terminalistrueorstatus = "settled". - Do not keep polling
under_revieworders — that time belongs to a human reviewer. Wait for the webhook; if you must check, use a batch call a few times a day. - Daily reconciliation uses
GET /v1/pay/events?since_sequence=plusGET /v1/pay/deposits?updated_after=, not a per-orderGETsweep. - 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// 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 — 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.
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 itFull details in webhook order is not guaranteed.
Next
- Open orders — finding the orders a single user still has open
- Webhook order is not guaranteed — the version guard that lets
GETand webhooks coexist - Verifying Ed25519 signatures — making webhooks trustworthy so you can stop polling
- Cancel a deposit — when an open order has to be closed
- The full state machine — every transition between statuses
