Skip to content

本章是通过 Payment Gateway 商户 API 接受充值的权威参考。内容涵盖两种充值模式(Direct API 与收银台会话)、它们的状态机、确切的请求与响应结构,以及在生产环境会出现的陷阱。

已完成充值批量查询 ​

POST /api/v1/merchant/query/deposits 只读取本商户的 COMPLETED 充值用于对账,不创建付款。仅向 allowlist 中状态为 ACTIVE 且有充值权限的商户开放。扁平 JSON body 使用现有 MD5 headers 签名;完整 schema 见 API 参考。

发送 recorded_from、recorded_to(带明确 offset 的 RFC 3339 时间)、currency_code,可选 limit(1–200,默认 100)和 cursor。半开区间 [recorded_from, recorded_to) 最长 24 小时,结束时间至少早于数据库当前时间 10 分钟。筛选依据是 completion_recorded_at(我方 DB 首次记录 COMPLETED 的时间),不是 completed_at(业务完成时间;上游同步可能回报数日前的时间)。响应同时包含两者。

结果按 (completion_recorded_at, id) 排序,使用不透明且已签名的 next_cursor 翻页;后续页须保持区间、币种和 limit 不变。cursor 24 小时后过期,请从第一页重拉。has_more=false 时 next_cursor=null,不返回页码或总数。每笔充值仅包含 order_no、merchant_order_no、currency_code、decimal string 的 amount、requested_amount、actual_amount、fee、status、created_at、completed_at、completion_recorded_at、user_id;金额为固定四位小数的 decimal string,与 callback 相同。请按 decimal 数值比较,不要按字符串或浮点数比较。

交付契约为 at-least-once:按 order_no upsert,重新对账时重叠重拉前一个区间;极端长事务仍可能在较晚提交后出现。两条批量 API 共用每商户 30 次/分钟、300 次/小时,同一筛选条件 10 分钟内最多从首页重新启动 3 次;并发上限为每商户 2、全局 8。未沉淀区间或无效 cursor 返回 422;应用限流返回 429 和 Retry-After;繁忙、Redis 不可用或 SQL 超时返回可重试的 503。Cloudflare edge 429 的 body 可能不同,请依据 HTTP status 与 Retry-After 判断。flag 关闭或不在 allowlist 返回 403。

概念与术语 ​

术语含义
充值订单一笔 DepositOrder 列,代表你的某位终端用户的一次付款意图。以 order_no(由网关指派)与 merchant_order_no(由你指派)识别。
渠道上游付款供应商(例如第三方 PSP 或虚拟账号聚合商)。渠道订单会被路由到 PaymentChannel,并提供 payment_url 或渠道回传的 payment_info。
账户由运营方持有的实体银行账户。账户订单会要求付款人把资金汇到该账户;对账是通过 expected_remark 或金额浮动完成。
payment_url部分渠道回传的转址 URL。把付款人导到这里完成付款。只有当订单被路由到使用托管付款页的渠道时才会出现。
payment_info渠道回传的 JSON 对象,包含银行信息、QR Code 数据,或任何给付款人看的指示。对于不使用转址 URL 的渠道订单会出现。
收银台会话一笔 CashierSession 列,代表一个托管、给付款人看的页面。不带 amount 时,付款人在此页面上选金额,真正的 DepositOrder 会在付款人提交之后创建。若请求带 amount,DepositOrder 会与 session 同步创建,托管页面直接显示付款信息。
付款人汇出资金的终端用户,与商户不同。付款人会与 payment_url、payment_info 或托管的收银台页面交互。付款人不会对商户 API 做任何身份验证。
MATCHED 状态充值已被认定为已付款(一笔银行交易匹配上订单,或是渠道回报成功),但结算与账本提交尚未完成。并非终态。
Mock channel沙盒中内置的假渠道。用它可以端到端走完状态机,不需要动到任何真实 PSP。参见 Sandbox。

两种充值模式 ​

