Skip to content

Approving withdrawals ​

Some withdrawals land in pending_approval first. Somebody has to approve them in your back office before they enter matching.

Money is locked at creation, not at approval

POST /v1/pay/withdrawals takes amount + fee out of available_minor immediately, on both the approval path and the non-approval path.

You receive balance.debited (type: "withdrawal_reserved") at the moment of creation — before anyone approves anything.

Why: if the lock happened at approval, one user could open ten withdrawals against the same money, and approving them one by one would drive the wallet negative.

Which orders need approval ​

Look at the create response.

bash
curl -sS -X POST "https://api.example-wl.com/v1/pay/withdrawals" \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Timestamp: 1755749553" \
  -H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
  -H "PG-Version: 2026-08-01" \
  -H "Idempotency-Key: wdr-u88213-20260821-01" \
  -H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_minor": 1000000,
    "currency": "THB",
    "merchant_reference": "WD-20260821-0042",
    "end_user_ref": "u_88213",
    "beneficiary_id": "ben_01K2R7Q4M8N0PZ3XT5VKD9WY2C",
    "client": {
      "device_fingerprint": "df_9a1c4e77b2",
      "ip": "203.0.113.44",
      "user_agent": "Mozilla/5.0"
    },
    "metadata": { "user_id": "u_88213" }
  }'
json
{
  "id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
  "object": "withdrawal_order",
  "version": 1,
  "status": "pending_approval",
  "amount_total_minor": 1000000,
  "fee_total_minor": 3000,
  "total_locked_minor": 1003000,
  "currency": "THB",
  "expires_at": "2026-08-21T16:12:00.000Z",
  "created_at": "2026-08-21T04:12:00.118Z"
}
status you get backMeaningWhat happens next
pending_approvalwaiting for a human to approveit goes into your back-office queue
openno approval needed, already in matchingwait for withdrawal.matched / withdrawal.filled

The withdrawal.created webhook carries requires_approval and reserved_balance_transaction_id (the transaction that locked amount + fee).

total_locked_minor is amount + fee, not just the amount

The withdrawal fee is added on top, not deducted from what the beneficiary receives. Withdraw THB 10,000.00 with a THB 30.00 fee ⇒ the beneficiary gets the full THB 10,000.00 · your wallet drops by THB 10,030.00. If available_minor is below 1003000 you get 402 insufficient_balance, and no order is created and no money is locked.

Approving ​

bash
curl -sS -X POST "https://api.example-wl.com/v1/bo/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/approve" \
  -H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
  -H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
  -H "Idempotency-Key: approve-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
  -H "Content-Type: application/json" \
  -d '{
    "note": "ยอดตรงกับคำขอถอนของลูกค้าในระบบ ตรวจบัญชีปลายทางแล้ว",
    "reason_code": "WITHDRAWAL_DETAILS_VERIFIED"
  }'

The note above is Thai, because the approver is a Thai staff member. It reads: "Amount matches the customer's withdrawal request in our system; destination account verified." Write the note in whatever language your reviewers actually use — it is free text, only the 20-character minimum is enforced.

