Skip to content

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 ​

BucketFieldMeaningCan back a withdrawal?
Availableavailable_minorWithdrawable now · the dispute window has already passed✅
Pending clearingpending_clearing_minorReally credited, but the withdrawer on the other side can still open a dispute (72 hours)❌
Reservedreserved_minorHeld 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 ​

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

js
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
<?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.

ReadingFieldExample
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

json
{
  "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.

json
{
  "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.

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

  1. Tell your finance team about the cash-flow shape before go-live, not after — money you sell today is withdrawable on day 4.
  2. Keep two separate columns in your own books: the balance you show users (available + pending_clearing) and the balance you make withdrawal decisions on (available only).
  3. Your back-office wallet screen must show all three buckets separately, with the available_at of 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.disputed when 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_minor from GET /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.

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-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.

EventEffect on the walletbalance_transaction.type
Withdrawal created (POST /v1/pay/withdrawals)available −(amount + fee) · reserved +amountwithdrawal_reserved (debit)
One chunk arrives (withdrawal.filled)reserved −that chunkwithdrawal_settled (debit)
Order rejected while awaiting approvalfull amount returns to availablewithdrawal_released (credit)
Order expires/cancels with a remaining balancethe remainder plus the unused fee returnswithdrawal_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/balance

balance_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_minor only
  • [ ] balance.cleared is handled as a bucket move, not as new money
  • [ ] balance.debited is handled (this event cannot be unsubscribed — you need a handler on day one)
  • [ ] balance_transaction deduped by btxn_id and accumulated, never using balance_after as an overwrite
  • [ ] 402 insufficient_balance releases the user's held amount immediately
  • [ ] available_at is shown as copy, never used to drive logic

Next ​

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