方面Direct API收银台会话
端点POST /api/v1/merchant/orders/depositPOST /api/v1/merchant/orders/deposit/cashier
由谁决定金额商户,于请求体中指定。默认由付款人在托管页面选择;若 cashier session 请求带 amount,则由商户预先指定。
由谁决定渠道网关的路由引擎,根据币种与金额决定。付款人提交后由网关的路由引擎决定;若 cashier session 请求带 amount,则在创建 session 时立即决定。
初始响应DepositPaymentInfo,含 payment_url 或银行信息。CashierCreateResponse,含 cashier_url 与 cashier_token。
DepositOrder 列何时创建POST /deposit 当下立即创建。默认在付款人提交时创建。若 cashier 请求带 amount,会与 session 同步创建,托管页面直接显示付款信息。
商户要展示给付款人看的内容你自己的 UI;由你渲染银行信息或转址到 payment_url。转址到 cashier_url。所有东西都由网关托管。
结果订单的 order_typeDIRECTCASHIER

模式选择指引 ​

如果你已经在自家 UI 收集了金额、币种与付款人意图,使用 Direct API。你的应用程序仍然掌控结账的外观与流程;转址或银行信息页面由你自己处理。

如果你希望 Payment Gateway 托管给付款人看的页面,使用收银台会话。付款人需要自行选金额时,不带 amount;你已在自家流程收集金额、只需要托管页面显示付款信息时,带入 amount。你只要把单一的 cashier_url 交给付款人,就可以放手不管,直到收到回调。

两种模式最后会收敛到相同的 DepositOrder 实体与相同的回调握手。查询、回调验证与状态语义完全一致。

API 接口 ​

MethodPath用途
POST/api/v1/merchant/orders/deposit创建 Direct API 充值订单。
POST/api/v1/merchant/orders/deposit/cashier创建收银台会话。
GET/api/v1/merchant/query/deposit/{order_no}以网关的 order_no 查询充值订单。
GET/api/v1/merchant/query/deposit?merchant_order_no=...以你的 merchant_order_no 查询充值订单。

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

DepositOrderCreate(Direct API 请求) ​

字段类型必填说明
merchant_order_nostring (1-64)是你对这笔充值的唯一引用。每个商户内必须唯一。
amountdecimal是必须大于 0。会做币种精度验证。
currency_codestring (3)是ISO 4217 代码。必须是你的商户账号已启用的币种。
callback_urlstring (1-500)是状态变更时我们会调用的 HTTPS URL。Direct API 必填。
extra_datastring否不透明字符串,会在回调中原样回传。
user_idstring (≤64)否你内部的用户标识符。用于分组与审计。
account_holderstring (≤128)否付款人姓名。会识别付款人的支付方式建议提供。
mobilestring (≤32)否付款人联系手机。只会转送给接受此字段的渠道。
emailstring (≤255)否付款人联系邮箱。只会转送给接受此字段的渠道。
bank_codestring (≤32)否付款银行代码,供审计与匹配使用;此字段不会选择支付渠道。
account_nostring (≤64)否付款人的银行账号或卡号。请传入这笔订单实际使用的付款标识资料。
payer_ipstring (≤45)否付款人的公网 IPv4 或 IPv6 地址,以你前端看到的为准;不是你服务器的 IP。INR 建议传送。

BDT 移动钱包充值 ​

将 currency_code 设为 BDT,订单就会按商户的孟加拉路由设置处理;API 没有另外的国家字段。每个商户号会配置其对应的 bKash 或 Nagad 支付渠道,网关按该商户号的 routing rule 处理。商户 API 不能通过 bank_code 更改支付方式;目前暂不支持 ROCKET。

若创建 BDT Direct API 充值时已知实际付款银行,请提供双方约定的孟加拉系统 bank_code 与 account_no,让付款资料逐笔记录在订单快照中;若能取得付款人姓名,建议一并提供 account_holder。这些字段仅描述实际付款方,不会选择 bKash 或 Nagad;尚不知道付款银行资料时请省略,不要自行生成数据。

