Skip to content

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

TermMeaning
Withdrawal orderA 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).
BeneficiaryThe 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 balanceThe 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 accountingThe 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 methodOne 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.
ApprovalA rule-based gate between PENDING and APPROVED. Some orders auto-approve, some require an operator. See Approval rules.
RoutingThe 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 taskAn 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 lockThe concurrency-control strategy used when the platform updates merchant balance and order state. A concurrent update can surface as OPTIMISTIC_LOCK_ERROR. See Pitfalls.
CallbackThe 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.

MethodPathPurpose
GET/api/v1/merchant/balanceList 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

bash
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:

json
{
  "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

FieldTypeMeaning
currency_codestringISO 4217. One entry per currency your account is enabled for.
available_balancedecimal stringFunds you can withdraw or use to create new withdrawals.
frozen_balancedecimal stringFunds reserved against in-flight withdrawals (PENDING / APPROVED / PROCESSING). Returns to available_balance if the withdrawal fails, is rejected, or is cancelled.
total_balancedecimal stringavailable_balance + frozen_balance. Provided for convenience.
versionintegerOptimistic-lock version of the balance row. Increments on every balance change. Useful for detecting concurrent modifications across your own reconciliation jobs.
created_at / updated_attimestampWhen the balance row was first seeded and last modified.

Common uses

  • Pre-flight check before withdrawal. Call this endpoint, confirm available_balance >= amount + fee for the target currency, then create the withdrawal. Skipping the check is safe — the gateway rejects the withdrawal with INSUFFICIENT_BALANCE if 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; version increments are a cheap way to know "something moved".

API surface

MethodPathPurpose
POST/api/v1/merchant/orders/withdrawalCreate 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)

FieldTypeRequiredNotes
merchant_order_nostring (1-64)yesYour unique reference for this payout. Must be unique per merchant.
amountdecimalyesMust be greater than 0. Subject to currency precision validation.
currency_codestring (3)yesISO 4217 code. Must be a currency your merchant account is enabled for.
beneficiary_namestring (1-100)yesRecipient legal name as printed on the bank account. Stored encrypted.
beneficiary_accountstring (1-100)yesRecipient bank account number. Stored encrypted. Format is country and channel specific; see Pitfalls.
beneficiary_bank_codestring (≤20)noBank identifier. Required by most channels; the gateway resolves it to the channel-specific bank code via the provider bank mapping table.
beneficiary_bank_branchstring (≤200)noBranch code or name. Required by some channels (notably Japanese and Indonesian rails).
callback_urlstring (1-500)yesHTTPS URL we call on terminal state changes.
extra_datastringnoOpaque 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)

FieldNotes
idInternal UUID. Use order_no for queries.
order_noGateway-assigned identifier. Format: W + 14-digit timestamp + 8 hex characters.
merchant_order_noEchoed from the request.
merchant_id, account_idThe 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.
amountThe amount you requested.
feeCalculated from MerchantFeeConfig. Added on top — your balance is debited by amount + fee.
actual_amountThe amount sent to the beneficiary. Equals amount because fee is 外加 (added on top), not deducted from amount.
currency_codeEchoed from the request.
payout_methodTHIRD_PARTY, MANUAL, or BANK_API. Set by the gateway based on the routing decision.
statusWithdrawalOrderStatus. See State transitions.
is_auto_approvedtrue 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_remarksApproval audit trail. approved_by is empty for auto-approved orders.
processed_atSet when the order enters PROCESSING.
completed_atSet when the order enters COMPLETED.
bank_referenceExternal transaction reference reported by the bank or the channel. Populated on COMPLETED.
callback_status, callback_countCallback delivery state. See Callbacks.
beneficiary_name, beneficiary_account, beneficiary_bank_code, beneficiary_bank_name, beneficiary_bank_branchDecrypted on read. beneficiary_bank_name is resolved from beneficiary_bank_code.
needs_manual_intervention, manual_reasonSet when an automated payout flow has stalled and an operator must take over.
channel_id, channel_name, channel_order_noPopulated for channel-routed orders.
estimated_channel_cost, estimated_profit, actual_channel_cost, actual_profitProfit-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 moves PENDING → 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 moves PROCESSING → FAILED. The signed merchant callback fires with status=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 ChannelAPIError during POST /withdrawal, the order row is deleted, the freeze is released, and the endpoint responds with HTTP 400 BUSINESS_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:

  1. 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 an Account, that specific account is used (and quota is checked).
  2. 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.
  3. No account either. The create call fails with BusinessError and message No 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:

  1. Per-merchant config (ApprovalConfig.merchant_id = <your merchant id>, is_active = true).
  2. 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:

  1. auto_approve_enabled flag. If false, manual approval is required regardless of other rules.
  2. auto_approve_max_amount threshold. If the order amount exceeds this threshold, manual approval is required.
  3. ApprovalWhitelist lookup. If a SHA-256 hash of beneficiary_account matches an active whitelist entry of type beneficiary_account, the order is auto-approved immediately, bypassing the daily limit checks below.
  4. Daily count limit. If today's auto-approved order count for this merchant already meets auto_approve_daily_count, manual approval is required.
  5. Daily amount limit. If today's auto-approved order amount plus this order's amount exceeds auto_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: A TransferTask is created automatically. The order moves APPROVED → PROCESSING and processed_at is set.
  • transfer_mode = MANUAL: The order sits at APPROVED until 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.

