Skip to content

Quote the fee first ​

Your withdrawal screen has to answer two questions before the user taps confirm.

  1. How much will the user receive? — exactly the amount they typed, in full.
  2. How much comes out of your wallet? — the withdrawal amount plus the fee.

GET /v1/pay/quote answers question 2 before any money is locked.

We do not deduct the fee from the withdrawal amount — we add it on top

Many gateways in this market work like this: withdraw 1,000 → deduct a fee of 26.75 → the user receives 973.25.

We do the opposite: withdraw 1,000 → the user receives the full 1,000 → your wallet is debited 1,026.75.

The reason: the destination is a real person's bank account, and the payer transfers the amount shown on their screen. If the transferred amount does not match the amount on the order, slip matching fails. So the withdrawal amount must always be the number that actually moves between the two bank accounts.

What you have to change: everywhere your existing code says if (userBalance >= amount) it must become if (userBalance >= amount + fee) — on the screen and in the backend validation.

Endpoint ​

GET /v1/pay/quote?channel=withdrawal&amount_minor=100000
queryvalue
channelwithdrawal (payout side) or deposit (pay-in side)
amount_minoramount in satang · THB 1,000.00 = 100000

It is a GET, so no Idempotency-Key is needed — but you still sign PG-Signature as usual, and the query string is part of the canonical string. See request authentication.

Rate limit: bucket banks_quote = 3,000 requests/minute per api_key. You can call it on every keystroke, but debounce ~300 ms anyway.

Example ​

curl ​

bash
curl -sS 'https://api.example-wl.com/v1/pay/quote?amount_minor=100000&channel=withdrawal' \
  -H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
  -H 'PG-Timestamp: 1755745102' \
  -H 'PG-Nonce: 01K3PA4R6T8V0X2Z4B6D8F0H2J' \
  -H 'PG-Version: 2026-08-01' \
  -H 'PG-Signature: v1=2c5d3e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d'

Response 200 ​

json
{
  "channel": "withdrawal",
  "amount_minor": 100000,
  "fee_total_minor": 2675,
  "maximum_fee_minor": 2675,
  "fee_breakdown": [
    { "component": "processing_fee", "amount_minor": 1500, "rate_bps": 150 },
    { "component": "fixed_fee", "amount_minor": 1000, "rate_bps": 0 },
    { "component": "vat", "amount_minor": 175, "rate_bps": 700 }
  ],
  "source_fee_options": [],
  "pricing_matrix_version": "pmv_01K3Q9S7Y2",
  "total_minor": 102675
}
fieldwhat it meanswhat to do with it
amount_minorwhat the end user receives, in fullshow "you will receive THB 1,000.00"
fee_total_minortotal feeshow it as a separate line
maximum_fee_minorequal to fee_total_minor for withdrawalslets you share parsing code with deposit quotes
fee_breakdown[]split by fee typestore it raw; you will need it in billing disputes
source_fee_options[]empty for withdrawalsonly deposit quotes have multiple source options
pricing_matrix_versionopaque pricing versionkeep it in logs; the created order remains the source of truth
total_minoramount_minor + fee_total_minor🔴 this is the number to compare against available_minor

Node (fetch) ​

js
import { pgFetch } from './pg-client.js'

export async function quoteWithdrawal(amountMinor) {
  const res = await pgFetch('GET', '/v1/pay/quote', {
    query: { channel: 'withdrawal', amount_minor: String(amountMinor) },
  })
  if (res.status !== 200) throw new PgError(res.body.error)

  const q = res.body
  return {
    userReceivesMinor: q.amount_minor,   // the user gets this in full
    feeMinor: q.fee_total_minor,
    walletDebitMinor: q.total_minor,     // this much leaves your wallet
    lines: q.fee_breakdown,
  }
}

// Display in baht: never multiply or divide with floats in your logic.
// Convert at render time only.
export const toBaht = (minor) =>
  (minor / 100).toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })

PHP ​

php
<?php
function quote_withdrawal(int $amountMinor): array
{
    [$status, $body] = pg_request('GET', '/v1/pay/quote', null, null, [
        'channel'      => 'withdrawal',
        'amount_minor' => (string) $amountMinor,
    ]);
    if ($status !== 200) {
        throw new PgError($body['error']);
    }
    return [
        'user_receives_minor' => $body['amount_minor'],
        'fee_minor'           => $body['fee_total_minor'],
        'wallet_debit_minor'  => $body['total_minor'],
        'lines'               => $body['fee_breakdown'],
    ];
}