BDT 充值请省略商户端的 mobile、email。上游渠道必填的固定 contact 由平台配置提供;商户传入的联系资料不会选择钱包,也不会由 BDT 渠道转送。

json
{
  "merchant_order_no": "BDT-DEP-0001",
  "amount": "300.00",
  "currency_code": "BDT",
  "callback_url": "https://merchant.example.com/api/pg/callback",
  "user_id": "user-42",
  "account_holder": "Rahim Ahmed",
  "bank_code": "ACTUAL_BD_BANK_CODE",
  "account_no": "1234567890"
}

请将 ACTUAL_BD_BANK_CODE 替换为与 OpenPay 约定的实际孟加拉付款银行代码。此字段不要传 BKASH 或 NAGAD;支付方式由商户号的路由配置决定。

INR UPI 充值 ​

将 currency_code 设为 INR,订单就会按商户的印度路由设置处理;API 没有另外的国家字段。付款人以 UPI 付款,支付方式由商户号的路由配置决定,bank_code 无法更改。

  • amount 以整数卢比传送,不带小数点,例如 "500"。"500.00" 与 "500.5" 会被币种精度验证拒绝。
  • payer_ip 传付款人的真实公网 IP。渠道会用它做风控,省略会降低付款成功率。
  • 每位付款人传固定的 user_id。
  • UPI 不需要 account_holder,也不需要 mobile、email。
  • 将付款人导向响应中的 payment_url。
json
{
  "merchant_order_no": "INR-DEP-0001",
  "amount": "500",
  "currency_code": "INR",
  "callback_url": "https://merchant.example.com/api/pg/callback",
  "user_id": "user-42",
  "payer_ip": "203.0.113.24"
}

付款人表示已付款但订单未完成时,向付款人取得该笔付款的 UTR(Unique Transaction Reference,显示在付款人的 UPI App 中),连同 order_no 提供给你的客户经理。付款核实后,订单会转成 COMPLETED,你会收到 deposit.completed 回调;即使订单已经超时也一样。不需要额外调用 API。参见超时后补单成功。

CashierDepositOrderCreate(收银台会话请求) ​

字段类型必填说明
order_typeenum否默认为 cashier。此字段为前向兼容而保留。
merchant_order_nostring (1-64)是唯一性规则与 Direct API 相同。
amountdecimal否预填金额。必须大于 0,且需符合商户设置的 cashier 面额;若已启用自定义金额,则需落在设置的最小/最大值范围内。带入时后端会立即创建 DepositOrder,托管页面会跳过面额选择。
currency_codestring (3)是ISO 4217 代码。
callback_urlstring (≤500)否收银台模式可选。若省略,使用商户默认值。
extra_datastring否会在回调中原样回传。
user_idstring (≤64)否强烈建议带上。网关会对同一个 user_id 重用既有的 PENDING 会话,避免你的应用程序重试时意外创建重复会话。
payer_bank_cardslist of objects (≤10)否预先绑定的付款人银行卡。提供时,收银台页面会强制付款人从中挑一张。

DepositPaymentInfo(Direct API 响应,HTTP 201) ​

字段何时出现说明
order_no总是网关指派的标识符。查询时使用此值。
requested_amount总是你提交的金额。可能会与 amount 不同。
amount总是付款人实际需要汇出的金额。当路由引擎启用金额浮动进行对账时,会与 requested_amount 不同。
currency_code总是从请求原样回传。
expire_at总是RFC 3339 时间戳,建单时固定。默认超时为 15 分钟;可依商户设置调整。走渠道的订单若该支付渠道付款期限较短,会更早超时,但不会更晚。
payment_url仅渠道订单付款人的转址目标。
payment_info仅渠道订单渠道回传的 JSON,结构依渠道而异。
bank_code、bank_name、bank_branch、account_no、account_holder账户订单;以及对于回传银行信息的渠道订单,为方便而镜像出来要展示给付款人看的银行信息。
remark仅账户订单付款人在银行转账备注栏必须填的字符串。对账器会用这个。

