Skip to content

本章是通过 Payment Gateway 商户 API 拨付资金的权威参考。内容涵盖 POST /withdrawal 端点、基于规则的审核引擎、渠道与账户两种路由分支、状态机,以及在生产环境会出现的陷阱。

概念与术语

术语含义
提款订单一笔 WithdrawalOrder 行,代表从你的商户余额发出的一次出款意图。以 order_no(由网关指派)与 merchant_order_no(由你指派)识别。
收款人出款的最终收受方。由 beneficiary_namebeneficiary_account,以及可选的 beneficiary_bank_code / beneficiary_bank_branch 定义。账号在存储时以 AES 加密;API 接收的是明文。
商户余额支撑每一笔出款的账本项目。提款在创建时冻结 amount + fee,在 COMPLETED 时确认冻结,或在 REJECTED / FAILED 时把资金释放回可用余额。
冻结会计平台把商户余额按币种拆成 availablefrozen 两个桶子。freeze_balanceamount + feeavailable 移到 frozenconfirm_freezefrozen 扣除(资金已支出)。release_freeze 移回 available(资金返还)。三者都有版本号,因此并发更新会以 OPTIMISTIC_LOCK_ERROR 浮现。
出款方式BANK_APIMANUALTHIRD_PARTY 三者之一。由网关根据出款实际离开系统的方式设定。商户无法选择。
审核介于 PENDINGAPPROVED 之间、基于规则的闸门。部分订单会自动审核,部分需要运营人员处理。参见 审核规则
路由决定出款要送到哪里。可能的目标有两种:第三方出款渠道(THIRD_PARTY),或由平台管理的银行账户(MANUALBANK_API)。
转账任务推动账户路由出款完成的内部工作。当路由命中的账户处于 AUTO 转账模式时会自动创建。商户不会直接看到。
乐观锁定平台在更新商户余额与订单状态时所用的并发控制策略。并发更新可能以 OPTIMISTIC_LOCK_ERROR 浮现。参见 陷阱
回调订单到达终态时,发往 callback_url 的已签名 HTTP POST。签名方案与对外请求相同。参见 Callbacks

查询余额

提交提款前,通常会想先知道自己有多少钱可以提。商户 API 提供单一端点,返回你账号开通的所有币种余额。

MethodPath用途
GET/api/v1/merchant/balance列出当前认证商户的所有余额。

该端点需要标准 MD5 签名标头。没有 body、也没有 query 参数,所以签名只对 timestamp 计算。

请求

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}"

响应

成功时返回 HTTP 200 OK,每个币种一笔条目:

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"
    }
  ]
}

字段

字段类型含义
currency_codestringISO 4217。你账号开通的每个币种一笔。
available_balancedecimal string可动用、可用来创建新提款的余额。
frozen_balancedecimal string已被进行中的提款(PENDING / APPROVED / PROCESSING)冻结的余额。若提款失败、被拒绝或取消,会回到 available_balance
total_balancedecimal stringavailable_balance + frozen_balance。为方便阅读提供。
versioninteger余额行的乐观锁版本。每次余额变动都会 +1。可用来在你自己的对账作业中检测并发更动。
created_at / updated_attimestamp余额行首次建立、最后变动的时间。

常见用途

  • 提款前的预检。 调用此端点、确认目标币种 available_balance >= 金额 + 手续费 后再创建提款。略过检查是安全的——网关会在资金不足时用 INSUFFICIENT_BALANCE 拒绝——但在你自己的 UI 上先给用户反馈体验更好。
  • 仪表板渲染。 周期性轮询以显示各币种当前余额。
  • 对账。 搭配充值 / 提款的 callback 处理器,检测你方账本与我方之间的差异;version 递增是「有东西动过」的便宜判断依据。

API 接口

MethodPath用途
POST/api/v1/merchant/orders/withdrawal创建一笔提款订单。
GET/api/v1/merchant/query/withdrawal/{order_no}以网关的 order_no 查询提款订单。
GET/api/v1/merchant/query/withdrawal?merchant_order_no=...以你的 merchant_order_no 查询提款订单。

所有端点都需要标准的 MD5 签名标头(对排序后的参数拼接 api_key 后计算 MD5,输出小写 hex)。参见 认证与签名

WithdrawalOrderCreate(请求体)

