Wallet and balances
A merchant wallet is three buckets, not one number. Money that has just arrived from a deposit is not withdrawable straight away.
The mistake that breaks this page most often
Taking credited_amount_minor from deposit.settled, assuming it is spendable, and immediately creating a withdrawal. You get 402 insufficient_balance while your dashboard clearly shows the money.
Freshly credited funds always land in the pending clearing bucket first. You have to wait until available_at.
What the three buckets are
| Bucket | Field | Meaning | Can back a withdrawal? |
|---|---|---|---|
| Available | available_minor | Withdrawable now · the dispute window has already passed | ✅ |
| Pending clearing | pending_clearing_minor | Really credited, but the withdrawer on the other side can still open a dispute (72 hours) | ❌ |
| Reserved | reserved_minor | Held for withdrawals/refunds that are still open (principal + fee) | ❌ |
The balance your end user sees inside your own product is a separate thing from these three buckets — the user is credited with amount_received_minor, while the merchant wallet moves by credited_amount_minor (R1).
The name reserved_minor is used in two places and means two different things
- In
GET /v1/pay/balance→ money in your wallet that is locked for withdrawals that have not closed yet. - In a withdrawal object (
GET /v1/pay/withdrawals/{id}) → the part of that one order that somebody is currently transferring to you, not settled yet.
Never add these two together, and never substitute one for the other.
Reading the balance
curl -sS "https://api.example-wl.com/v1/pay/balance" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a"{
"available_minor": 1284300,
"pending_clearing_minor": 492500,
"reserved_minor": 200000,
"currency": "THB",
"as_of": "2026-08-21T09:41:20.006Z"
}The unit is always satang (minor units) — 1284300 is THB 12,843.00.
Convert units only at the edge of your system
Store integer satang in the database, do the arithmetic in integers, and divide by 100 only when rendering. Never let a float in along the way — 500000 / 100 is safe, 5000.00 * 100 is not.
Node (fetch)
import { createHmac, createHash, randomUUID } from 'node:crypto'
const BASE = 'https://api.example-wl.com'
const KEY = process.env.PG_SECRET_KEY // sk_live_*
const PG_VERSION = '2026-08-01'
// canonical string = 9 lines, every line terminated with \n including the last
function sign({ method, path, query = '', body = '', idempotencyKey = '' }) {
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = randomUUID().replace(/-/g, '')
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex')
const canonical =
`v1\n${ts}\n${nonce}\n${method}\n${path}\n${query}\n${bodyHash}\n${idempotencyKey}\n${PG_VERSION}\n`
const sig = createHmac('sha256', KEY).update(canonical, 'utf8').digest('hex')
return {
'Authorization': `Bearer ${KEY}`,
'PG-Timestamp': ts,
'PG-Nonce': nonce,
'PG-Version': PG_VERSION,
'PG-Signature': `v1=${sig}`,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
}
}
export async function getBalance() {
const path = '/v1/pay/balance'
const res = await fetch(BASE + path, { headers: sign({ method: 'GET', path }) })
if (!res.ok) throw new Error(`balance failed: ${res.status} ${await res.text()}`)
return res.json()
}PHP
<?php
const BASE = 'https://api.example-wl.com';
const PG_VERSION = '2026-08-01';
function pg_sign(string $method, string $path, string $query = '', string $body = '', string $idem = ''): array {
$key = getenv('PG_SECRET_KEY');
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonical = "v1\n{$ts}\n{$nonce}\n{$method}\n{$path}\n{$query}\n"
. hash('sha256', $body) . "\n{$idem}\n" . PG_VERSION . "\n";
$headers = [
'Authorization: Bearer ' . $key,
'PG-Timestamp: ' . $ts,
'PG-Nonce: ' . $nonce,
'PG-Version: ' . PG_VERSION,
'PG-Signature: v1=' . hash_hmac('sha256', $canonical, $key),
];
if ($idem !== '') { $headers[] = 'Idempotency-Key: ' . $idem; }
return $headers;
}
function pg_get_balance(): array {
$ch = curl_init(BASE . '/v1/pay/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => pg_sign('GET', '/v1/pay/balance'),
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) { throw new RuntimeException("balance failed: {$code} {$raw}"); }
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
}Your own back-office wallet screen can also call GET /v1/bo/wallet — same numbers. But the balance you make money decisions on must come from GET /v1/pay/balance, because that is the set reconciliation uses (see Reports and reconciliation).
🔴 available_at — when newly received money becomes withdrawable
Money from a deposit always moves in two steps.
T+0 deposit.settled → balance.credited bucket_to = "pending_clearing"
the balance_transaction carries available_at
T+72h (no business event) → balance.cleared bucket_from = "pending_clearing"
bucket_to = "available"The field that carries this timestamp has two different names, depending on which object you are reading.
| Reading | Field | Example |
|---|---|---|
deposit.settled (object = deposit_order) | clearing_available_at | "2026-08-24T04:12:33.881Z" |
balance.credited (object = balance_transaction) | available_at | "2026-08-24T04:12:33.881Z" |
A full balance.credited event
{
"id": "evt_01K3M9ZZ5Q8RX3PJT1WVKD6N4A",
"object": "event",
"type": "balance.credited",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:12:33.902Z",
"sequence": 88215,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"object": "balance_transaction",
"type": "deposit_settled",
"direction": "credit",
"amount_minor": 500000,
"currency": "THB",
"bucket_from": null,
"bucket_to": "pending_clearing",
"available_at": "2026-08-24T04:12:33.881Z",
"balance_after": {
"available_minor": 1284300,
"pending_clearing_minor": 500000,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"created_at": "2026-08-21T04:12:33.881Z"
}
}
}balance.cleared is not new money arriving
balance.cleared is a bucket move from pending_clearing to available. It is not a second credit.
Code that does wallet += event.data.object.amount_minor on every balance.* ends up with double the real balance on every single deposit.
The correct shape: balance.credited = add to pending_clearing · balance.cleared = subtract from pending_clearing and add to available · balance.debited = subtract according to bucket_from.
{
"id": "evt_01K3TQ8W2N5RZ7YB4KDXCVH0PM",
"object": "event",
"type": "balance.cleared",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-24T04:12:41.220Z",
"sequence": 91884,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "btxn_01K3TQ8W1M4NP0DZ2XKQ7RV9B",
"object": "balance_transaction",
"type": "clearing_released",
"direction": "credit",
"amount_minor": 500000,
"currency": "THB",
"bucket_from": "pending_clearing",
"bucket_to": "available",
"cleared_from_balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"balance_after": {
"available_minor": 1784300,
"pending_clearing_minor": 0,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"created_at": "2026-08-24T04:12:41.108Z"
}
}
}Why the 72-hour wait exists — and why it is good for you
The money never passes through an account of ours. The end user transfers directly into the bank account of another merchant who is withdrawing. That withdrawer can press "I did not receive the money" for 72 hours.
If a reversal happens inside that window, we take it out of pending_clearing, not out of available — look at bucket_from on balance.debited.
{
"type": "reversal",
"direction": "debit",
"amount_minor": 492500,
"bucket_from": "pending_clearing",
"bucket_to": null,
"reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT"
}⇒ Your wallet never goes negative, and you never have to chase a refund from a user who has already walked off with the goods. That is the entire point of the 72-hour window.
How this differs from the rest of the market
Most gateways let you withdraw the moment money hits your balance, because the money is sitting in their own pooled account. We have no pooled account on the normal path.
What you have to do about it before production
- Tell your finance team about the cash-flow shape before go-live, not after — money you sell today is withdrawable on day 4.
- Keep two separate columns in your own books: the balance you show users (
available + pending_clearing) and the balance you make withdrawal decisions on (availableonly). - Your back-office wallet screen must show all three buckets separately, with the
available_atof each pending chunk.
When available_at has passed but the balance has not moved
This genuinely happens: the withdrawer on the other side opened a dispute before the 72 hours were up, and that chunk is held until the dispute is decided.
- You only receive
withdrawal.disputedwhen you are the withdrawing side of that order. If you are the depositing side, there is no event telling you the chunk is held. - ⇒ 🔴 Never drive logic off
available_at. Use it as copy for the user and nothing more. - The only number you can actually decide on is
available_minorfromGET /v1/pay/balance, read at the moment you are about to create the withdrawal.
Just try the withdrawal, do not pre-check
Do not guess whether the money is ready. Fire the create call and handle 402 correctly — that is both faster and more correct than polling the balance.
{ "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-24T04:12:33.881Z"
} } }(message_th reads: "The withdrawable balance is not enough for the withdrawal amount plus the fee." It is Thai text for your own staff.)
🔴 When you get 402, no order was created and no money was locked. Release whatever you were holding on your side back to the user immediately, and tell them when it will be ready using pending_clearing_available_at.
When the reserved bucket moves
reserved_minor in the wallet tracks the lifecycle of withdrawals and refunds.
| Event | Effect on the wallet | balance_transaction.type |
|---|---|---|
Withdrawal created (POST /v1/pay/withdrawals) | available −(amount + fee) · reserved +amount | withdrawal_reserved (debit) |
One chunk arrives (withdrawal.filled) | reserved −that chunk | withdrawal_settled (debit) |
| Order rejected while awaiting approval | full amount returns to available | withdrawal_released (credit) |
| Order expires/cancels with a remaining balance | the remainder plus the unused fee returns | withdrawal_released (credit) |
The withdrawal fee is added on top, not taken out of the amount
Withdraw THB 10,000.00 with a THB 30.00 fee ⇒ you need available_minor ≥ 1003000. The beneficiary receives the full THB 10,000.00 · your wallet drops by THB 10,030.00. Check with GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000 before you show a number to the user.
Proving your numbers match ours
Every wallet movement has exactly one balance_transaction ⇒ you can reconcile line by line.
Σ balance_transactions per bucket == that bucket in GET /v1/pay/balancebalance_transaction is immutable ⇒ no version, no previous_attributes ⇒ dedupe on id (btxn_...) and accumulate. Never write balance_after over your own totals: events can arrive out of order, and an older event's balance_after would overwrite a newer one.
The full reconciliation script lives in Reports and reconciliation.
Wallet screen checklist
- [ ] Show all three buckets separately, never collapsed into one figure
- [ ] The "withdraw" button is driven by
available_minoronly - [ ]
balance.clearedis handled as a bucket move, not as new money - [ ]
balance.debitedis handled (this event cannot be unsubscribed — you need a handler on day one) - [ ]
balance_transactiondeduped bybtxn_idand accumulated, never usingbalance_afteras an overwrite - [ ]
402 insufficient_balancereleases the user's held amount immediately - [ ]
available_atis shown as copy, never used to drive logic
Next
- Reports and reconciliation — the script that proves
Σ balance_transactionsmatches the wallet - Approving withdrawals — money is locked at creation, not at approval
- Create a withdrawal —
402and releasing the user's held amount - All events —
balance.credited/balance.cleared/balance.debited - Claims — when funds are held because of a dispute
