Create a withdrawal
Current scope — implementation 0092, validation and deployment pending
Start with the implemented Live P2P profile. Your provider must confirm the deployed version and configuration before real payments. The Target contract examples below describe a broader scope that this profile does not fully enable.
Request in implementation 0092
Send POST /v1/pay/withdrawals with a Live key explicitly granted withdrawals:write and the existing HMAC authentication. Use an 8–200 character Idempotency-Key. Retries retain the exact body/key and use a fresh nonce and signature.
{
"amount_minor": 100000,
"currency": "THB",
"merchant_reference": "WD-20261017-0001",
"end_user_ref": "customer-001",
"beneficiary_id": "ben_92000000000040008000000000000423",
"metadata": { "order": "WD-20261017-0001" }
}amount_minor is a positive safe integer in satang, at most 9007199254740991, and must meet the current P2P minimum. Both references are 1–64 characters without controls; merchant_reference is unique per Merchant. metadata accepts at most 20 string-valued keys, key lengths 1–40 and values up to 500 characters. Supported client hints are validated and then excluded from stored metadata and receipts.
Use an existing verified beneficiary, formatted as ben_ plus 32 lowercase UUID hexadecimal characters and owned by the same Merchant in Live mode. Inline beneficiary, Customer/beneficiary registration APIs and Merchant quote are outside this profile.
| Response | Meaning in this profile |
|---|---|
201 | pending_approval, requires_approval: true, version: 0; principal plus fee reserved, pending two employee approvals before matching |
402 insufficient_balance | Available balance cannot cover principal plus fee; no order is created |
409 beneficiary_not_verified | Beneficiary is ineligible or not owned by this Merchant in Live mode |
422 amount_below_p2p_minimum | Amount is below current P2P policy |
All four Payout sale prices are pinned at creation, with the current P2P fee based on wl_pool. Read fee_total_minor and total_locked_minor from the response. 201 is not a completed payout. Poll GET /v1/pay/withdrawals/{id} with withdrawals:read until status: "settled" and remaining_minor: 0. Events retain their existing business snapshots rather than carrying a full Withdrawal object. See supported endpoints, pagination and events.
Target contract — expanded flow not fully enabled by implementation 0092
The following examples describe the integration target
Inline beneficiaries, public beneficiary registration, quotes and the expanded event set below must be checked against the implemented profile before use.
A withdrawal order tells the system to find payers who will transfer into the destination account you name. The money does not leave a house account of ours — payers transfer directly into that account, chunk by chunk.
Two things must be true first, or the call cannot succeed at all:
| Requirement | What happens without it |
|---|---|
A destination account already at status = 'verified' | 409 beneficiary_not_verified · no order created, no money locked |
available_minor ≥ withdrawal amount + fee | 402 insufficient_balance · no order created |
Our fee is added on top, not deducted from the withdrawal amount
Many gateways in this market subtract the fee from the withdrawal — the user asks for 1,000 and receives 973.25.
We do not. The user asks for 1,000 and receives the full 1,000; the fee is added and taken from your wallet separately. So the amount actually locked is total_locked_minor = amount_total_minor + fee_total_minor.
What you have to change: check the user's balance against amount + fee from the screen onwards, not just the amount. Always call /v1/pay/quote before you open the form.
Endpoint
POST /v1/pay/withdrawalsRequired headers
| header | value |
|---|---|
Authorization | Bearer sk_live_... |
PG-Signature | v1=<hex HMAC-SHA256> — see request authentication |
PG-Timestamp | unix seconds (±300 s tolerance) |
PG-Nonce | unique within 10 minutes · new on every attempt |
Idempotency-Key | required on every POST · we keep it for 7 days |
PG-Version | e.g. 2026-08-01 |
Write the Idempotency-Key to your database before you send, do not generate one at retry time
If a retry generates a new key you get two withdrawal orders and twice the money locked. Every retry must read the original key back from your database and resend with it — changing only PG-Nonce.
Idempotency-Key is part of the signature's canonical string, so changing the key without re-signing gives you a 401.
Request body
| field | required | type | notes |
|---|---|---|---|
amount_minor | ✅ | int > 0 | 🔴 in satang · THB 1,000.00 = 100000 · in the returned object this field is called amount_total_minor |
currency | ✅ | string | "THB" only, today |
merchant_reference | ✅ | string ≤ 64 | unique per merchant |
end_user_ref | ✅ | string ≤ 64 | the user id in your system |
beneficiary_id | ✅ (or beneficiary{}) | string | from POST /v1/pay/beneficiaries · must be verified |
beneficiary | ✅ (or beneficiary_id) | object | {bank_code, account_number, account_name_th, promptpay_id?} · if that account has not passed verification → 409 beneficiary_not_verified |
client | — | object | {device_fingerprint, ip, user_agent} |
metadata | — | object | ≤ 20 keys · ≤ 500 bytes per value |
Send beneficiary_id, not beneficiary{}
An inline beneficiary{} is only accepted if that account has already passed verification. Send one that never has and you get a 409 every time.
The correct flow is: create the beneficiary → wait for beneficiary.verified → create the withdrawal with beneficiary_id.
Full example
Withdraw THB 5,000.00 (500000), fee THB 90.95 (9095) ⇒ you need available_minor ≥ 509095.
curl
curl -sS -X POST 'https://api.example-wl.com/v1/pay/withdrawals' \
-H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
-H 'PG-Timestamp: 1755745331' \
-H 'PG-Nonce: 01K3PA5T8V0X2Z4B6D8F0H2J4L' \
-H 'PG-Version: 2026-08-01' \
-H 'Idempotency-Key: wdr-u88213-20260821-0001' \
-H 'PG-Signature: v1=6b1f0c9d8e7a4b2c5d3e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c' \
-H 'Content-Type: application/json' \
-d '{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X)"
},
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
}'Node (fetch)
// pgFetch() is the helper that signs the canonical string — see /en/get-started/authentication
import { pgFetch } from './pg-client.js'
import { db } from './db.js'
export async function createWithdrawal({ userId, amountMinor, beneficiaryId, ref }) {
// 🔴 Persist the Idempotency-Key before sending — every retry reads the same value back.
const key = await db.oneValue(
`INSERT INTO wd_requests (merchant_reference, idem_key)
VALUES ($1, $2) ON CONFLICT (merchant_reference) DO UPDATE SET merchant_reference = EXCLUDED.merchant_reference
RETURNING idem_key`,
[ref, `wdr-${userId}-${ref}`]
)
const res = await pgFetch('POST', '/v1/pay/withdrawals', {
idempotencyKey: key,
body: {
amount_minor: amountMinor,
currency: 'THB',
merchant_reference: ref,
end_user_ref: userId,
beneficiary_id: beneficiaryId,
client: { device_fingerprint: 'df_9a1c4e77b2', ip: '203.0.113.44', user_agent: 'Mozilla/5.0 ...' },
metadata: { user_id: userId, ticket: ref },
},
})
if (res.status === 402) {
const d = res.body.error.details
// 🔴 No order was created → release the hold on the user's balance immediately.
await unlockUserBalance(userId, ref)
throw new NotEnoughBalance(d.available_minor, d.required_minor, d.pending_clearing_available_at)
}
if (res.status === 409 && res.body.error.code === 'beneficiary_not_verified') {
await unlockUserBalance(userId, ref)
throw new BeneficiaryNotReady()
}
if (res.status !== 201) throw new PgError(res.body.error)
const w = res.body
await db.none(
`INSERT INTO pg_withdrawals (pg_withdrawal_id, pg_version, status,
amount_total_minor, fee_total_minor, total_locked_minor,
filled_minor, remaining_minor, our_ref)
VALUES ($1,$2,$3,$4,$5,$6,0,$4,$7)
ON CONFLICT (pg_withdrawal_id) DO NOTHING`,
[w.id, w.version, w.status, w.amount_total_minor, w.fee_total_minor, w.total_locked_minor, ref]
)
return w
}PHP
<?php
// pg_request() is the helper that signs the canonical string — see /en/get-started/authentication
function create_withdrawal(PDO $db, string $userId, int $amountMinor, string $beneficiaryId, string $ref): array
{
// 🔴 Persist the Idempotency-Key before sending.
$st = $db->prepare(
'INSERT INTO wd_requests (merchant_reference, idem_key) VALUES (:r, :k)
ON CONFLICT (merchant_reference) DO UPDATE SET merchant_reference = EXCLUDED.merchant_reference
RETURNING idem_key'
);
$st->execute([':r' => $ref, ':k' => "wdr-{$userId}-{$ref}"]);
$idemKey = $st->fetchColumn();
[$status, $body] = pg_request('POST', '/v1/pay/withdrawals', [
'amount_minor' => $amountMinor,
'currency' => 'THB',
'merchant_reference' => $ref,
'end_user_ref' => $userId,
'beneficiary_id' => $beneficiaryId,
'client' => [
'device_fingerprint' => 'df_9a1c4e77b2',
'ip' => '203.0.113.44',
'user_agent' => 'Mozilla/5.0 ...',
],
'metadata' => ['user_id' => $userId, 'ticket' => $ref],
], $idemKey);
if ($status === 402) {
unlock_user_balance($userId, $ref); // no order was created
throw new NotEnoughBalance($body['error']['details']);
}
if ($status === 409 && $body['error']['code'] === 'beneficiary_not_verified') {
unlock_user_balance($userId, $ref);
throw new BeneficiaryNotReady();
}
if ($status !== 201) {
throw new PgError($body['error']);
}
return $body;
}Response 201
{
"id": "wdr_01K3PA6M2T7QW9YB4H0NRSXC5D",
"object": "withdrawal_order",
"version": 1,
"status": "open",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 500000,
"fee_total_minor": 9095,
"total_locked_minor": 509095,
"filled_minor": 0,
"remaining_minor": 500000,
"fee_recognized_minor": 0,
"settled_splits": 0,
"max_splits": 4,
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
}| field | how to read it |
|---|---|
status | open = in the pool · pending_approval = back office must approve first |
amount_total_minor | what the end user will have received in full once the order is settled |
fee_total_minor | the fee locked at creation time; it never changes 🔴 use this, not the quote |
total_locked_minor | amount_total_minor + fee_total_minor — gone from available_minor immediately |
remaining_minor | the part with no payer yet · equals amount_total_minor at creation |
max_splits | the maximum number of chunks this order can be split into · a policy value, do not hard-code it |
expires_at | 🔴 after this the order leaves the pool · always read it from the response, never hard-code a countdown |
Your parser must tolerate unknown fields
An api_version may add new fields without that counting as a breaking change, and several enums are open (fee_breakdown[].component, balance_transaction.type, cancel_reason_code). Every switch needs a default:, and the parser must not throw on an unknown key.
What happens the moment you get 201
total_locked_minoris deducted fromavailable_minorright then — not when the payout happens.- Webhook
withdrawal.created(carriesrequires_approvalandreserved_balance_transaction_id). - Webhook
balance.debited—type: "withdrawal_reserved"🔴 this event cannot be unsubscribed. You need a handler on day one.
{
"id": "evt_01K3PA6M4V8ZR2NTY0KDXC7WH5",
"object": "event",
"type": "withdrawal.created",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T03:02:11.508Z",
"sequence": 91004,
"account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
"request": { "id": "req_01K3PA6M1S", "idempotency_key": "wdr-u88213-20260821-0001" },
"data": {
"object": {
"id": "wdr_01K3PA6M2T7QW9YB4H0NRSXC5D",
"object": "withdrawal_order",
"version": 1,
"status": "open",
"merchant_reference": "WD-20260821-0001",
"end_user_ref": "u_88213",
"beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
"currency": "THB",
"amount_total_minor": 500000,
"fee_total_minor": 9095,
"total_locked_minor": 509095,
"filled_minor": 0,
"remaining_minor": 500000,
"fee_recognized_minor": 0,
"settled_splits": 0,
"max_splits": 4,
"requires_approval": false,
"reserved_balance_transaction_id": "btxn_01K3PA6M3U9YT1QW5HN0ZDXR8C",
"created_at": "2026-08-21T03:02:11.402Z",
"expires_at": "2026-08-21T09:02:11.402Z",
"metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0001" }
},
"previous_attributes": null
}
}201 does not mean the money reached the end user
201 means only "your money is locked and the order is in the pool". The real money arrives in the destination account chunk by chunk, as payers transfer it.
Close the order in your system only when status = 'settled' and remaining_minor = 0. Read handling partial fills before you write the handler.
pending_approval — when the order needs approval first
If provider policy requires approval at this amount, you get status: "pending_approval" instead of open.
| Fact | Consequence |
|---|---|
The money was already locked at 201 | available_minor is already down, approved or not |
| The order is not in the pool yet | nobody is transferring against it |
| The amount and the destination account cannot be edited | cancel and create a new order instead |
| It can be canceled, refunded in full | see cancel a withdrawal |
| Not approved within 24 hours | the system cancels it for you (cancel_reason_code: "approval_ttl_expired") and refunds in full |
Approval goes through POST /v1/bo/withdrawals/{id}/approve, which is a back-office endpoint and not part of the merchant integration.
There is no webhook for the approval itself
pending_approval → open has no event of its own. You read the new status from data.object.status on the next event that arrives (usually withdrawal.matched).
So if your screen needs to show "approved", poll GET /v1/pay/withdrawals/{id} with a backoff of 3 → 5 → 10 → 30 seconds. Do not poll every second (pay_read = 1,200 requests/minute per api_key).
Every error this endpoint can return
| HTTP | code | when | retryable | what to do |
|---|---|---|---|---|
| 400 | idempotency_key_required | no Idempotency-Key sent | ❌ | add the header (write it to your DB first) |
| 400 | malformed_json / unsupported_media_type | broken body / wrong content-type | ❌ | fix the code |
| 401 | signature_invalid | wrong canonical string | ❌ | compare against the test vector |
| 401 | timestamp_out_of_window | PG-Timestamp off by more than ±300 s | ✅ after a clock sync | run NTP |
| 401 | nonce_replayed | PG-Nonce reused within 10 minutes | ❌ | generate a new nonce per attempt (keep the same Idempotency-Key) |
| 401 | api_key_invalid / api_key_revoked | wrong or revoked key | ❌ | contact your provider |
| 402 | insufficient_balance | available_minor < amount + fee · 🔴 no order created | ✅ after pending_clearing_available_at | release the user's hold immediately and tell them when they can withdraw |
| 403 | tenant_suspended / scope_denied | account suspended / key lacks the scope | ❌ | contact your provider |
| 409 | idempotency_key_reused | same key, different body | ❌ | a different body needs a different key |
| 409 | idempotency_in_flight | the first request with this key is still running | ✅ (retry_after_seconds: 1) | wait, then retry with the same key |
| 409 | beneficiary_not_verified | destination account not verified · 🔴 no order, no money locked | ✅ after verification passes | wait for the beneficiary.verified webhook |
| 422 | validation_error | malformed field | ❌ | read details.fields[] |
| 422 | amount_not_supported | amount below or above policy, or an unsupported remainder | ❌ | use details.min_minor / details.max_minor |
| 429 | rate_limited | bucket pay_write exhausted (600/60 s per api_key) | ✅ | respect Retry-After + ±20% jitter · 🔴 never retry in a tight loop |
| 503 | route_unavailable / service_unavailable | we are shedding load | ✅ | show "system busy, try again" and follow Retry-After |
| 500 | internal_error | our fault | ✅ | send the request_id to support |
402 insufficient_balance — the quietest failure of them all
{
"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": 502000,
"required_minor": 509095,
"pending_clearing_minor": 480000,
"pending_clearing_available_at": "2026-08-24T14:26:44.000Z"
}
}
}The dashboard shows money but the withdrawal will not create
Money that just arrived from a deposit sits in the pending_clearing bucket for 72 hours before it moves to available. Only available_minor can be withdrawn.
Code that computes "withdrawable balance" from accumulated credited_amount_minor will fail to create withdrawals while the screen insists there is money.
What you have to change: keep the two numbers apart in your system — display available + pending_clearing, but gate withdrawals on available alone. Both come from GET /v1/pay/balance. On a 402, release the user's hold immediately and show the time from pending_clearing_available_at.
Which webhooks follow
withdrawal.created 1x money locked
balance.debited 1x type = withdrawal_reserved 🔴 cannot be unsubscribed
withdrawal.matched nx every time a payer is reserved for this order (not money yet)
withdrawal.filled nx one per chunk that actually arrived and passed slip verification
withdrawal.settled 1x filled_minor = amount_total_minorwithdrawal.matched is not money
matched means only "a payer has been reserved to transfer". That payer may never transfer, may transfer late, or may transfer and fail slip verification.
And because slip checking on the payer's side is accept first, verify afterwards (successful durable intake answers 202, with no immediate 409 duplicate_slip), the verdict reaches you later than the moment the payer hit send. If the after-the-fact check finds the slip unusable, a chunk that was already filled can still be reversed through withdrawal.fill_reversed (that event cannot be unsubscribed either).
Next
| If you are about to | Go to |
|---|---|
| Show the net amount before the user confirms | Quote the fee first |
| Get a destination account verified | Destination accounts |
| Write the withdrawal webhook handler | Handling partial fills |
| Cancel the part that has not been filled | Cancel a withdrawal |
| Stop a stale event from overwriting fresh state | Ordering and the version guard |
| Verify the signature before touching a payload | Verifying Ed25519 signatures |