字段类型必填说明
merchant_order_nostring (1-64)你对这笔出款的唯一参照。每个商户内必须唯一。
amountdecimal必须大于 0。会做币种精度验证。
currency_codestring (3)ISO 4217 代码。必须是你的商户账号已启用的币种。
beneficiary_namestring (1-100)收款人法定姓名,需与银行账户上印的一致。加密存储。
beneficiary_accountstring (1-100)收款人银行账号。加密存储。格式因国家与渠道而异;参见 陷阱
beneficiary_bank_codestring (≤20)银行识别码。多数渠道需要;网关会通过 provider bank mapping 表将其解析为渠道专用的银行代码。
beneficiary_bank_branchstring (≤200)分行代码或名称。部分渠道需要(特别是日本与印尼的通道)。
callback_urlstring (1-500)终态状态变更时我们会调用的 HTTPS URL。
extra_datastring不透明字符串,会在回调中原样回传。

schema 定义在 app/schemas/order.pyWithdrawalOrderCreate。模型是 app/models/order.pyWithdrawalOrder

WithdrawalOrderResponse(HTTP 201 与查询响应)

字段说明
id内部 UUID。查询请用 order_no
order_no网关指派的识别码。格式:W + 14 位时间戳 + 8 位十六进制字符。
merchant_order_no原样回传请求中的值。
merchant_id, account_id商户与被选定执行出款的银行账户。即使是渠道路由订单,account_id 也会被设定;它指向绑定渠道的记账账户。
amount你请求的金额。
feeMerchantFeeConfig 计算。为外加费用 — 你的余额会被扣除 amount + fee
actual_amount实际送到收款人手中的金额。等于 amount,因为手续费是外加,不从 amount 扣除。
currency_code原样回传请求中的值。
payout_methodTHIRD_PARTYMANUALBANK_API。由网关根据路由决策设定。
statusWithdrawalOrderStatus。参见 状态转移
is_auto_approved若审核引擎放行订单,或订单被路由到渠道(渠道路由订单会跳过人工审核),则为 true
approved_at, approved_by, approval_remarks审核审计轨迹。自动审核订单的 approved_by 为空。
processed_at订单进入 PROCESSING 时设定。
completed_at订单进入 COMPLETED 时设定。
bank_reference银行或渠道回报的外部交易参照。COMPLETED 时填入。
callback_status, callback_count回调投递状态。参见 Callbacks
beneficiary_name, beneficiary_account, beneficiary_bank_code, beneficiary_bank_name, beneficiary_bank_branch读取时解密。beneficiary_bank_namebeneficiary_bank_code 解析而来。
needs_manual_intervention, manual_reason自动出款流程卡住、需要运营人员接手时设定。
channel_id, channel_name, channel_order_no渠道路由订单会填入。
estimated_channel_cost, estimated_profit, actual_channel_cost, actual_profit利润追踪字段。对多数集成方无关。

完整 schema 定义在 API reference

流程

失败分支:

  • 审核拒绝。 运营人员在后台 UI 拒绝。后端调用 reject_order:冻结余额与预留额度释放回商户;状态移至 PENDING → REJECTED。触发此动作的端点仅限管理员;商户只能通过查询或回调观察到变化(回调是否触发取决于设置)。
  • 渠道回报失败。 渠道回调带失败状态抵达。后端调用 fail_order:冻结余额与预留额度释放,状态移至 PROCESSING → FAILED。会以 status=failed 触发已签名的商户回调。
  • 转账任务取消。 若平台取消底层的转账任务(罕见;运营人员动作),提款订单移至 PROCESSING → CANCELLED。预留余额目前会保持冻结,等待运营人员对账(这是平台已知的 follow-up 项目)。
  • 送单时的渠道创建错误。 若渠道 adapter 在 POST /withdrawal 期间抛出 ChannelAPIError,订单行会被删除,冻结释放,端点以 HTTP 400 BUSINESS_ERROR 响应。没有订单被创建,也不会触发回调。

网关如何在渠道与账户间做选择

路由引擎(ChannelRoutingService.find_target_for_order)按以下顺序查询 merchant_channel_rule 行:

  1. 此商户 + 币种 + 金额区间的明确规则。 若匹配规则指向 PaymentChannel,订单走渠道路由。若指向 Account,则使用该特定账户(并检查额度)。
  2. 无规则,或规则未匹配。 退回到该币种第一个额度足够的可用出款账户,由 AccountService.find_available_withdrawal_account 挑选。
  3. 连账户都没有。 创建调用以 BusinessError 失败,消息为 No available payout account for <CCY> with sufficient quota。在此情况下商户余额永远不会被冻结。