bash
#!/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
python
#!/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
<?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']);
typescript
#!/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.

bash
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):

json
{
  "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:

json
{
  "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:

bash
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:

bash
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:

json
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:

  1. Do not retry the same withdrawal blindly.
  2. Top up your merchant balance, or wait for incoming deposits to settle.
  3. Query your balance via the platform balance endpoint (see API reference) before resubmitting.
  4. 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):

json
{
  "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:

  1. Verify the signature. Reject unsigned or mis-signed requests.
  2. Look up the order by order_no (preferred) or merchant_order_no.
  3. If the order is already in your local terminal state, reply 200 OK and return — idempotent.
  4. Otherwise, update your ledger: amount + fee leaves your "in-flight" bucket; actual_amount reaches the recipient.
  5. Reply 200 OK (body containing success is 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:

json
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.

python
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 → ToTriggerMerchant observes
[*] → PENDINGPOST /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 → APPROVEDOperator approves in admin UI (approve_order), or the auto-approval engine runs a delayed evaluation (rare).Visible via query. No callback.
PENDING → REJECTEDOperator denies (reject_order). Frozen balance and reserved quota are released.Visible via query. Callback may fire depending on configuration.
PENDING → CANCELLEDAn associated TransferTask is cancelled.Visible via query. Frozen balance currently remains frozen; operator reconciles.
APPROVED → PROCESSINGThe 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 → FAILEDA failure surfaces before the order starts processing (rare). fail_order releases freeze and quota.Callback fires with status=failed.
PROCESSING → COMPLETEDChannel 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 → FAILEDChannel callback reports failure, or operator marks the order failed. Backend releases freeze and quota.Signed callback with status=failed.
PROCESSING → CANCELLEDTransfer 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_order moves PENDING → APPROVED. Sets approved_by to the operator user id, approved_at to the approval time, and may carry approval_remarks.
  • reject_order moves PENDING → REJECTED. Releases freeze and reservation. Sets approved_by to the rejecting operator (the same field is reused) and approval_remarks to the reason.
  • process_order moves APPROVED → PROCESSING for orders that were not auto-routed. Used when an operator picks up a manual payout. May overwrite payout_method (operator picks BANK_API or MANUAL) and may seed bank_reference.
  • complete_order moves PROCESSING → COMPLETED. Used when an operator confirms a manual payout, or by the channel-callback handler. Confirms the freeze and may seed bank_reference.
  • fail_order moves APPROVED → FAILED or PROCESSING → 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

  1. Reusing merchant_order_no across attempts. The backend rejects duplicates with HTTP 409 and CONFLICT code in the standard error envelope. Always use a fresh merchant_order_no for each new logical payout intent. For retries of the same intent, reuse the same merchant_order_no — the duplicate check then lets you safely query the existing order. See CONFLICT.

  2. beneficiary_account format 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_branch even when the field looks optional in the schema. Strip user-entered whitespace before submitting. Validate at the merchant side; the API will not.
  3. Currency vs channel mismatch. If you submit a currency_code that no active payout channel and no active account supports, the create call fails with BUSINESS_ERROR and message No 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. See BUSINESS_ERROR.

  4. Treating REJECTED as retryable. Rejection is terminal. Reusing the same merchant_order_no after rejection trips the duplicate check. Create a new order with a new merchant_order_no and address the rejection reason (visible in approval_remarks via query).

  5. Logging beneficiary_account in 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 to beneficiary_name for jurisdictions with PII rules.

  6. OPTIMISTIC_LOCK_ERROR without 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. See OPTIMISTIC_LOCK_ERROR.

  7. Assuming PROCESSING → COMPLETED is fast. Real payout channels take seconds to hours depending on settlement windows, bank business days, and KYC. A withdrawal sitting in PROCESSING for 30 minutes is normal. Surface processed_at and created_at to your operations team; do not surface "stuck" status to end users prematurely.

  8. 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.

  9. 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.

  10. Expecting a callback at APPROVED or PENDING. No callback fires while an order is awaiting approval or sitting in APPROVED. 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.

  11. Confusing amount, actual_amount, and fee. The fee model is 外加 — added on top of amount. Your balance is debited by amount + fee. The recipient receives actual_amount, which equals amount. There is no currency conversion in the merchant API at this version, so the three fields share the same currency_code.

  12. Hard-coding payout_method in your logic. payout_method is 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.

  13. Assuming manual approval is fast. When the approval engine routes to manual, the order can sit in PENDING for minutes or hours. Do not block your end-user-facing UI on a withdrawal completing. Acknowledge submission, then push status updates from your reconciler.

  14. Not testing the FAILED path 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.

  15. Calling the merchant API for cancellation. There is no merchant API endpoint to cancel a withdrawal. CANCELLED is reachable only via internal TransferTask cancellation, which is an operator action. If you submitted an order in error, contact platform operations.

  16. Trusting account_id as a payer-facing identifier. account_id in 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.

  17. Implicit trust in is_auto_approved=true. Channel-routed orders always carry is_auto_approved=true, but that does not mean the funds have moved. Read status to decide what actually happened: pending or processing (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 containing success) 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_code is 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_name is 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_ERROR retry 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), and needs_manual_intervention=true (automated flow stalled).
  • [ ] Your ledger reconciler treats COMPLETED as the only "funds gone" signal, and REJECTED / FAILED as "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.