CashierCreateResponse(收银台响应,HTTP 201) ​

字段说明
cashier_url完整的转址 URL,付款人会被导到这里。包含已签名的 token。
cashier_token同一个 token,另外暴露出来方便后端查看。对商户来说并非必要。
session_idCashierSession.id。记录下来方便日志追踪。
expire_at会话超时。默认 15 分钟,可依商户设置调整。

完整的 schema 定义,包含 DepositOrderResponse 上的所有响应字段,请见 API reference。

Direct API 流程 ​

这张图描绘的是 happy path。网关在步骤 2-3 根据设置的路由规则决定订单路由:若有匹配的 PaymentChannel 就用渠道,否则用一个受管的银行 Account。订单列以 status=PENDING 与 expire_at 写入,expire_at 在建单时固定:取商户的 deposit_expire_minutes 设置(默认 15 分钟),若路由到的支付渠道付款期限较短则取较早者。之后修改设置不会改变既有订单的 expire_at。

当付款人付款时,网关会把订单从 MATCHED(已认定付款)推进到 COMPLETED(账本已结算),然后触发回调。失败分支(FAILED、EXPIRED、CANCELLED)详见状态转换。

收银台会话流程 ​

收银台模式的关键点:

  • 未带 amount 时,DepositOrder 列在付款人提交之前不存在。 在那之前你手上只有 CashierSession。对 /api/v1/merchant/query/deposit/{order_no} 发出查询会回 404,因为此时还没有 order_no。
  • 带 amount 时,DepositOrder 会立即创建。 cashier session 会以 submitted 状态返回,public cashier API 会显示 has_deposit_order=true,托管页面会跳过面额选择。
  • cashier_url 里的签名 token 是短效的,并且绑定到该会话。 它不是商户 API 的凭证。不要尝试拿它去调用商户 API 端点。
  • 会话重用以 (merchant_id, user_id) 为键。 若同一用户已有 PENDING 会话,网关会用新的 merchant_order_no、币种与 callback URL 更新它,并回传同一个 token。这可以避免重试时意外创建重复会话。
  • 走渠道的订单不会晚于会话超时。 付款人提交时,走渠道订单的 expire_at 不会晚于会话的 expire_at;若订单较早,会话的 expire_at 也会缩短到与订单相同。两者都不会被延长。走账户的订单仍依商户设置取得新的超时时间。
  • 收银台页面可能使用 WebSocket 推送状态更新给付款人浏览器,不需要轮询。对商户集成而言这是透明的:你还是依赖服务器对服务器的回调。

示例 ​

下方片段假设你已依 Authentication 所述生成 X-Timestamp 与 X-Signature 标头。四个示例都用同一把 key 签名:

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));

每个示例中,--data payload 就是你要签名的 JSON 请求体。签请求体,不要签 URL。

示例 1:Direct API 充值 ​

创建一笔 direct 充值。渠道或账户由网关挑选;当前版本无法从商户 API 指定渠道。

bash
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d '{
    "merchant_order_no": "DEP-20260520-0001",
    "amount": "1000.00",
    "currency_code": "THB",
    "callback_url": "https://merchant.example.com/api/pg/callback",
    "user_id": "user-42",
    "extra_data": "checkout-cart-77"
  }'

响应(路由到渠道,已精简):

json
{
  "order_no": "D2026052012345678",
  "requested_amount": "1000.00",
  "amount": "1000.00",
  "currency_code": "THB",
  "expire_at": "2026-05-20T08:15:00Z",
  "payment_url": "https://pay.example-psp.com/pay/abc123",
  "payment_info": {
    "bank_code": "SCB",
    "bank_name": "Siam Commercial Bank",
    "account_no": "1234567890",
    "account_name": "OPEN PAY CO LTD"
  },
  "bank_code": "SCB",
  "bank_name": "Siam Commercial Bank",
  "account_no": "1234567890",
  "account_holder": "OPEN PAY CO LTD"
}

把 order_no 与你的 merchant_order_no 对应后保存。后续查询与回调对账会用到。