商户无法从商户 API 指定渠道或账户。若你的特定使用案例需要可预测的路由,请联系平台运营为你的商户安装 merchant_channel_rule

审核规则:PENDING 何时变成 APPROVED

审核行为依路由目标而定。平台是基于规则的,并非只有人工或只有自动。

渠道路由订单

若路由引擎为订单选择 PaymentChannel,平台会同步将 is_auto_approved=trueapproved_at=now 设定好,然后送单给渠道 adapter。订单完全跳过运营人员队列,并从 PROCESSING 开始(或短暂停在 PENDING,直到 adapter 调用成功返回)。渠道路由提款永远不会出现在人工审核队列中。

账户路由订单

若路由引擎选择 Account(或无规则匹配、退回方案以额度挑选账户),平台会查询 ApprovalConfig。第一个匹配者胜出:

  1. 商户层设置ApprovalConfig.merchant_id = <你的商户 id>is_active = true)。
  2. 全局设置ApprovalConfig.merchant_id IS NULLis_active = true)。

若没有任何有效设置,订单需要人工审核(status = PENDING)。

若存在有效设置,自动审核引擎依序检查:

  1. auto_approve_enabled 标志。 若为 false,无论其他规则为何皆需人工审核。
  2. auto_approve_max_amount 阈值。 若订单金额超过此阈值,需人工审核。
  3. ApprovalWhitelist 查找。beneficiary_account 的 SHA-256 散列匹配到一条 beneficiary_account 类型的有效白名单项目,订单会立即自动审核,跳过下方的每日限额检查。
  4. 每日笔数限制。 若今日此商户的自动审核订单笔数已达到 auto_approve_daily_count,需人工审核。
  5. 每日金额限制。 若今日已自动审核的订单金额加上本笔订单的 amount 超过 auto_approve_daily_limit,需人工审核。

若全部检查通过,订单会被自动审核(is_auto_approved=truestatus=APPROVEDapproved_at=now)。

自动审核后的订单是否立即进入 PROCESSING,取决于路由命中账户的 transfer_mode

  • transfer_mode = AUTO:自动创建 TransferTask。订单移至 APPROVED → PROCESSING,并设定 processed_at
  • transfer_mode = MANUAL:订单停在 APPROVED,直到运营人员处理。

实现位于 app/services/withdrawal.pyWithdrawalService._check_auto_approvalWithdrawalService.create。把规则视为平台设置;不要在商户端写死特定审核模式的逻辑。

示例

下面代码片段假设你已依照 Authentication 文档生成 X-TimestampX-Signature 标头。签名对象是 JSON 请求体,不是 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));

示例 1:提交一笔 THB 的泰国银行提款

把 5,000 THB 出款到一个 Siam Commercial Bank 账户。

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"
  }'

响应(渠道路由、自动审核,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"
}

响应(账户路由、需人工审核,节录):

json
{
  "order_no": "W202605200815120ABCDEF12",
  "status": "pending",
  "is_auto_approved": false,
  "payout_method": "manual",
  "approved_at": null
}

order_no 与你的 merchant_order_no 对应保存。查询与回调对账会用到。在收到 COMPLETED 回调之前,不要依据 actual_amount 采取行动;该值在建单时就设定,但资金尚未移动。

示例 2:创建后查询提款

以网关的 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"

或以你的 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"

两者都回传完整的 WithdrawalOrderResponse schema,包含 statusbank_referencecompleted_atcallback_status。用响应跟你内部记录对账。GET 端点可以反复调用;它们只读取状态。

示例 3:处理 INSUFFICIENT_MERCHANT_BALANCE

提款在创建时冻结 amount + fee。若你该币种的商户余额低于这个总额,创建调用会失败:

json
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "code": "INSUFFICIENT_MERCHANT_BALANCE",
  "message": "Insufficient merchant balance",
  "data": null
}

标准错误信封与 code 定义请参见 Errors

建议的恢复方式:

  1. 不要盲目重试同一笔提款。
  2. 为你的商户余额储值,或等待入账的充值结算。
  3. 重新送单前,通过平台余额端点查询余额(参见 API reference)。
  4. 若你重新送单,重用相同的 merchant_order_no。若原本第一次其实已悄悄落地,重复检查可以保护你。

