Withdrawals
Withdrawal Overview
How payouts work — gross debited immediately at create, manual approval, and the PENDING→SUCCESS/FAILED/REJECTED state machine
This section explains how your merchant system creates a withdrawal request (payout) out of your wallet through UnknownPay, paying a destination (e.g. your end customer) into a bank account over a Server-to-Server (S2S) connection.
A payout does not move money out instantly. Every withdrawal request must pass manual approval by the UnknownPay team before the system sends the real pay-out instruction to the bank. Your system must therefore wait for the
withdrawal.success webhook to confirm the payout succeeded — never treat the request as successful just because creation returned 201 Created.Every money field on the wire is always a baht string with 2 decimals, e.g.
"100.50" (not a number, not satang) — in both request and response. Fields such as amount, fee, and net_payout must always be sent/read as baht strings.Diagram coming soon — sequence diagram — merchant → POST /v1/withdrawals → UnknownPay (PENDING) → team approve → bank pays out → withdrawal.success webhook back to merchant
Endpoints
| Operation | Method | Path |
|---|---|---|
| Create a withdrawal | POST | /v1/withdrawals |
| Get one withdrawal (single) | GET | /v1/withdrawals/:id |
| List your own withdrawals | GET | /v1/withdrawals |
Every endpoint lives on the /v1 gateway surface and requires S2S authentication (API key + HMAC) just like the other merchant endpoints — see Authentication.
State machine
The live state flow:
PENDING ──(team approves as a batch)──▶ PROCESSING ──▶ [IN_PROGRESS] ──▶ SUCCESS
│ │ │
│ └──────────────┬───────────────┘
│ ▼
└──(team rejects)──▶ REJECTED FAILED
(bank/bot reports failure → refund)
| Status | Meaning | What the merchant should do |
|---|---|---|
PENDING | Request created, gross already debited from the wallet, awaiting team approval | Wait — not paid out yet |
APPROVED | Approved (mainly a sandbox/test resting state — live skips it) | Wait |
PROCESSING | Team approved + the pay instruction was sent to the payment system/bank (money is going out) | Wait — can no longer be cancelled |
IN_PROGRESS | In progress at the bank (intermediate state, may be skipped) | Wait |
SUCCESS | Payout succeeded (terminal) | ✅ Treat as success — withdrawal.success webhook |
FAILED | Payout failed (terminal) — system refunds the gross back to the wallet | ❌ withdrawal.failed + withdrawal.refunded webhooks |
REJECTED | Team rejected the request (terminal) — system refunds the gross back to the wallet | ❌ withdrawal.refunded webhook |
Technical notes that match the code:
- The live approval path goes straight from
PENDING→PROCESSING(approval happens as a "batch") —APPROVEDis not a resting state in live (it is used mainly in sandbox/test mode). IN_PROGRESSis optional — the bank may reportSUCCESSdirectly fromPROCESSING. Do not design your system to requireIN_PROGRESSas a mandatory condition.SUCCESS,FAILED, andREJECTEDare terminal (they never change again).
The statuses the merchant should "wait for the webhook" on: treat a payout as truly finished only when you receive a terminal-status webhook, namely:
withdrawal.success(paid out successfully — money has really left)withdrawal.failed(payout failed — money refunded to the wallet)withdrawal.refunded(money refunded to the wallet; fires for bothREJECTEDandFAILED)
withdrawal.rejected (team rejected) always comes paired with withdrawal.refunded.Pending confirmation — confirm the exact event names emitted by the live webhook side (code confirms: withdrawal.success / withdrawal.failed / withdrawal.refunded / withdrawal.rejected) and document the real webhook payload
Diagram coming soon — withdrawal state diagram (PENDING → PROCESSING → IN_PROGRESS → SUCCESS / FAILED / REJECTED)
The exact payload of each event and how to verify the signature are covered in the Webhooks section.
Debit at create + refund (matters for reconciliation)
- Debit at create (debit-at-request): when the request is created successfully (
201, statusPENDING), the system immediately debits gross =amount+feefrom your wallet — that amount is "reserved" from this moment, not at approval. - Rejected → refund: if the team rejects the request (while still
PENDINGorAPPROVED), the system refunds the full gross back to the wallet and sends thewithdrawal.refundedwebhook (statusREJECTED). - Failed → refund: if the payout was attempted and failed (
FAILED), the system likewise refunds the gross back to the wallet (withdrawal.failed+withdrawal.refundedwebhooks). - Once
PROCESSING, it cannot be cancelled: once approved and dispatched (PROCESSING), the team can no longer reject it (it returns409) — from this point onlySUCCESSorFAILEDwill settle the money.
Effect on your merchant-side logic: do not deduct your own internal balance again. The debit happens on the UnknownPay side. Rely on the balance endpoint for the wallet balance, and rely on the terminal webhook as the deciding factor for the final outcome.
Next steps
- Create a withdrawal —
POST /v1/withdrawals - Get & list withdrawals —
GET /v1/withdrawals/:idandGET /v1/withdrawals
