Create Withdraw
Creates a new withdraw/transfer to a customer bank account. The withdraw will be processed asynchronously.
Endpoint
POST /api/v1/withdraws
Authentication
This endpoint requires HMAC Integration Authentication.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | Yes | Amount as decimal string (e.g., "1000.00" = 10.00 THB) |
currency | string | No | Currency code. Defaults to "THB" |
referenceId | string | Yes | Your unique reference ID (alphanumeric, underscores, hyphens, dots, 1-255 chars) |
destinationType | string | Yes | Destination type: "bank_account" or "wallet" |
customerBankAccountName | string | Yes | Customer's bank account name |
customerBankAccountNumber | string | Yes | Customer's bank account number |
customerBankCode | string | Yes | 3-digit Thai bank code (e.g., "004" for KBANK) |
description | string | No | Withdraw description (max 255 characters) |
metadata | object | No | Additional metadata (key-value pairs) |
idempotencyKey | string | No | Unique key to prevent duplicate withdraws (recommended) |
Integration Context
Your merchantId and integrationId are automatically provided from your integration
credentials. You don't need to include them in the request.
Example Request
import crypto from 'crypto';
const API_KEY = process.env.PAYMENT_API_KEY!;
const API_SECRET = process.env.PAYMENT_API_SECRET!;
async function createWithdraw() {
const method = 'POST';
const path = '/api/v1/withdraws';
const body = JSON.stringify({
amount: '1000.00',
currency: 'THB',
referenceId: `payout-${Date.now()}`,
destinationType: 'bank_account',
customerBankAccountName: 'John Doe',
customerBankAccountNumber: '1234567890',
customerBankCode: '004',
description: 'Payout for order #12345',
metadata: {
order_id: '12345',
},
idempotencyKey: `payout-12345-${Date.now()}`,
});
const timestamp = Date.now().toString();
const message = `${timestamp}.${body}`;
const signature = crypto
.createHmac('sha256', API_SECRET)
.update(message)
.digest('hex');
const response = await fetch(`https://api.example.com${path}`, {
method,
headers: {
'X-API-Key': API_KEY,
'X-Timestamp': timestamp,
'X-Signature': `sha256=${signature}`,
'Content-Type': 'application/json',
},
body,
});
const result = await response.json();
if (result.success) {
console.log('Withdraw created:', result.data.id);
console.log('Status:', result.data.status);
return result.data;
} else {
console.error('Error:', result.error);
throw new Error(result.error.message);
}
}
createWithdraw();Response
Success Response (201 Created)
{
"success": true,
"data": {
"id": "660e8400-e29b-41d4-a716-446655440001",
"amount": "1000.00",
"currency": "THB",
"status": "pending",
"referenceId": "payout-12345",
"destinationType": "bank_account",
"bankAccountNumber": "1234****7890",
"bankAccountName": "John Doe",
"bankCode": "004",
"destinationId": null,
"description": "Payout for order #12345",
"metadata": {
"order_id": "12345"
},
"paidAt": null,
"canceledAt": null,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
}Error Response (400 Bad Request)
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "amount must be a valid decimal number",
"metadata": {
"field": "amount"
}
}
}Combined Withdrawal Limit (400 Bad Request)
Regular withdrawals and manual wallet withdrawals consume one shared daily and monthly wallet
withdrawal limit. awaiting_verification, pending, and processing regular withdrawals reserve
capacity together with paid withdrawals. pending and processing wallet withdrawals reserve
capacity together with completed wallet withdrawals. Failed, canceled, and expired rows do not
consume capacity.
Admission is serialized per wallet, so concurrent requests cannot each pass against the same remaining capacity. The accounting windows use UTC. An exact idempotency replay returns the original withdrawal and does not consume capacity twice.
This is an admission limit, not a guarantee that the final ledger can never move above the limit. A provider-confirmed late success may recover a previously failed or expired row into a successful state after its capacity was released. That recovered amount then appears in usage and blocks new admissions until capacity becomes available or the configured period resets.
{
"success": false,
"error": {
"code": "INVALID_INPUT",
"message": "Monthly withdrawal limit exceeded. Current: 4950000.00, Limit: 5000000.00",
"metadata": {
"monthlyTotal": "4950000.00",
"amount": "100000.00",
"monthlyLimit": "5000000.00"
}
}
}Matching Payout Conflict (409 Conflict)
Within one merchant account, a payout is considered a match when it has the same decimal amount and the payout account holder names are more than 90% similar after removing only formatting such as case, whitespace, and punctuation. Titles and other name content are not removed. A matching payout blocks creation when either condition is true:
- its status is
awaiting_verification,pending, orprocessing, regardless of age; or - it was created less than 15 minutes ago, including a recent
paid,failed, orcanceledpayout.
A terminal match stops blocking after 15 minutes. Different amounts and different merchants are independent. A different bank account number does not bypass the guard when the merchant, amount, and payout name still match.
{
"success": false,
"error": {
"code": "STATE_CONFLICT",
"message": "A matching payout is still active or was created within the last 15 minutes",
"metadata": {
"existingWithdrawId": "8f00bae0-d586-49d2-8355-d03d0351fce8",
"existingWithdrawStatus": "processing",
"reason": "active_payout"
}
}
}For a recent terminal match, reason is recent_payout and metadata also includes
retryAfter, an ISO timestamp for when the 15-minute window ends.
Withdraw Status
After creation, withdraws typically start in pending status and progress to processing as they're handled by the payment provider. awaiting_verification, pending, and processing are active statuses and keep blocking a matching payout regardless of age. An indeterminate provider result remains processing; do not retry it as a new withdrawal until BroPay has reconciled the provider outcome.
Idempotency
Use Idempotency Keys
Always include an idempotencyKey in your requests to prevent duplicate withdraws if you need to
retry a request.
If you make the same request twice with the same caller-provided idempotencyKey, you'll receive the same withdraw object with HTTP 200. This is useful for handling network errors and retries.
If you don't provide an idempotencyKey, the system will auto-generate one, but you won't be able to replay the original request safely. A new or omitted key does not bypass the matching-payout guard and returns HTTP 409 when that guard matches.
Error Codes
| Code | Description |
|---|---|
VALIDATION_ERROR | Invalid request parameters |
INVALID_AMOUNT | Amount must be a valid decimal number |
INVALID_BANK_CODE | Bank code must be a valid 3-digit Thai bank code |
INSUFFICIENT_BALANCE | Merchant account has insufficient balance |
INVALID_INPUT | Combined daily or monthly withdrawal limit would be exceeded |
STATE_CONFLICT | A matching payout is active or less than 15 minutes old |
DUPLICATE_REFERENCE_ID | Reference ID already exists for this merchant |
INVALID_CREDENTIALS | Authentication failed |
Related Endpoints
- List Withdraws - List all withdraws
- Retrieve Withdraw - Get withdraw details
- Cancel Withdraw - Cancel a withdraw
See Also
- Withdraws Overview
- Webhooks - Receive withdraw status updates
- Error Handling