Skip to content

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.

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

ResponseMeaning in this profile
201pending_approval, requires_approval: true, version: 0; principal plus fee reserved, pending two employee approvals before matching
402 insufficient_balanceAvailable balance cannot cover principal plus fee; no order is created
409 beneficiary_not_verifiedBeneficiary is ineligible or not owned by this Merchant in Live mode
422 amount_below_p2p_minimumAmount 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:

RequirementWhat happens without it
A destination account already at status = 'verified'409 beneficiary_not_verified · no order created, no money locked
available_minor ≥ withdrawal amount + fee402 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/withdrawals

Required headers ​

headervalue
AuthorizationBearer sk_live_...
PG-Signaturev1=<hex HMAC-SHA256> — see request authentication
PG-Timestampunix seconds (±300 s tolerance)
PG-Nonceunique within 10 minutes · new on every attempt
Idempotency-Keyrequired on every POST · we keep it for 7 days
PG-Versione.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 ​

fieldrequiredtypenotes
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 ≤ 64unique per merchant
end_user_ref✅string ≤ 64the user id in your system
beneficiary_id✅ (or beneficiary{})stringfrom 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 ​

bash
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) ​

js
// 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
<?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 ​

json
{
  "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" }
}
fieldhow to read it
statusopen = in the pool · pending_approval = back office must approve first
amount_total_minorwhat the end user will have received in full once the order is settled
fee_total_minorthe fee locked at creation time; it never changes 🔴 use this, not the quote
total_locked_minoramount_total_minor + fee_total_minor — gone from available_minor immediately
remaining_minorthe part with no payer yet · equals amount_total_minor at creation
max_splitsthe 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 ​

  1. total_locked_minor is deducted from available_minor right then — not when the payout happens.
  2. Webhook withdrawal.created (carries requires_approval and reserved_balance_transaction_id).
  3. Webhook balance.debited — type: "withdrawal_reserved" 🔴 this event cannot be unsubscribed. You need a handler on day one.
json
{
  "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.

FactConsequence
The money was already locked at 201available_minor is already down, approved or not
The order is not in the pool yetnobody is transferring against it
The amount and the destination account cannot be editedcancel and create a new order instead
It can be canceled, refunded in fullsee cancel a withdrawal
Not approved within 24 hoursthe 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 ​

HTTPcodewhenretryablewhat to do
400idempotency_key_requiredno Idempotency-Key sent❌add the header (write it to your DB first)
400malformed_json / unsupported_media_typebroken body / wrong content-type❌fix the code
401signature_invalidwrong canonical string❌compare against the test vector
401timestamp_out_of_windowPG-Timestamp off by more than ±300 s✅ after a clock syncrun NTP
401nonce_replayedPG-Nonce reused within 10 minutes❌generate a new nonce per attempt (keep the same Idempotency-Key)
401api_key_invalid / api_key_revokedwrong or revoked key❌contact your provider
402insufficient_balanceavailable_minor < amount + fee · 🔴 no order created✅ after pending_clearing_available_atrelease the user's hold immediately and tell them when they can withdraw
403tenant_suspended / scope_deniedaccount suspended / key lacks the scope❌contact your provider
409idempotency_key_reusedsame key, different body❌a different body needs a different key
409idempotency_in_flightthe first request with this key is still running✅ (retry_after_seconds: 1)wait, then retry with the same key
409beneficiary_not_verifieddestination account not verified · 🔴 no order, no money locked✅ after verification passeswait for the beneficiary.verified webhook
422validation_errormalformed field❌read details.fields[]
422amount_not_supportedamount below or above policy, or an unsupported remainder❌use details.min_minor / details.max_minor
429rate_limitedbucket pay_write exhausted (600/60 s per api_key)✅respect Retry-After + ±20% jitter · 🔴 never retry in a tight loop
503route_unavailable / service_unavailablewe are shedding load✅show "system busy, try again" and follow Retry-After
500internal_errorour fault✅send the request_id to support

402 insufficient_balance — the quietest failure of them all ​

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": 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_minor

withdrawal.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 toGo to
Show the net amount before the user confirmsQuote the fee first
Get a destination account verifiedDestination accounts
Write the withdrawal webhook handlerHandling partial fills
Cancel the part that has not been filledCancel a withdrawal
Stop a stale event from overwriting fresh stateOrdering and the version guard
Verify the signature before touching a payloadVerifying Ed25519 signatures

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