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.
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" }
}'{
"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 back | Meaning | What happens next |
|---|---|---|
pending_approval | waiting for a human to approve | it goes into your back-office queue |
open | no approval needed, already in matching | wait 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
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.
{
"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
| # | Condition | Why |
|---|---|---|
| 1 | The approver must not be the person who created the order | one person creating and approving their own payout is no control at all |
| 2 | Step-up MFA within the last 5 minutes before pressing | a session left logged in on a desk is not enough authority to move money out |
| 3 | A second approver if the amount is over the threshold (next section) | large amounts need a second pair of eyes |
| 4 | No account anywhere in the chain may be suspended | if any tier is suspended you get 403 tenant_suspended |
| 5 | note must be at least 20 characters | enforced 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.
| Amount | Approvers required | What the first approval does |
|---|---|---|
| At or under the threshold | 1 | status flips to open immediately |
| Over the threshold | 2 | the order stays pending_approval until the second person approves |
What your back office needs
- A queue that visibly separates "waiting for approver 1" from "waiting for approver 2". Without it, large orders sit there and nobody notices.
- The approve button greyed out for anyone who already pressed it — not an error message after the fact.
- A 24-hour clock counted from creation time (next section), because orders waiting for a second approver expire too.
- 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
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 duringpending_approvalis the only way into that status. - The full
amount + feereturns toavailable_minor. - Webhooks:
withdrawal.rejected(withrejection.codeplus display text) andbalance.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
{ "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.
{
"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
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.settledwithdrawal.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
| HTTP | code | When | What to do |
|---|---|---|---|
| 402 | insufficient_balance | available_minor below amount + fee at creation | release the user's held amount immediately · tell them the date from details.pending_clearing_available_at |
| 409 | withdrawal_has_pending_fill | you cancelled while somebody is mid-transfer | wait retry_after_seconds and try again — 🔴 never strand a payer who is already transferring |
| 409 | withdrawal_not_cancelable | the order is already settled/expired/canceled | read the current state with GET |
| 409 | withdrawal_state_conflict | the state moved while you were pressing the button | read the current state with GET and decide again |
| 409 | beneficiary_not_verified | the beneficiary account is not verified yet | wait for the beneficiary.verified webhook — no order was created, no money locked |
| 422 | review_note_required | note shorter than 20 characters | write a real reason |
| 403 | tenant_suspended | some account in the chain is suspended | contact 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
noteminimum in your UI too, not by waiting for422 - [ ] 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 awithdrawal.approvedevent - [ ] 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
- One order, many fills (partial fill) — what happens once the order is
open - Wallet and balances — why the balance drops at creation time
- Settings — what you can tune and what the tier above fixes
- Logs and audit trail — who approved what, and when
- Beneficiary accounts — must be verified before an order can be created