示例 4(续):回调长什么样子

当订单到达 COMPLETED,网关会 POST 一个已签名的请求体到你的 callback_url。回调可用的字段包含 order_nomerchant_order_nostatusamountactual_amountfeecurrency_codebank_referencecompleted_atextra_data。确切的传输格式与签名方案记载于 Callbacks

上面那笔订单成功出款的回调(节录):

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"
}

你的处理器应该:

  1. 验证签名。拒绝未签名或签名错误的请求。
  2. order_no(优先)或 merchant_order_no 查询订单。
  3. 若订单在你本地已是终态,回 200 OK 并返回 — 幂等。
  4. 否则更新你的账本:amount + fee 从你的「处理中」桶子离开;actual_amount 抵达收款人。
  5. 200 OK(包含 success 的响应体也会被网关的重试策略接受)。

FAILED 回调使用 status: "failed",并省略 bank_referencecompleted_at。你的处理器应把 amount + fee 释放回你的可用桶子。完整信封与重试策略参见 Callbacks

示例 5:以重试处理 OPTIMISTIC_LOCK_ERROR

平台对商户余额与订单行使用乐观锁定。在并发负载下,一次送单可能与并行更新发生冲突,API 会回传:

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
}

错误码是 OPTIMISTIC_LOCK_ERROR。这是暂时性的。建议的客户端模式是有上限的重试加抖动

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}"
    )

把重试预算设在 3 次。不要无限重试 — 持续的锁冲突通常代表后端有问题,而不是暂时的竞态。重试用完后,以 merchant_order_no 查询订单:它有可能在第一次就悄悄建单成功。参见 OPTIMISTIC_LOCK_ERROR

乐观锁定实际在哪里触发

提款上 OPTIMISTIC_LOCK_ERROR 最常见的来源是商户余额更新:freeze_balance 读取余额行、修改它、写回时做版本检查。同一笔商户余额行上若有并行的提款或并行的充值结算,都会让检查失败。较少见的情况是渠道 adapter(特别是 TGCPay)在上游使用乐观锁,网关把错误往下传。重试模式对两种情况的处理方式完全相同。

状态转移

enum 值定义在 app/models/order.pyWithdrawalOrderStatus。传输值为小写:pendingapprovedprocessingcompletedrejectedfailedcancelled