示例 2:收银台会话 ​

创建一笔收银台会话,并把付款人导到 cashier_url。

bash
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit/cashier \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d '{
    "merchant_order_no": "DEP-20260520-0002",
    "currency_code": "THB",
    "user_id": "user-42",
    "callback_url": "https://merchant.example.com/api/pg/callback"
  }'

响应:

json
{
  "cashier_url": "https://pre-cashier.open-pay.co/?token=eyJhbGciOi...",
  "cashier_token": "eyJhbGciOi...",
  "session_id": "0a3f5c2e-1c0b-4a3b-9c2d-1f4e7a8b9c0d",
  "expire_at": "2026-05-20T08:15:00Z"
}

把付款人的浏览器导向 cashier_url。不要把该页面嵌在隐藏的 iframe 里;收银台页面预期执行在顶层浏览器上下文中。

示例 3:带预填金额的收银台会话 ​

当你的结账流程已经收集金额,但仍希望 Payment Gateway 托管付款信息页时,使用此模式。金额会套用与托管面额选择相同的面额/自定义金额验证。

bash
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit/cashier \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d '{
    "merchant_order_no": "DEP-20260520-0003",
    "amount": "1000.00",
    "currency_code": "THB",
    "user_id": "user-42",
    "callback_url": "https://merchant.example.com/api/pg/callback"
  }'

响应:

json
{
  "cashier_url": "https://pre-cashier.open-pay.co/?token=eyJhbGciOi...",
  "cashier_token": "eyJhbGciOi...",
  "session_id": "1d0f5c2e-2c0b-4a3b-9c2d-1f4e7a8b9c0d",
  "expire_at": "2026-05-20T08:15:00Z"
}

把付款人导向 cashier_url。托管页面会直接载入付款信息,不会显示面额选择。

示例 4:创建后查询充值订单 ​

以网关的 order_no 查询:

bash
curl https://pre-api.open-pay.co/api/v1/merchant/query/deposit/D2026052012345678 \
  -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/deposit?merchant_order_no=DEP-20260520-0001" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"

两者都会回传完整的 DepositOrderResponse schema,包含 status、amount、actual_amount、matched_at、completed_at 与 callback_status。用响应内容对齐你内部的记录。

示例 5:merchant_order_no 重复 ​

如果你以一个对该商户已存在的 merchant_order_no 调用 POST /deposit(或 POST /deposit/cashier),后端会拒绝该请求:

json
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "success": false,
  "code": "CONFLICT",
  "message": "Duplicate merchant order number: DEP-20260520-0001",
  "data": null
}

建议的恢复方式:不要盲目重试。先查询。

bash
curl "https://pre-api.open-pay.co/api/v1/merchant/query/deposit?merchant_order_no=DEP-20260520-0001" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG"

如果现有订单已是 COMPLETED,就结束。如果仍是 PENDING 且尚未超时,把该订单的 payment_url 或银行信息回传给付款人,不要创建新订单。

状态转换 ​

DepositOrder 状态机 ​

From → To触发条件商户观察到的现象
PENDING → MATCHED银行 webhook 匹配上订单的 expected_remark 或浮动金额,或渠道发来已付款通知。后端设置 matched_at。尚无回调。可通过查询看到状态。
MATCHED → COMPLETED账本把资金提交到商户余额。后端设置 completed_at、actual_amount。触发已签名回调。这是成功充值唯一的一次回调。
PENDING → EXPIREDexpire_at 已过且没有付款匹配上。后台任务会搬动该列。订单不会自动取消;它会明确转成 EXPIRED。多数渠道会发送已签名的 deposit.expired 回调;超时后仍可能确认付款的渠道不会发送。通过查询对账。
EXPIRED → COMPLETED渠道在订单超时后才确认付款(渠道迟到的通知或状态重查),或运营人员核实付款后补单(例如依付款人回单或 UTR)。资金在此时入账。触发已签名的 deposit.completed 回调;即使你之前已收到 deposit.expired 也一样。参见超时后补单成功。
PENDING → CANCELLED / MATCHED → CANCELLED运营人员或商户明确取消。对于账户订单,预留的充值额度会被释放。取消不会触发回调。通过查询或运营人员通知对账。
PENDING → FAILED / MATCHED → FAILED渠道回报终态失败(例如渠道订单被上游银行拒绝)。是否触发回调视渠道而定。通过查询对账。