// 100000 → "1,000.00" · divide by 100 at display time only.
// Always store integers in the database.
function to_baht(int $minor): string
{
    return number_format($minor / 100, 2);
}

Combined with the balance — the logic behind the "Withdraw" button ​

js
const [q, bal] = await Promise.all([
  quoteWithdrawal(amountMinor),
  pgFetch('GET', '/v1/pay/balance').then((r) => r.body),
])

// 🔴 Compare against available_minor only — pending_clearing cannot be withdrawn yet.
const canWithdraw = bal.available_minor >= q.walletDebitMinor

// Message to show when the balance looks sufficient on screen but is not withdrawable.
// Thai text is what the end user sees ("Part of your balance is still clearing and
// cannot be withdrawn yet").
if (!canWithdraw && bal.available_minor + bal.pending_clearing_minor >= q.walletDebitMinor) {
  show('ยอดบางส่วนอยู่ระหว่างรอเคลียร์ ยังถอนไม่ได้')
}

GET /v1/pay/balance returns {available_minor, pending_clearing_minor, reserved_minor, currency, as_of}

bucketmeaningwithdrawable?
available_minorcleared✅
pending_clearing_minorjust received from a deposit, waiting 72 hours❌
reserved_minorlocked against a withdrawal that is still open❌

Four fee rules that trip up anyone who computes the fee themselves ​

Do not compute the fee in your own code

1 · A quote is the number "right now", not a commitment The fee bound to the real order is fee_total_minor in the response of POST /v1/pay/withdrawals. That value is locked when the order is created and never changes. Use the quote only to show a number before the user taps. After the order exists, store and quote the value from the create response.

2 · A fixed fee belongs to the order, not to the fill One withdrawal can be filled in several chunks (max_splits). Four chunks do not cost fixed_fee four times. The whole fee is recognized in proportion to what actually got paid out.

3 · A chunk's fee is not fee_total ÷ number of chunks The system computes a running total and takes the difference, so two chunks of the same size can carry different fees — and the sum still lands exactly on the total once the order is complete. See the real numbers in handling partial fills.

4 · component is an open enum Today you see processing_fee, fixed_fee and vat. A new one can appear tomorrow without that counting as a breaking change. Every switch (component) needs a default:, and the UI must render an unknown line rather than hide it.

The two amounts used throughout this documentation ​

withdrawalamount_minorfee_total_minortotal_minorminimum available_minor
THB 1,000.001000002675 (26.75)102675102675
THB 5,000.005000009095 (90.95)509095509095

The breakdown for THB 5,000.00:

json
{
  "amount_minor": 500000,
  "fee_total_minor": 9095,
  "fee_breakdown": [
    { "component": "processing_fee", "amount_minor": 7500, "rate_bps": 150 },
    { "component": "fixed_fee", "amount_minor": 1000, "rate_bps": 0 },
    { "component": "vat", "amount_minor": 595, "rate_bps": 700 }
  ],
  "total_minor": 509095
}

Store fee_breakdown[] raw

When a customer or your finance team asks "why was I charged 90.95?", the answer is in those lines. Always parse by component — never by array position.

The deposit side uses the same endpoint ​

channel=deposit returns the same shape, but the meaning is inverted. On the deposit side the user transfers the full amount and your wallet receives the amount after the fee:

Deposit side      bank verifies transferred_amount_minor (V) → gateway accepts amount_received_minor (A) → your wallet +credited_amount_minor
                  if V>A, excess E stays in an overpayment claim; it is neither wallet credit nor an automatic refund
Withdrawal side   user receives  amount_total_minor     →  your wallet −total_locked_minor      (larger  — fee added)

🔴 amount_received_minor (what you must credit to the end user) and credited_amount_minor (how much your merchant wallet moves) are two different numbers. Swapping them is the single most common mistake in a first integration. See the field dictionary.

Next ​

If you are about toGo to
Create a real withdrawalCreate a withdrawal
Get a destination account verified firstDestination accounts
See how the fee is recognized chunk by chunkHandling partial fills
Look up every money field nameField dictionary

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