This chapter is the canonical reference for paying funds out through the Payment Gateway merchant API. It covers the POST /withdrawal endpoint, the rule-based approval engine, the channel-versus-account routing branches, the state machine, and the pitfalls that show up in production.
Concepts and terminology
| Term | Meaning |
|---|---|
| Withdrawal order | A WithdrawalOrder row that represents a single payout intent from your merchant balance. Identified by order_no (assigned by the gateway) and merchant_order_no (assigned by you). |
| Beneficiary | The end recipient of the payout. Defined by beneficiary_name, beneficiary_account, and an optional beneficiary_bank_code / beneficiary_bank_branch. The account number is AES-encrypted at rest; the API takes it as plaintext. |
| Merchant balance | The ledger entry that backs every payout. Withdrawals freeze amount + fee on creation, then either confirm the freeze on COMPLETED or release it back to available on REJECTED / FAILED. |
| Freeze accounting | The platform splits merchant balance into available and frozen buckets per currency. freeze_balance moves amount + fee from available to frozen. confirm_freeze deducts from frozen (funds gone). release_freeze moves back to available (funds returned). All three are versioned, so concurrent updates surface as OPTIMISTIC_LOCK_ERROR. |
| Payout method | One of BANK_API, MANUAL, THIRD_PARTY. Set by the gateway based on how the payout will actually leave the system. The merchant does not pick this. |
| Approval | A rule-based gate between PENDING and APPROVED. Some orders auto-approve, some require an operator. See Approval rules. |
| Routing | The decision of where to send the payout. Two targets are possible: a third-party payout channel (THIRD_PARTY) or a bank account managed by the platform (MANUAL or BANK_API). |
| Transfer task | An internal job that drives an account-routed payout to completion. Created automatically when the routed account is in AUTO transfer mode. Merchants do not see this directly. |
| Optimistic lock | The concurrency-control strategy used when the platform updates merchant balance and order state. A concurrent update can surface as OPTIMISTIC_LOCK_ERROR. See Pitfalls. |
| Callback | The signed HTTP POST to callback_url when the order reaches a terminal state. The same signing scheme as outbound requests. See Callbacks. |
Querying balances
Before posting a withdrawal you typically want to know how much you can withdraw. The merchant API exposes a single endpoint that returns balances across every currency your account is enabled for.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/merchant/balance | List all balances for the authenticated merchant. |
The endpoint requires the standard MD5 signature headers. It has no body and no query parameters, so the signature is computed over the timestamp alone.
Request
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL="https://pre-api.open-pay.co"
TIMESTAMP=$(date +%s)
PARAMS="timestamp=${TIMESTAMP}"
SIGNATURE=$(python sign.py --api-key "$API_KEY" --params "$PARAMS")
curl -X GET "${BASE_URL}/api/v1/merchant/balance" \
-H "X-API-Key: ${API_KEY}" \
-H "X-Timestamp: ${TIMESTAMP}" \
-H "X-Signature: ${SIGNATURE}"Response
A successful request returns HTTP 200 OK with one entry per currency:
{
"merchant_id": "01J...",
"items": [
{
"id": "01J...",
"merchant_id": "01J...",
"currency_code": "THB",
"available_balance": "12500.00",
"frozen_balance": "300.00",
"total_balance": "12800.00",
"version": 47,
"created_at": "2026-04-01T08:00:00Z",
"updated_at": "2026-05-20T08:00:00Z"
},
{
"id": "01J...",
"merchant_id": "01J...",
"currency_code": "VND",
"available_balance": "5000000.00",
"frozen_balance": "0.00",
"total_balance": "5000000.00",
"version": 12,
"created_at": "2026-04-01T08:00:00Z",
"updated_at": "2026-05-18T08:00:00Z"
}
]
}Fields
| Field | Type | Meaning |
|---|---|---|
currency_code | string | ISO 4217. One entry per currency your account is enabled for. |
available_balance | decimal string | Funds you can withdraw or use to create new withdrawals. |
frozen_balance | decimal string | Funds reserved against in-flight withdrawals (PENDING / APPROVED / PROCESSING). Returns to available_balance if the withdrawal fails, is rejected, or is cancelled. |
total_balance | decimal string | available_balance + frozen_balance. Provided for convenience. |
version | integer | Optimistic-lock version of the balance row. Increments on every balance change. Useful for detecting concurrent modifications across your own reconciliation jobs. |
created_at / updated_at | timestamp | When the balance row was first seeded and last modified. |
Common uses
- Pre-flight check before withdrawal. Call this endpoint, confirm
available_balance >= amount + feefor the target currency, then create the withdrawal. Skipping the check is safe — the gateway rejects the withdrawal withINSUFFICIENT_BALANCEif funds are short — but giving the user feedback in your own UI is friendlier. - Dashboard rendering. Poll periodically to show current balances per currency.
- Reconciliation. Pair with deposit / withdrawal callback handlers to detect drift between your ledger and ours;
versionincrements are a cheap way to know "something moved".
API surface
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/merchant/orders/withdrawal | Create a withdrawal order. |
GET | /api/v1/merchant/query/withdrawal/{order_no} | Query a withdrawal order by gateway order_no. |
GET | /api/v1/merchant/query/withdrawal?merchant_order_no=... | Query a withdrawal order by your merchant_order_no. |
All endpoints require the standard MD5 signature headers (lowercase-hex MD5 over sorted params + API key). See Authentication.
WithdrawalOrderCreate (request body)
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_order_no | string (1-64) | yes | Your unique reference for this payout. Must be unique per merchant. |
amount | decimal | yes | Must be greater than 0. Subject to currency precision validation. |
currency_code | string (3) | yes | ISO 4217 code. Must be a currency your merchant account is enabled for. |
beneficiary_name | string (1-100) | yes | Recipient legal name as printed on the bank account. Stored encrypted. |
beneficiary_account | string (1-100) | yes | Recipient bank account number. Stored encrypted. Format is country and channel specific; see Pitfalls. |
beneficiary_bank_code | string (≤20) | no | Bank identifier. Required by most channels; the gateway resolves it to the channel-specific bank code via the provider bank mapping table. |
beneficiary_bank_branch | string (≤200) | no | Branch code or name. Required by some channels (notably Japanese and Indonesian rails). |
callback_url | string (1-500) | yes | HTTPS URL we call on terminal state changes. |
extra_data | string | no | Opaque string echoed back in the callback. |
The schema is defined in app/schemas/order.py as WithdrawalOrderCreate. The model is WithdrawalOrder in app/models/order.py.
WithdrawalOrderResponse (HTTP 201 and query responses)
| Field | Notes |
|---|---|
id | Internal UUID. Use order_no for queries. |
order_no | Gateway-assigned identifier. Format: W + 14-digit timestamp + 8 hex characters. |
merchant_order_no | Echoed from the request. |
merchant_id, account_id | The merchant and the bank account selected for payout. account_id is set even for channel-routed orders; it points at the channel-bound bookkeeping account. |
amount | The amount you requested. |
fee | Calculated from MerchantFeeConfig. Added on top — your balance is debited by amount + fee. |
actual_amount | The amount sent to the beneficiary. Equals amount because fee is 外加 (added on top), not deducted from amount. |
currency_code | Echoed from the request. |
payout_method | THIRD_PARTY, MANUAL, or BANK_API. Set by the gateway based on the routing decision. |
status | WithdrawalOrderStatus. See State transitions. |
is_auto_approved | true if the approval engine cleared the order, or if it was routed to a channel (channel-routed orders skip manual approval). |
approved_at, approved_by, approval_remarks | Approval audit trail. approved_by is empty for auto-approved orders. |
processed_at | Set when the order enters PROCESSING. |
completed_at | Set when the order enters COMPLETED. |
bank_reference | External transaction reference reported by the bank or the channel. Populated on COMPLETED. |
callback_status, callback_count | Callback delivery state. See Callbacks. |
beneficiary_name, beneficiary_account, beneficiary_bank_code, beneficiary_bank_name, beneficiary_bank_branch | Decrypted on read. beneficiary_bank_name is resolved from beneficiary_bank_code. |
needs_manual_intervention, manual_reason | Set when an automated payout flow has stalled and an operator must take over. |
channel_id, channel_name, channel_order_no | Populated for channel-routed orders. |
estimated_channel_cost, estimated_profit, actual_channel_cost, actual_profit | Profit-tracking fields. Not relevant for most integrators. |
Full schema definitions are in the API reference.
Flow
Failure branches:
- Approval denial. Operator rejects in admin UI. Backend calls
reject_order: frozen balance and reserved quota are released to the merchant; status movesPENDING → REJECTED. The endpoint that triggers this is admin-only; merchants observe the change only via query or callback (callback may or may not fire depending on configuration). - Channel reports failure. Channel callback arrives with a failure status. Backend calls
fail_order: frozen balance and reserved quota are released, status movesPROCESSING → FAILED. The signed merchant callback fires withstatus=failed. - Transfer task cancellation. If the platform cancels the underlying transfer task (rare; operator action), the withdrawal order moves
PROCESSING → CANCELLED. The reserved balance is left frozen pending operator reconciliation (this is a known follow-up item in the platform). - Channel creation error at submit time. If the channel adapter raises
ChannelAPIErrorduringPOST /withdrawal, the order row is deleted, the freeze is released, and the endpoint responds with HTTP 400BUSINESS_ERROR. No order is created and no callback fires.
How the gateway chooses between channel and account
The routing engine (ChannelRoutingService.find_target_for_order) consults merchant_channel_rule rows in this order:
- Explicit rule for this merchant + currency + amount band. If a matching rule points at a
PaymentChannel, the order is channel-routed. If it points at anAccount, that specific account is used (and quota is checked). - No rule, or rule did not match. Fallback to the first available payout account for the currency with sufficient daily quota, picked by
AccountService.find_available_withdrawal_account. - No account either. The create call fails with
BusinessErrorand messageNo available payout account for <CCY> with sufficient quota. The merchant balance is never frozen in this case.
The merchant cannot pin a channel or account from the merchant API. If you need predictable routing for a specific use case, ask platform operations to install a merchant_channel_rule for your merchant.
Approval rules: when PENDING becomes APPROVED
Approval behaviour depends on the routing target. The platform is rule-based, not manual-only or auto-only.
Channel-routed orders
If the routing engine selects a PaymentChannel for the order, the platform sets is_auto_approved=true and approved_at=now synchronously, then submits to the channel adapter. The order skips the operator queue entirely and starts in PROCESSING (or PENDING momentarily, until the adapter call returns successfully). Channel-routed withdrawals never sit in the manual approval queue.
Account-routed orders
If the routing engine selects an Account (or no rule matches and the fallback picks an account by quota), the platform consults ApprovalConfig. The first match wins:
- Per-merchant config (
ApprovalConfig.merchant_id = <your merchant id>,is_active = true). - Global config (
ApprovalConfig.merchant_id IS NULL,is_active = true).
If no active config exists, the order requires manual approval (status = PENDING).
If an active config exists, the auto-approval engine checks, in order:
auto_approve_enabledflag. Iffalse, manual approval is required regardless of other rules.auto_approve_max_amountthreshold. If the order amount exceeds this threshold, manual approval is required.ApprovalWhitelistlookup. If a SHA-256 hash ofbeneficiary_accountmatches an active whitelist entry of typebeneficiary_account, the order is auto-approved immediately, bypassing the daily limit checks below.- Daily count limit. If today's auto-approved order count for this merchant already meets
auto_approve_daily_count, manual approval is required. - Daily amount limit. If today's auto-approved order amount plus this order's
amountexceedsauto_approve_daily_limit, manual approval is required.
If all checks pass, the order is auto-approved (is_auto_approved=true, status=APPROVED, approved_at=now).
Whether an auto-approved order moves to PROCESSING immediately depends on the routed account's transfer_mode:
transfer_mode = AUTO: ATransferTaskis created automatically. The order movesAPPROVED → PROCESSINGandprocessed_atis set.transfer_mode = MANUAL: The order sits atAPPROVEDuntil an operator processes it.
The implementation is in WithdrawalService._check_auto_approval and WithdrawalService.create in app/services/withdrawal.py. Treat the rules as platform configuration; do not encode merchant-side logic that assumes a specific approval mode.
Worked examples
The snippets below assume you have generated X-Timestamp and X-Signature headers as documented in Authentication. Sign the JSON body, not the URL.
#!/usr/bin/env bash
# MD5 signature for Payment Gateway merchant API (bash reference).
#
# Usage:
# ./sign.sh --api-key <key> --params 'k1=v1&k2=v2&...'
set -euo pipefail
API_KEY=""
PARAMS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2 ;;
--params) PARAMS="$2"; shift 2 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# Sort key=value pairs ASCII order (C locale), drop empty trailing field
SORTED=$(printf '%s' "$PARAMS" | tr '&' '\n' | LC_ALL=C sort | grep -v '^$' | tr '\n' '&' | sed 's/&$//')
SIGN_STRING="${SORTED}${API_KEY}"
# MD5: md5sum on Linux/CI, md5 on macOS
if command -v md5sum >/dev/null 2>&1; then
printf '%s' "$SIGN_STRING" | md5sum | cut -d' ' -f1
else
printf '%s' "$SIGN_STRING" | md5
fi#!/usr/bin/env python3
"""Reference implementation: MD5 signature for Payment Gateway merchant API.
Usage:
python sign.py --api-key <key> --params 'k1=v1&k2=v2&...'
Algorithm:
1. Parse params into a dict.
2. Sort keys alphabetically (ASCII order).
3. Build query string 'k=v&k=v', skipping None values.
4. Append api_key directly (no '&').
5. MD5 the UTF-8 bytes; output lowercase hex.
"""
import argparse
import hashlib
import sys
from urllib.parse import parse_qsl
def create_signature(params: dict[str, str], api_key: str) -> str:
sorted_items = sorted(params.items())
query_string = "&".join(f"{k}={v}" for k, v in sorted_items if v is not None)
return hashlib.md5((query_string + api_key).encode("utf-8")).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--params", required=True)
args = parser.parse_args()
params = dict(parse_qsl(args.params, keep_blank_values=True))
print(create_signature(params, args.api_key))
return 0
if __name__ == "__main__":
sys.exit(main())<?php
/**
* MD5 signature for Payment Gateway merchant API (PHP reference).
*
* Usage:
* php sign.php --api-key=<key> --params='k1=v1&k2=v2&...'
*
* Note: getopt() requires --flag=value form (no space between flag and value).
*/
$opts = getopt('', ['api-key:', 'params:']);
if (!isset($opts['api-key']) || !isset($opts['params'])) {
fwrite(STDERR, "Usage: php sign.php --api-key=<key> --params='k=v&...'\n");
exit(2);
}
parse_str($opts['params'], $params);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) {
if ($v === null) continue;
$parts[] = "$k=$v";
}
echo md5(implode('&', $parts) . $opts['api-key']);#!/usr/bin/env node
/**
* MD5 signature for Payment Gateway merchant API (Node.js / TypeScript reference).
*
* Usage:
* tsx sign.ts --api-key <key> --params 'k1=v1&k2=v2&...'
*/
import { createHash } from "crypto";
function parseArgs(argv: string[]): { apiKey: string; params: string } {
let apiKey = "", params = "";
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--api-key") apiKey = argv[++i];
else if (argv[i] === "--params") params = argv[++i];
}
if (!apiKey || !params) {
process.stderr.write("Usage: sign.ts --api-key <key> --params 'k=v&...'\n");
process.exit(2);
}
return { apiKey, params };
}
function createSignature(paramsQuery: string, apiKey: string): string {
const parsed = new Map<string, string>();
for (const pair of paramsQuery.split("&")) {
if (!pair) continue;
const eq = pair.indexOf("=");
const k = eq >= 0 ? pair.slice(0, eq) : pair;
const v = eq >= 0 ? pair.slice(eq + 1) : "";
parsed.set(k, v);
}
const sorted = [...parsed.entries()].sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0
);
const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
return createHash("md5").update(query + apiKey, "utf8").digest("hex");
}
const { apiKey, params } = parseArgs(process.argv);
process.stdout.write(createSignature(params, apiKey));Example 1: Submit a Thai bank withdrawal in THB
Pay 5,000 THB out to a Siam Commercial Bank account.
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/withdrawal \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-d '{
"merchant_order_no": "WD-20260520-0001",
"amount": "5000.00",
"currency_code": "THB",
"beneficiary_name": "SOMSAK CHAIYAPORN",
"beneficiary_account": "1234567890",
"beneficiary_bank_code": "SCB",
"callback_url": "https://merchant.example.com/api/pg/callback",
"extra_data": "payroll-2026-05"
}'Response, channel-routed and auto-approved (HTTP 201):
{
"id": "0a3f5c2e-1c0b-4a3b-9c2d-1f4e7a8b9c0d",
"order_no": "W202605200815120ABCDEF12",
"merchant_order_no": "WD-20260520-0001",
"merchant_id": "c1234567-89ab-cdef-0123-456789abcdef",
"account_id": "d2345678-9abc-def0-1234-56789abcdef0",
"amount": "5000.00",
"fee": "20.00",
"actual_amount": "5000.00",
"currency_code": "THB",
"payout_method": "third_party",
"status": "processing",
"is_auto_approved": true,
"approved_at": "2026-05-20T08:15:12Z",
"approved_by": null,
"processed_at": "2026-05-20T08:15:13Z",
"completed_at": null,
"bank_reference": null,
"callback_status": null,
"callback_count": 0,
"created_at": "2026-05-20T08:15:12Z",
"updated_at": "2026-05-20T08:15:13Z"
}Response, account-routed and requiring manual approval, abbreviated:
{
"order_no": "W202605200815120ABCDEF12",
"status": "pending",
"is_auto_approved": false,
"payout_method": "manual",
"approved_at": null
}Persist order_no against your merchant_order_no. You will need it for queries and callback reconciliation. Do not act on actual_amount until you have received the COMPLETED callback; the value is set at order creation but the funds have not moved yet.
Example 2: Query a withdrawal after creation
Query by gateway order_no:
curl https://pre-api.open-pay.co/api/v1/merchant/query/withdrawal/W202605200815120ABCDEF12 \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"Or by your merchant_order_no:
curl "https://pre-api.open-pay.co/api/v1/merchant/query/withdrawal?merchant_order_no=WD-20260520-0001" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"Both return the full WithdrawalOrderResponse schema, including status, bank_reference, completed_at, and callback_status. Use the response to reconcile against your internal record. The GET endpoints are safe to call repeatedly; they only read state.
Example 3: Handle INSUFFICIENT_MERCHANT_BALANCE
Withdrawals freeze amount + fee on creation. If your merchant balance for that currency is below that total, the create call fails:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"success": false,
"code": "INSUFFICIENT_MERCHANT_BALANCE",
"message": "Insufficient merchant balance",
"data": null
}See Errors for the standard error envelope and code definitions.
Recommended recovery:
- Do not retry the same withdrawal blindly.
- Top up your merchant balance, or wait for incoming deposits to settle.
- Query your balance via the platform balance endpoint (see API reference) before resubmitting.
- If you resubmit, reuse the same
merchant_order_no. The duplicate check then protects you if the original landed silently.
Example 4 (continued): What the callback looks like
When the order reaches COMPLETED, the gateway POSTs to your callback_url with a signed body. The fields available on the callback include order_no, merchant_order_no, status, amount, actual_amount, fee, currency_code, bank_reference, completed_at, and extra_data. The exact wire shape and signing scheme are documented in Callbacks.
A successful payout callback for the order above (abbreviated):
{
"order_no": "W202605200815120ABCDEF12",
"merchant_order_no": "WD-20260520-0001",
"status": "completed",
"amount": "5000.00",
"actual_amount": "5000.00",
"fee": "20.00",
"currency_code": "THB",
"bank_reference": "FT26052000001234",
"completed_at": "2026-05-20T08:42:11Z",
"extra_data": "payroll-2026-05"
}Your handler should:
- Verify the signature. Reject unsigned or mis-signed requests.
- Look up the order by
order_no(preferred) ormerchant_order_no. - If the order is already in your local terminal state, reply
200 OKand return — idempotent. - Otherwise, update your ledger:
amount + feeleaves your "in-flight" bucket;actual_amountreaches the recipient. - Reply
200 OK(body containingsuccessis also accepted by the gateway's retry policy).
A FAILED callback uses status: "failed" and omits bank_reference and completed_at. Your handler should release amount + fee back to your available bucket. See Callbacks for the full envelope and retry policy.
Example 5: Handle OPTIMISTIC_LOCK_ERROR with retries
The platform uses optimistic locking on merchant balance and on the order row. Under concurrent load, a single submission may collide with a parallel update and the API returns:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"success": false,
"code": "OPTIMISTIC_LOCK_ERROR",
"message": "Concurrent update conflict, please retry",
"data": null
}The error code is OPTIMISTIC_LOCK_ERROR. This is transient. The recommended client pattern is bounded retry with jitter.
import random
import time
import requests
def submit_withdrawal(payload, headers, max_attempts=3):
last_response = None
for attempt in range(max_attempts):
resp = requests.post(
"https://pre-api.open-pay.co/api/v1/merchant/orders/withdrawal",
json=payload,
headers=headers,
timeout=10,
)
last_response = resp
if resp.status_code == 201:
return resp.json()
if resp.status_code == 400 and "Concurrent update conflict" in resp.text:
# Back off with jitter: 200ms, 500ms, 1200ms (capped)
backoff = min(0.2 * (2 ** attempt), 1.2) + random.uniform(0, 0.2)
time.sleep(backoff)
continue
# Any other 4xx/5xx: stop and surface
resp.raise_for_status()
raise RuntimeError(
f"Withdrawal submission failed after {max_attempts} attempts: "
f"{last_response.status_code} {last_response.text}"
)Cap the retry budget at three attempts. Do not retry forever — a persistent lock conflict usually indicates a back-end issue, not a transient race. After exhausting retries, query the order by merchant_order_no: it may have been created silently on the first attempt. See OPTIMISTIC_LOCK_ERROR.
Where optimistic locking actually fires
The most common source of OPTIMISTIC_LOCK_ERROR on withdrawals is the merchant balance update: freeze_balance reads the balance row, modifies it, and writes it back with a version check. A parallel withdrawal or a parallel deposit settlement on the same merchant balance row can fail the check. Less commonly, the channel adapter (notably TGCPay) uses an upstream optimistic lock and the gateway propagates the error. The retry pattern handles both cases identically.
State transitions
The enum values are defined in app/models/order.py as WithdrawalOrderStatus. The wire values are lowercase: pending, approved, processing, completed, rejected, failed, cancelled.
| From → To | Trigger | Merchant observes |
|---|---|---|
[*] → PENDING | POST /withdrawal succeeds for an account-routed order requiring manual approval. Merchant balance freeze is held; withdrawal quota is reserved on the account. | 201 with status=pending. No callback yet. |
[*] → APPROVED (skipping PENDING) | POST /withdrawal succeeds for an account-routed order that auto-approves (whitelist match, under threshold and limits) on a MANUAL transfer-mode account. | 201 with status=approved, is_auto_approved=true. No callback yet. |
[*] → PROCESSING (skipping PENDING and APPROVED) | POST /withdrawal succeeds for a channel-routed order, or for an auto-approved account-routed order on an AUTO transfer-mode account. | 201 with status=processing. No callback yet. |
PENDING → APPROVED | Operator approves in admin UI (approve_order), or the auto-approval engine runs a delayed evaluation (rare). | Visible via query. No callback. |
PENDING → REJECTED | Operator denies (reject_order). Frozen balance and reserved quota are released. | Visible via query. Callback may fire depending on configuration. |
PENDING → CANCELLED | An associated TransferTask is cancelled. | Visible via query. Frozen balance currently remains frozen; operator reconciles. |
APPROVED → PROCESSING | The platform creates a TransferTask (account-routed, AUTO mode) or the channel adapter accepts the submission. processed_at is set. | Visible via query. No callback. |
APPROVED → FAILED | A failure surfaces before the order starts processing (rare). fail_order releases freeze and quota. | Callback fires with status=failed. |
PROCESSING → COMPLETED | Channel callback reports success, or operator marks the order completed. Backend confirms the balance freeze (funds are gone) and sets completed_at, bank_reference. | Signed callback with status=completed. |
PROCESSING → FAILED | Channel callback reports failure, or operator marks the order failed. Backend releases freeze and quota. | Signed callback with status=failed. |
PROCESSING → CANCELLED | Transfer task cancelled mid-process. | Visible via query. Frozen balance currently remains frozen; operator reconciles. |
Terminal states: COMPLETED, REJECTED, FAILED, CANCELLED. None of them is reversible by the merchant API. To "retry" a failed withdrawal, create a new order with a new merchant_order_no.
Operator-driven transitions
Some transitions originate from the admin UI rather than from the merchant API or a channel callback. They are listed here for transparency so your reconciler does not assume otherwise:
approve_ordermovesPENDING → APPROVED. Setsapproved_byto the operator user id,approved_atto the approval time, and may carryapproval_remarks.reject_ordermovesPENDING → REJECTED. Releases freeze and reservation. Setsapproved_byto the rejecting operator (the same field is reused) andapproval_remarksto the reason.process_ordermovesAPPROVED → PROCESSINGfor orders that were not auto-routed. Used when an operator picks up a manual payout. May overwritepayout_method(operator picksBANK_APIorMANUAL) and may seedbank_reference.complete_ordermovesPROCESSING → COMPLETED. Used when an operator confirms a manual payout, or by the channel-callback handler. Confirms the freeze and may seedbank_reference.fail_ordermovesAPPROVED → FAILEDorPROCESSING → FAILED. Releases freeze and reservation. Used by the channel-callback handler on failure or by an operator marking a stalled payout failed.
The merchant API surface does not expose these directly. Merchants observe the resulting status via query and via the terminal-state signed callback.
Pitfalls
Reusing
merchant_order_noacross attempts. The backend rejects duplicates withHTTP 409andCONFLICTcode in the standard error envelope. Always use a freshmerchant_order_nofor each new logical payout intent. For retries of the same intent, reuse the samemerchant_order_no— the duplicate check then lets you safely query the existing order. SeeCONFLICT.beneficiary_accountformat mismatches. The platform passes the value through to the payout channel mostly as-is (after AES encryption at rest, decryption at submit time). Channel-specific rules apply:- Thai bank accounts: digits only, no dashes or spaces. Leading zeros are significant for some banks.
- Vietnamese bank accounts: digits only, but some channels accept length up to 19. Do not pad.
- Indonesian bank accounts: 10-16 digits. Some channels require
beneficiary_bank_brancheven when the field looks optional in the schema. Strip user-entered whitespace before submitting. Validate at the merchant side; the API will not.
Currency vs channel mismatch. If you submit a
currency_codethat no active payout channel and no active account supports, the create call fails withBUSINESS_ERRORand messageNo available payout account for <CCY> with sufficient quota(or a routing error from the channel layer). The merchant balance is never frozen in this case. SeeBUSINESS_ERROR.Treating
REJECTEDas retryable. Rejection is terminal. Reusing the samemerchant_order_noafter rejection trips the duplicate check. Create a new order with a newmerchant_order_noand address the rejection reason (visible inapproval_remarksvia query).Logging
beneficiary_accountin plaintext. The platform encrypts it at rest with AES. Your side must do the same: mask all but the last 4 digits in logs, and never store the full account number in plaintext outside your encrypted store. The same rule applies tobeneficiary_namefor jurisdictions with PII rules.OPTIMISTIC_LOCK_ERRORwithout retry. Treat it as transient. Retry up to 3 times with exponential jitter (see Example 4). If retries are exhausted, query before resubmitting — the lock may have applied successfully and your retry would create a duplicate. SeeOPTIMISTIC_LOCK_ERROR.Assuming
PROCESSING → COMPLETEDis fast. Real payout channels take seconds to hours depending on settlement windows, bank business days, and KYC. A withdrawal sitting inPROCESSINGfor 30 minutes is normal. Surfaceprocessed_atandcreated_atto your operations team; do not surface "stuck" status to end users prematurely.Polling instead of relying on callbacks. The callback fires once at the terminal state. Polling
GET /query/withdrawal/{order_no}adds load and latency. Use queries only as a reconciliation fallback for missed or unverified callbacks. See Callbacks.Ignoring
FAILED. When an order fails, the platform releases the freeze and reserves back to your available balance. Your reconciler must observe the callback and update your internal ledger; otherwise you will think funds are gone when they are still on the platform. See Callbacks for retry semantics on the failed-callback delivery.Expecting a callback at
APPROVEDorPENDING. No callback fires while an order is awaiting approval or sitting inAPPROVED. The signed callback fires at terminal states only. If your business needs an "approval received" notification, poll the query endpoint with a backoff suited to your operator SLA, or build that into your operator-side UI.Confusing
amount,actual_amount, andfee. The fee model is外加— added on top ofamount. Your balance is debited byamount + fee. The recipient receivesactual_amount, which equalsamount. There is no currency conversion in the merchant API at this version, so the three fields share the samecurrency_code.Hard-coding
payout_methodin your logic.payout_methodis set by the gateway based on the routing decision and may change between similar-looking orders. Treat it as informational. The state transitions are the same regardless of method.Assuming manual approval is fast. When the approval engine routes to manual, the order can sit in
PENDINGfor minutes or hours. Do not block your end-user-facing UI on a withdrawal completing. Acknowledge submission, then push status updates from your reconciler.Not testing the
FAILEDpath before going live. The sandbox supports failed-payout simulation through the mock channel. Confirm your callback handler reconciles balance correctly and your UI surfaces the failure to operators. See Sandbox.Calling the merchant API for cancellation. There is no merchant API endpoint to cancel a withdrawal.
CANCELLEDis reachable only via internalTransferTaskcancellation, which is an operator action. If you submitted an order in error, contact platform operations.Trusting
account_idas a payer-facing identifier.account_idin the response refers to the platform's internal bank account selected by the routing engine. It is not the beneficiary account, it can change between routing decisions, and it must not be displayed to end users.Implicit trust in
is_auto_approved=true. Channel-routed orders always carryis_auto_approved=true, but that does not mean the funds have moved. Readstatusto decide what actually happened:pendingorprocessing(in flight),completed(settled),failed/rejected/cancelled(returned).
The full error code catalogue is in Errors.
Going live checklist
Before pointing production traffic at the gateway:
- [ ] Callback URL is HTTPS, monitored, and returns
200 OK(or response body containingsuccess) within 5 seconds. - [ ] Callback signature verification is implemented and unit-tested. See Authentication.
- [ ] Your production NAT exit IPs are in the Cloudflare Access allowlist. See Sandbox for the Cloudflare edge source-IP model.
- [ ] Idempotency strategy is decided: on retries you reuse the same
merchant_order_no, and you have a code path to handle the duplicate response. - [ ]
currency_codeis validated against the currencies your merchant account is enabled for, and against the channels actually configured for that currency. - [ ] Bank account number formatting is normalised per target country before submission (no spaces, no dashes, leading zeros preserved). See Pitfalls.
- [ ]
beneficiary_nameis encoded as UTF-8 and truncated to 100 characters. Pre-flight your local character handling against your most exotic recipient name; some banks reject non-ASCII. - [ ] The
OPTIMISTIC_LOCK_ERRORretry pattern is implemented client-side, capped at 3 attempts with jitter. - [ ] A sandbox end-to-end test is run for at least the happy path (
PROCESSING → COMPLETED) and the failure path (PROCESSING → FAILED). - [ ] Production API key and signing secret are in your secret manager, not in source or CI logs.
- [ ] Operator runbook covers
PENDING(manual approval),CANCELLED(operator action), andneeds_manual_intervention=true(automated flow stalled). - [ ] Your ledger reconciler treats
COMPLETEDas the only "funds gone" signal, andREJECTED/FAILEDas "freeze released, funds back".
Cross-references
- Authentication — MD5-over-sorted-params signing scheme used on every request and every callback.
- Callbacks — Server-to-server status notifications, retry policy, response format.
- Errors — Canonical error code reference. Cross-linked from the Pitfalls section.
- API reference — Full schema definitions for every request and response field.
- Sandbox — PRE environment, mock channel, Cloudflare edge IP allowlist model.
- Quickstart — End-to-end example of creating a first deposit.
- Deposits — The opposite direction; receives funds into your merchant balance.