MATCHED 与 EXPIRED 都不是终态

MATCHED 订单在少见情况下仍可能转到 CANCELLED 或 FAILED(匹配后的渠道错误、运营人员介入)。EXPIRED 订单在付款迟确认时仍可能转到 COMPLETED。少数渠道上,运营人员核实付款后也可能补回 FAILED 订单。只有 COMPLETED 是终态。

超时后补单成功 ​

付款人可能在 expire_at 之后才付款,渠道也可能迟确认付款。付款人也可能拿回单或 UTR 反馈漏单,由运营人员核实后补单。以上情况网关都会将款项入账到原订单,并把订单从 EXPIRED 转成 COMPLETED。所有币种都适用。

你的接入要能处理以下顺序:

  1. 收到 deposit.expired 或查询返回 EXPIRED 时,在你的系统把订单标为超时,但保留转成完成的可能。
  2. 之后同一个 order_no 收到 deposit.completed 时,以 actual_amount 为付款人入账,并回 2xx。
  3. 不要因为本地订单已超时就对该回调回 4xx。4xx 会停止所有重试,付款人在你那边就不会入账。

以 (order_no, status) 做幂等即可处理:expired 与 completed 是同一笔订单的不同状态。

CashierSession 状态机 ​

From → To触发条件商户观察到的现象
PENDING → SUBMITTED不带 amount 的 session:付款人在收银台页面选定面额(与银行卡,若有要求)。后端创建 DepositOrder 并以 deposit_order_id 串接。此步骤不会触发回调。充值生命周期从此开始。
创建时即为 SUBMITTEDcashier session 请求带 amount。后端会立即创建 DepositOrder、以 deposit_order_id 串接,托管页面跳过面额选择。此步骤不会触发回调。商户可立即查询对应的 DepositOrder。
PENDING → EXPIRED会话的 expire_at 已过且付款人始终未提交。下一次触碰该会话的请求会把它标记为超时;运营人员明确动作也可以搬动。不会触发回调。会话无法续用;创建一笔新的。

不存在 SUBMITTED → EXPIRED 路径。一旦会话被提交,与之关联的 DepositOrder 会拥有自己的状态机;会话就形同已封存的记录。

