Skip to content

本章是透過 Payment Gateway 商戶 API 接受充值的權威參考。內容涵蓋兩種充值模式(Direct API 與收銀台會話)、它們的狀態機、確切的請求與回應結構,以及在正式環境會出現的陷阱。

概念與術語

術語意義
充值訂單一筆 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 會在付款人送出之後建立。若請求帶 amountDepositOrder 會與 session 同步建立,託管頁面直接顯示付款資訊。
付款人匯出資金的終端使用者,與商戶不同。付款人會與 payment_urlpayment_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_urlcashier_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)你內部的使用者識別碼。用於分組與稽核。

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_codebank_namebank_branchaccount_noaccount_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=PENDINGexpire_at 寫入,expire_at 取自商戶的 deposit_expire_minutes 設定(預設 15 分鐘)。

當付款人付款時,閘道會把訂單從 MATCHED(已認定付款)推進到 COMPLETED(帳本已結算),然後觸發回調。失敗分支(FAILEDEXPIREDCANCELLED)詳見狀態轉換

收銀台會話流程

收銀台模式的關鍵點:

  • 未帶 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 彼此獨立。 付款人送出時,訂單會根據商戶設定取得新的逾期時間。
  • 收銀台頁面可能使用 WebSocket 推播狀態更新給付款人瀏覽器,不需要輪詢。對商戶整合而言這是透明的:你還是依賴伺服器對伺服器的回調。

範例

下方片段假設你已依 Authentication 所述產生 X-TimestampX-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,包含 statusamountactual_amountmatched_atcompleted_atcallback_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_atactual_amount觸發已簽章回調。這是成功充值唯一的一次回調。
PENDING → EXPIREDexpire_at 已過且沒有付款匹配上。背景工作會搬動該列。訂單不會自動取消;它會明確轉成 EXPIRED,並停止接受付款。預設不會為逾期觸發回調。透過查詢對帳。
PENDING → CANCELLED / MATCHED → CANCELLED操作員或商戶明確取消。對於帳戶訂單,預留的充值額度會被釋放。取消不會觸發回調。透過查詢或操作員通知對帳。
PENDING → FAILED / MATCHED → FAILED渠道回報終態失敗(例如渠道訂單被上游銀行拒絕)。是否觸發回調視渠道而定。透過查詢對帳。

MATCHED 不是終態

MATCHED 訂單在少見情況下仍可能轉到 CANCELLEDFAILED(匹配後的渠道錯誤、操作員介入)。只把 COMPLETED 當成最終成功,把 EXPIRED / CANCELLED / FAILED 當成最終失敗。

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;重複檢查會把衝突浮現出來,讓你可以查詢既有訂單。參見 CONFLICTBUSINESS_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(你要求的)。這兩個欄位在 DepositOrderResponseDepositPaymentInfo 上都有清楚命名。

  9. 忽略 expire_at 逾期訂單會轉到 DepositOrderStatus.EXPIRED 並停止接受付款。付款人在逾期後才到帳的轉帳不會自動對到該訂單,且可能需要手動對帳。對逾期訂單上的操作會得到怎樣的回應,參見 BUSINESS_ERROR

  10. 在 UI 寫死銀行帳戶資訊。 DepositPaymentInfo 上的 payment_infobank_codebank_nameaccount_noaccount_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 — 相反方向;從你的商戶餘額付款出去。