Withdrawals
Create a Withdrawal
POST /v1/withdrawals — create a payout request; gross (amount + fee) is debited immediately and the request starts in PENDING awaiting manual approval
POST /v1/withdrawals
Creates a payout request out of your wallet. The request is HMAC-signed (S2S) and requires an Idempotency-Key. On success it returns 201 Created with status PENDING — the money is not out yet; it awaits manual approval.
Never trust
201 PENDING as a completed payout. Creation only reserves the funds and queues the request for approval — wait for the terminal withdrawal.success webhook before treating the payout as done. See Withdrawal Overview.Headers
| Header | Required | Description |
|---|---|---|
Idempotency-Key | Yes | A merchant-generated key (one per request) to prevent duplicate creation — see Idempotency. If omitted you get 400 IDEMPOTENCY_KEY_REQUIRED. |
Content-Type | Yes | application/json |
X-Api-Key / X-Signature / X-Timestamp | Yes | S2S authentication (HMAC) headers — see Authentication. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amount | string (baht) | Yes | The amount the destination will "actually receive" (net), as a baht string, e.g. "500.00". The fee is added on top of this amount and debited from your wallet. |
currency | string | No (default "THB") | Currency. Currently supports "THB". If omitted or empty, defaults to "THB". |
receiver_bank_provider | string | Yes | Destination bank code (bank code alias), e.g. "SCB", "KBANK", "BBL", "KTB" — not the 3-digit number. Valid codes come from GET /v1/banks (field bank_code). Lowercase / leading-trailing whitespace is normalized. Unknown code → 422 INVALID_BANK. |
receiver_bank_account_name | string | Yes | Destination account name. If blank → 422 (destination must have bank/account_no/name complete). |
receiver_bank_account_number | string | Yes | Destination account number. If blank → 422. |
kind | string | No (default "customer") | Must be "customer" only (see the constraint below). Blank = customer; any other value → 422 INVALID_KIND. |
additional | object | No | Extra optional data. |
additional.description | string | No | Merchant description. |
additional.reference_user_id | string | No | The merchant-side reference id of the "destination customer" — stored and echoed back in the response/list as reference_user_id. |
kind=customer constraint (important): a merchant API key can only create customer payouts (paying a destination account the merchant specifies). Sending any other kind (e.g. settlement, merchant, …) returns 422 INVALID_KIND — settlement is an internal system route only and cannot be called by a merchant over S2S.Destination structure: internally the destination is stored as an object
{bank, account_no, name}, but in the request the merchant sends three separate fields (receiver_bank_provider, receiver_bank_account_number, receiver_bank_account_name). The response returns destination back as that object (full plaintext, not masked).Amount / balance rules
amountmust be positive and within the system's configured min/max (out of range →422 INVALID_AMOUNT).- The system debits funds immediately at create (debit-at-request): the amount taken from the wallet is gross =
amount+fee. If the gross exceeds your "available" balance (which already excludes prior PENDING/approved requests) →422 INSUFFICIENT_BALANCE(no row is created).
Pending confirmation — the real withdrawal min/max in production (the code default is unlimited until configured)
Example request
curl -X POST "https://api.unkpay.co/v1/withdrawals" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2b7a-3e44-4d18-9b21-7c0e6a1d2f55" \
-d '{
"amount": "500.00",
"currency": "THB",
"receiver_bank_provider": "SCB",
"receiver_bank_account_name": "สมชาย ใจดี",
"receiver_bank_account_number": "1234567890",
"kind": "customer",
"additional": {
"description": "payout order #A-1042",
"reference_user_id": "cust-7"
}
}'
Pending confirmation — add the S2S authentication headers (API key + HMAC signature)
Response body — 201 Created
| Field | Type | Description |
|---|---|---|
id | string (uuid) | The withdrawal id — used with GET /v1/withdrawals/:id and referenced in the webhook. |
amount | string (baht) | The amount the destination will receive. |
fee | string (baht) | The fee debited on top of the wallet amount (frozen at creation). |
net_payout | string (baht) | The net amount paid to the destination (for customer = equal to amount). |
currency | string | "THB" |
receiver_bank_provider | string | Destination bank code. |
destination | object | { "bank": "...", "account_no": "...", "name": "..." } — destination in full plaintext. |
kind | string | "customer" |
status | string | Initial status = "PENDING" (awaiting approval — not paid out yet). |
This response does not include
gross_debit (the real gross debited = amount + fee) — to check the amount taken from the wallet, compute amount + fee or check the balance via the balance endpoint.{
"id": "3b9c1e54-2a77-4f0d-bb1e-8a6d4c2e91aa",
"amount": "500.00",
"fee": "9.00",
"net_payout": "500.00",
"currency": "THB",
"receiver_bank_provider": "SCB",
"destination": {
"bank": "SCB",
"account_no": "1234567890",
"name": "สมชาย ใจดี"
},
"kind": "customer",
"status": "PENDING"
}
::placeholder-note{value="the fee value "9.00" in the example is illustrative (based on a bps fee structure) — check the merchant's real withdrawal fee rate in the system"} ::
Idempotency-Key
POST /v1/withdrawals is a money-creating endpoint, so the Idempotency-Key header is required (shared mechanism: Idempotency).
- The value is a unique string per request — the system enforces no format, only that it is non-empty. UUID v4 recommended, e.g.
9f1c2e7a-3b4d-4f8a-9c10-2b6d5e7f8a90. - If omitted →
400 IDEMPOTENCY_KEY_REQUIRED. - If you retry with the same key + same body → the system returns the original response (no duplicate request created) with the header
Idempotent-Replay: true. - If you use the same key but a different body →
422 IDEMPOTENCY_KEY_MISMATCH. - Key TTL = 24 hours.
- Keys are namespaced by mode (live/test) + merchant — live and test keys never collide.
Set your merchant-side retry policy to reuse the same Idempotency-Key when retrying the same request (e.g. on a timeout / 5xx) to prevent a double payout.
Error codes
| HTTP | code | Cause |
|---|---|---|
400 | IDEMPOTENCY_KEY_REQUIRED | Idempotency-Key header not sent |
422 | IDEMPOTENCY_KEY_MISMATCH | Same Idempotency-Key used with a different body |
422 | INVALID_AMOUNT | amount malformed / not positive / out of min-max range |
422 | INVALID_CURRENCY | currency not supported |
422 | INVALID_BANK | receiver_bank_provider is not a known bank code |
422 | INVALID_KIND | kind is not customer |
422 | INSUFFICIENT_BALANCE | Gross (amount+fee) exceeds the available wallet balance |
422 | VALIDATION | destination incomplete (must have bank / account_no / name) |
See the Error Envelope & Codes for the cross-cutting error format.