陷阱 ​

  1. 在不同的充值意图间重用 merchant_order_no。 后端会以 HTTP 409 与标准错误信封中的 CONFLICT code 拒绝。现在传输层会正确使用 HTTP 409。为每一笔新的充值生成全新的 merchant_order_no。

  2. 重试时换新的 merchant_order_no。 与陷阱 1 相反。POST /deposit 过程中网络抖动时,若你用新的 merchant_order_no 重试,第一个请求可能其实已经到了,于是你会留下两笔订单。重试时永远使用相同的 merchant_order_no;重复检查会把冲突浮现出来,让你可以查询既有订单。参见 CONFLICT 与 BUSINESS_ERROR。

  3. 收银台会话在付款人完成前就超时。 会话无法续用。商户必须再调用一次 POST /deposit/cashier,并把付款人导向新的 cashier_url。在你的终端用户 UI 上把 expire_at 预期说清楚。

  4. 忘了验证回调签名。 每一次回调都使用与外发请求相同的 MD5 排序拼接签名方案。略过验证会让你暴露于伪造回调的风险,并破坏幂等假设。参见 回调 与 INVALID_SIGNATURE。

  5. 对「同一笔」订单混用 Direct API 与收银台模式。 Direct API 与收银台产生不同的 DepositOrderType 值(DIRECT vs CASHIER)。即使 merchant_order_no 相同,它们也不能互换。一个逻辑充值尝试只挑一种模式。

  6. 以 payment_url 是否存在来判断收银台模式。 这样判断是错的。收银台会话回传 cashier_url,不是 payment_url。Direct API 渠道订单回传 payment_url。Direct API 账户订单两者都不会回传。要分支判断,依照你调用的是哪个端点,或依查询响应里的 order_type。

  7. 把 MATCHED 当成终态。 MATCHED 的意思是「付款已认定,账本尚未提交」。它仍可能转到 CANCELLED 或(少见地)FAILED。已签名的回调是在 COMPLETED 触发,不是在 MATCHED。参见状态转换。

  8. 假设 amount 等于 requested_amount。 对于使用金额浮动对账的币种与渠道,网关会调整应付金额,以区分同时汇到同一银行账户的多笔转账。展示指示时,要采用 amount(付款人实际付的)而非 requested_amount(你要求的)。这两个字段在 DepositOrderResponse 与 DepositPaymentInfo 上都有清楚命名。

  9. 把 EXPIRED 当成终态。 超时订单会转到 DepositOrderStatus.EXPIRED。渠道之后才确认的付款,或运营人员补单的付款,仍会完成原订单并触发 deposit.completed。超时订单要保留转成完成的可能。参见超时后补单成功;对超时订单上的操作会得到怎样的响应,参见 BUSINESS_ERROR。

  10. 在 UI 写死银行账户信息。 DepositPaymentInfo 上的 payment_info、bank_code、bank_name、account_no 与 account_holder 字段会依订单而变——路由引擎可能根据负载、币种或浮动可用性挑选不同账户。响应给什么就显示什么;千万不要从你自己的数据库预填。

  11. 以轮询 /query/deposit/ 取代依赖回调。 轮询会浪费你的 API 配额并增加延迟。回调会在 COMPLETED 触发一次。GET /query/deposit/{order_no} 只应作为错过回调时的对账备援。重试语义参见 Callbacks。

  12. 没处理信用额度设置账号的 INSUFFICIENT_MERCHANT_BALANCE。 对充值来说罕见,但如果你的账号启用了预付手续费扣款,就有可能遇到。参见 INSUFFICIENT_MERCHANT_BALANCE。

  13. 在提交前以 merchant_order_no 重新查询收银台会话。 GET /query/deposit?merchant_order_no=... 端点查的是 DepositOrder,不是 CashierSession。PENDING 的收银台会话此时还没有 DepositOrder,所以查询会回 404 NOT_FOUND。这不是 bug。若 cashier 请求已带 amount,订单会立即创建,此限制不适用。

  14. 把 cashier_token 当成长期凭证信任。 收银台 token 是短效、仅限该会话的已签名 JWS。会话更新时它会轮替。永远不要保存它;每次都以 POST /deposit/cashier 重新开始。

上线检查清单 ​

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

  • [ ] 回调 URL 走 HTTPS、有监控,并在 5 秒内回传 200 OK(或响应体含 success)。
  • [ ] 已实现回调签名验证,并有单元测试。参见 Authentication。
  • [ ] 你生产环境的 NAT 出口 IP 已加入 Cloudflare Access 允许清单。Cloudflare 边缘来源 IP 模型参见 Sandbox。
  • [ ] 幂等策略已决定:POST /deposit 重试时使用相同的 merchant_order_no,且你已有处理重复响应的代码路径。
  • [ ] currency_code 的值会被验证为你商户账号已启用的币种。送不支持的币种会回 BUSINESS_ERROR。
  • [ ] Mock channel 沙盒测试针对 Direct API 与收银台两种模式都已通过,至少涵盖 happy path 与 EXPIRED 分支。
  • [ ] 生产环境的 API key 与签名 secret 存于你的 secret manager。它们不在源代码里、不在 CI 日志里、也不在客户端 bundle 里。
  • [ ] 你已决定是否要把网关的 order_no 暴露给终端用户。把它用于客服流程是安全的,但它并不是给付款人看的标识符。

交叉引用 ​

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