从 → 到触发条件商户观察到
[*] → PENDINGPOST /withdrawal 对需要人工审核的账户路由订单成功。商户余额冻结保持中;提款额度已在账户上预留。201 带 status=pending。尚无回调。
[*] → APPROVED(跳过 PENDINGPOST /withdrawal 对自动审核(白名单命中、未超过阈值与限额)的账户路由订单成功,且该账户为 MANUAL 转账模式。201 带 status=approvedis_auto_approved=true。尚无回调。
[*] → PROCESSING(跳过 PENDINGAPPROVEDPOST /withdrawal 对渠道路由订单成功,或对自动审核、AUTO 转账模式账户路由订单成功。201 带 status=processing。尚无回调。
PENDING → APPROVED运营人员在后台 UI 审核通过(approve_order),或自动审核引擎执行延迟评估(罕见)。通过查询可见。无回调。
PENDING → REJECTED运营人员拒绝(reject_order)。冻结余额与预留额度释放。通过查询可见。回调是否触发取决于设置。
PENDING → CANCELLED关联的 TransferTask 被取消。通过查询可见。冻结余额目前保持冻结;由运营人员对账。
APPROVED → PROCESSING平台创建 TransferTask(账户路由、AUTO 模式)或渠道 adapter 接受送单。设定 processed_at通过查询可见。无回调。
APPROVED → FAILED失败在订单开始处理前浮现(罕见)。fail_order 释放冻结与额度。触发回调,带 status=failed
PROCESSING → COMPLETED渠道回调回报成功,或运营人员把订单标为完成。后端确认余额冻结(资金已支出),并设定 completed_atbank_reference已签名回调,带 status=completed
PROCESSING → FAILED渠道回调回报失败,或运营人员把订单标为失败。后端释放冻结与额度。已签名回调,带 status=failed
PROCESSING → CANCELLED转账任务在进行中被取消。通过查询可见。冻结余额目前保持冻结;由运营人员对账。

终态:COMPLETEDREJECTEDFAILEDCANCELLED。其中没有任何一个可由商户 API 反转。要「重试」一笔失败的提款,请以新的 merchant_order_no 创建新订单。

由运营人员驱动的转移

部分转移源自后台 UI,而非商户 API 或渠道回调。在此列出以提高透明度,避免你的对账器做出错误假设:

  • approve_orderPENDING → APPROVED。设定 approved_by 为运营人员用户 id,approved_at 为审核时间,并可能带上 approval_remarks
  • reject_orderPENDING → REJECTED。释放冻结与预留。设定 approved_by 为拒绝的运营人员(重用同一字段),approval_remarks 为原因。
  • process_order 将未自动路由的订单从 APPROVED → PROCESSING。当运营人员接手人工出款时使用。可能覆写 payout_method(运营人员挑选 BANK_APIMANUAL),并可能预先填入 bank_reference
  • complete_orderPROCESSING → COMPLETED。当运营人员确认人工出款,或由渠道回调处理器调用时使用。确认冻结,并可能预先填入 bank_reference
  • fail_orderAPPROVED → FAILEDPROCESSING → FAILED。释放冻结与预留。由渠道回调处理器在失败时调用,或由运营人员把卡住的出款标为失败时调用。

商户 API 接口不会直接暴露上述操作。商户通过查询与终态的已签名回调观察到最终的 status

陷阱

  1. 跨次尝试重用 merchant_order_no 后端会以 HTTP 409 与标准错误信封中的 CONFLICT code 拒绝重复。每一笔新的逻辑出款意图请使用新的 merchant_order_no。对于同一个意图的重试,重用相同的 merchant_order_no — 重复检查反而让你可以安全查询既有订单。参见 CONFLICT

  2. beneficiary_account 格式不符。 平台把值大致原样传给出款渠道(存储时 AES 加密、送单时解密)。渠道专属规则:

    • 泰国银行账号:只允许数字,不接受破折号或空格。对部分银行而言,前导零有意义。
    • 越南银行账号:只允许数字,但部分渠道接受最长 19 码。不要补零。
    • 印尼银行账号:10–16 位数字。部分渠道即使字段在 schema 看起来可选,仍要求 beneficiary_bank_branch。 送出前剔除用户输入的空白。在商户端验证;API 不会做这件事。
  3. 币种与渠道不符。 若你送出的 currency_code 没有任何有效的出款渠道或有效的账户支持,创建调用会以 BUSINESS_ERROR 失败,消息为 No available payout account for <CCY> with sufficient quota(或来自渠道层的路由错误)。在此情况下商户余额永远不会被冻结。参见 BUSINESS_ERROR

  4. REJECTED 当成可重试。 拒绝是终态。在拒绝后重用同一个 merchant_order_no 会触发重复检查。请以新的 merchant_order_no 创建新订单,并处理拒绝原因(通过查询可在 approval_remarks 看到)。

  5. 以明文记录 beneficiary_account 平台在存储时使用 AES 加密。你这一侧也必须这么做:在日志中除了最后 4 码以外全部遮罩,永远不要把完整账号以明文存在你的加密存储以外的地方。对于有 PII 规范的司法管辖区,beneficiary_name 也适用相同规则。

  6. OPTIMISTIC_LOCK_ERROR 不重试。 把它视为暂时性的。以指数退避加抖动最多重试 3 次(参见 示例 4)。若重试用完,重新送单前先查询 — 锁可能其实已套用成功,重试会创建重复订单。参见 OPTIMISTIC_LOCK_ERROR

  7. 以为 PROCESSING → COMPLETED 很快。 真实出款渠道根据结算窗口、银行营业日与 KYC,会花几秒到几小时。提款停在 PROCESSING 30 分钟是正常的。把 processed_atcreated_at 揭露给你的运营团队;不要过早对终端用户揭露「卡住」的状态。

  8. 以轮询取代依靠回调。 回调在终态时触发一次。轮询 GET /query/withdrawal/{order_no} 会增加负载与延迟。查询只应作为遗漏或未验证回调的对账备援。参见 Callbacks

  9. 忽略 FAILED 订单失败时,平台会释放冻结与预留回你的可用余额。你的对账器必须观察回调并更新内部账本;否则你会以为资金已支出,但其实还在平台上。失败回调的投递重试语意参见 Callbacks

  10. APPROVEDPENDING 期望收到回调。 订单在等待审核或停在 APPROVED 时不会触发回调。已签名回调只在终态时触发。若你的业务需要「已收到审核」通知,请以符合运营人员 SLA 的退避策略轮询查询端点,或把这件事建在你的运营人员端 UI 内。

  11. 混淆 amountactual_amountfee 手续费模型是「外加」 — 加在 amount 之上。你的余额会被扣除 amount + fee。收款人收到 actual_amount,等于 amount。本版本商户 API 没有任何币种换算,因此三个字段共享相同的 currency_code

  12. 在逻辑中写死 payout_method payout_method 由网关根据路由决策设定,可能在看起来相似的订单间变动。把它当成信息性字段即可。状态转移无论方式为何都是一样的。

  13. 以为人工审核很快。 当审核引擎路由到人工,订单可能在 PENDING 停留数分钟到数小时。不要让你面向终端用户的 UI 卡在提款完成上。先确认送单,然后由对账器推送状态更新。

  14. 上线前未测试 FAILED 路径。 沙盒通过 mock channel 支持出款失败模拟。确认你的回调处理器正确对账余额,且 UI 能把失败揭露给运营人员。参见 Sandbox

  15. 调用商户 API 取消。 没有商户 API 端点可以取消提款。CANCELLED 只能通过内部 TransferTask 取消达成,那是运营人员动作。若你误送了订单,请联系平台运营。

  16. account_id 当成面向付款人的识别码。 响应中的 account_id 指的是路由引擎选定的平台内部银行账户。它不是收款人账户,可能在路由决策之间变动,绝对不可显示给终端用户。

  17. 隐性信任 is_auto_approved=true 渠道路由订单一律带 is_auto_approved=true,但这并不代表资金已经移动。读 status 才知道实际发生什么:pendingprocessing(处理中)、completed(已结算)、failed / rejected / cancelled(已返还)。

完整错误码目录在 Errors

上线检查清单

把生产环境流量指向网关之前:

  • [ ] 回调 URL 是 HTTPS、有监控、会在 5 秒内回 200 OK(或响应体包含 success)。
  • [ ] 回调签名验证已实现并有单元测试。参见 Authentication
  • [ ] 你的生产环境 NAT 出口 IP 已在 Cloudflare Access 允许清单中。Cloudflare 边缘来源 IP 模型参见 Sandbox
  • [ ] 幂等策略已决定:重试时重用相同的 merchant_order_no,且你有处理重复响应的代码路径。
  • [ ] currency_code 会对你商户账号已启用的币种做验证,并对该币种实际设置的渠道做验证。
  • [ ] 送单前已依目标国家标准化银行账号格式(无空格、无破折号、保留前导零)。参见 陷阱
  • [ ] beneficiary_name 以 UTF-8 编码并截断至 100 字符。预先测试你本地的字符处理对最罕见的收款人姓名是否正常;部分银行会拒绝非 ASCII。
  • [ ] OPTIMISTIC_LOCK_ERROR 重试模式已在客户端实现,上限 3 次并带抖动。
  • [ ] 已在沙盒跑过端到端测试,至少包含 happy path(PROCESSING → COMPLETED)与失败路径(PROCESSING → FAILED)。
  • [ ] 生产环境的 API key 与签名密钥存在你的密钥管理器中,不在源代码或 CI 日志中。
  • [ ] 运营人员手册涵盖 PENDING(人工审核)、CANCELLED(运营人员动作),以及 needs_manual_intervention=true(自动流程卡住)。
  • [ ] 你的账本对账器把 COMPLETED 视为唯一的「资金已支出」信号,把 REJECTED / FAILED 视为「冻结已释放、资金返还」。

交叉参照

  • 认证与签名 — 每个请求与每个回调都使用的 MD5 排序拼接签名方案。
  • Callbacks — 服务器对服务器的状态通知、重试策略、响应格式。
  • Errors — 权威错误码参考。从 陷阱 段落交叉链接。
  • API reference — 每个请求与响应字段的完整 schema 定义。
  • Sandbox — PRE 环境、mock channel、Cloudflare 边缘 IP 允许清单模型。
  • Quickstart — 创建第一笔充值的端到端示例。
  • Deposits — 反方向;将资金收入你的商户余额。