/v1/bo/* uses the operator's OIDC access token, not sk_live_* or PG HMAC. This action also requires an unexpired step-up token. The system identifies the approver from the OIDC token's sub claim — never accept an actor_id or approver ID in the request body.

The idempotency scope includes tenant, the verified OIDC principal (issuer/sub mapped to a tenant user), livemode, and method/path. A retry by the same person with the same literal key and body replays the result; a second approver is a different principal, so the same literal key is an independent operation. Step-up is a one-time credential bound to subject, resource/action, Idempotency-Key, and body digest. Once the operation completed, replaying that same scope/key/body does not require another MFA ceremony.

json
{
  "id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
  "object": "withdrawal_order",
  "version": 2,
  "status": "open",
  "amount_total_minor": 1000000,
  "fee_total_minor": 3000,
  "filled_minor": 0,
  "remaining_minor": 1000000,
  "approved_at": "2026-08-21T04:14:52.330Z",
  "expires_at": "2026-08-21T16:12:00.000Z"
}

There is no withdrawal.approved event, and there never will be

Approval moves no money — it was locked at creation — so there is no journal entry, so there is no dedicated event.

You learn that an order was approved from data.object.status turning into open on that order's next event (for example withdrawal.matched), or from GET /v1/pay/withdrawals/{id}.

Code that waits for an event called withdrawal.approved waits forever.

Conditions that must all pass before approval works ​

#ConditionWhy
1The approver must not be the person who created the orderone person creating and approving their own payout is no control at all
2Step-up MFA within the last 5 minutes before pressinga session left logged in on a desk is not enough authority to move money out
3A second approver if the amount is over the threshold (next section)large amounts need a second pair of eyes
4No account anywhere in the chain may be suspendedif any tier is suspended you get 403 tenant_suspended
5note must be at least 20 charactersenforced in the database; short notes get 422 review_note_required

"Approved" does not mean "paid"

open means the order has entered matching. Nobody has transferred a single baht yet. You may close the order in your own system only when status = 'settled' and remaining_minor = 0.

The two-approver threshold ​

Amounts above the threshold set by the tier above you need a second approver who is not the first one before the order can move.

Each approver makes a separate approve request with their own OIDC access token and their own one-time step-up token. Never submit two approvers in one request. They may use the same literal Idempotency-Key because the server scopes it by principal; do not mutate the body merely to make the calls distinct.

AmountApprovers requiredWhat the first approval does
At or under the threshold1status flips to open immediately
Over the threshold2the order stays pending_approval until the second person approves

What your back office needs

  1. A queue that visibly separates "waiting for approver 1" from "waiting for approver 2". Without it, large orders sit there and nobody notices.
  2. The approve button greyed out for anyone who already pressed it — not an error message after the fact.
  3. A 24-hour clock counted from creation time (next section), because orders waiting for a second approver expire too.
  4. A record of who pressed what, when, and with which note — see Logs and audit trail.

The threshold value is not sent in webhooks

We do not put the threshold figure in event payloads ⇒ do not try to parse it out of an event. Read the current value on the Settings page. You may tighten it, but only within the ceiling set by the tier above you.

Rejecting ​

bash
curl -sS -X POST "https://api.example-wl.com/v1/bo/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/reject" \
  -H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN" \
  -H "X-PG-Step-Up-Token: $BO_STEP_UP_TOKEN" \
  -H "Idempotency-Key: reject-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
  -H "Content-Type: application/json" \
  -d '{
    "note": "ลูกค้ายกเลิกคำขอถอนทางแชทก่อนเวลา 11:20 น. ยืนยันตัวตนแล้ว",
    "reason_code": "END_USER_CANCELED"
  }'

(The Thai note reads: "Customer cancelled the withdrawal request over chat before 11:20; identity verified.")

What happens

  • The order goes to rejected — being rejected during pending_approval is the only way into that status.
  • The full amount + fee returns to available_minor.
  • Webhooks: withdrawal.rejected (with rejection.code plus display text) and balance.credited (type: "withdrawal_released").
  • A new order can be created straight away. The old one never comes back.

A note shorter than 20 characters gets

json
{ "error": {
    "code": "review_note_required",
    "type": "invalid_request_error",
    "message": "A decision note of at least 20 characters is required.",
    "message_th": "ต้องระบุเหตุผลอย่างน้อย 20 ตัวอักษร",
    "retryable": false,
    "retry_after_seconds": null,
    "request_id": "req_01JQA5TD5Y3A7E9G1J3L5N7Q9S",
    "details": { "field": "note" } } }

Orders nobody touches within 24 hours ​

An order stuck in pending_approval for more than 24 hours is cancelled automatically and refunded in full.

json
{
  "id": "evt_01K3RC7T2V5XZ8YB4KDMWQH0PL",
  "object": "event",
  "type": "withdrawal.canceled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-22T04:12:00.442Z",
  "sequence": 90117,
  "account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
  "data": {
    "object": {
      "id": "wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC",
      "object": "withdrawal_order",
      "version": 3,
      "status": "canceled",
      "canceled_by": "system",
      "cancel_reason_code": "approval_ttl_expired",
      "amount_total_minor": 1000000,
      "fee_total_minor": 3000,
      "filled_minor": 0,
      "refunded_minor": 1000000,
      "fee_recognized_minor": 0,
      "currency": "THB"
    },
    "previous_attributes": { "version": 1, "status": "pending_approval" }
  }
}

Alert well before the 24 hours are up

An order sitting for more than 4 hours is a sign that nobody is watching the approval queue, not that the order has a problem. A customer who waits 24 hours and then gets auto-cancelled will have no idea what happened unless you tell them first.

Cancelling while it waits for approval ​

bash
curl -sS -X POST "https://api.example-wl.com/v1/pay/withdrawals/wdr_01K3PB2M7Q4RW8YT0NBVXH5DKC/cancel" \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "PG-Timestamp: 1755749760" \
  -H "PG-Nonce: 01JQA5TF7A5C9E1G3J5L7N9Q1S" \
  -H "PG-Version: 2026-08-01" \
  -H "Idempotency-Key: cancel-wdr01K3PB2M7Q4RW8YT0NBVXH5DKC" \
  -H "PG-Signature: v1=6d2b90f4a8c1e37b5d9f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "ลูกค้าเปลี่ยนบัญชีปลายทาง" }'

(The Thai reason reads: "Customer changed the destination account.")

Full refund · withdrawal.canceled + balance.credited.

You cannot edit the amount or the beneficiary of a pending_approval order

There is no endpoint for it and there will not be one — cancel and create a new order, that is the whole answer. Why: the money is already locked against the old amount. Editing mid-flight means unlocking and re-locking inside the same transaction as the beneficiary change, and that is exactly the place where money goes missing.

What follows approval ​

One withdrawal can be filled by several deposits ⇒ the webhooks arrive repeatedly, not once at the end.

withdrawal.matched   (chunk 1 · 4,000.00)   ← fires on every reservation, not just the first
withdrawal.filled    (filled 400000 · remaining 600000)
withdrawal.matched   (chunk 2 · 6,000.00)
withdrawal.filled    (filled 1000000 · remaining 0)
withdrawal.settled

withdrawal.filled is not the end

Always read the totals from data.object.filled_minor / remaining_minor behind a version guard. Never drive logic from a SUM() over your own fill table — events may still be in flight, and their order is not guaranteed. The fill table exists to build a timeline, keyed by leg_ref.

Full detail in One order, many fills (partial fill).

Errors you will hit on this page ​

HTTPcodeWhenWhat to do
402insufficient_balanceavailable_minor below amount + fee at creationrelease the user's held amount immediately · tell them the date from details.pending_clearing_available_at
409withdrawal_has_pending_fillyou cancelled while somebody is mid-transferwait retry_after_seconds and try again — 🔴 never strand a payer who is already transferring
409withdrawal_not_cancelablethe order is already settled/expired/canceledread the current state with GET
409withdrawal_state_conflictthe state moved while you were pressing the buttonread the current state with GET and decide again
409beneficiary_not_verifiedthe beneficiary account is not verified yetwait for the beneficiary.verified webhook — no order was created, no money locked
422review_note_requirednote shorter than 20 characterswrite a real reason
403tenant_suspendedsome account in the chain is suspendedcontact your provider

Approval queue checklist ​

  • [ ] Show total_locked_minor (amount + fee), not just the withdrawal amount
  • [ ] Separate the "waiting for approver 1" and "waiting for approver 2" queues
  • [ ] 24-hour countdown from the order's created_at, with an alert at 4 hours
  • [ ] Enforce the 20-character note minimum in your UI too, not by waiting for 422
  • [ ] Grey out the approve button for the creator and for anyone who already approved
  • [ ] Read the outcome from data.object.status; do not wait for a withdrawal.approved event
  • [ ] Persist the Idempotency-Key + body snapshot per OIDC principal before sending; the same person retries those values, while approver two is a new operation even with the same literal key

Next ​

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