Quote the fee first
Your withdrawal screen has to answer two questions before the user taps confirm.
- How much will the user receive? — exactly the amount they typed, in full.
- 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| query | value |
|---|---|
channel | withdrawal (payout side) or deposit (pay-in side) |
amount_minor | amount 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
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
{
"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
}| field | what it means | what to do with it |
|---|---|---|
amount_minor | what the end user receives, in full | show "you will receive THB 1,000.00" |
fee_total_minor | total fee | show it as a separate line |
maximum_fee_minor | equal to fee_total_minor for withdrawals | lets you share parsing code with deposit quotes |
fee_breakdown[] | split by fee type | store it raw; you will need it in billing disputes |
source_fee_options[] | empty for withdrawals | only deposit quotes have multiple source options |
pricing_matrix_version | opaque pricing version | keep it in logs; the created order remains the source of truth |
total_minor | amount_minor + fee_total_minor | 🔴 this is the number to compare against available_minor |
Node (fetch)
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
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
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}
| bucket | meaning | withdrawable? |
|---|---|---|
available_minor | cleared | ✅ |
pending_clearing_minor | just received from a deposit, waiting 72 hours | ❌ |
reserved_minor | locked 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
| withdrawal | amount_minor | fee_total_minor | total_minor | minimum available_minor |
|---|---|---|---|---|
| THB 1,000.00 | 100000 | 2675 (26.75) | 102675 | 102675 |
| THB 5,000.00 | 500000 | 9095 (90.95) | 509095 | 509095 |
The breakdown for THB 5,000.00:
{
"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 to | Go to |
|---|---|
| Create a real withdrawal | Create a withdrawal |
| Get a destination account verified first | Destination accounts |
| See how the fee is recognized chunk by chunk | Handling partial fills |
| Look up every money field name | Field dictionary |
