Skip to content

沙盒

PRE 是 Payment Gateway 的沙盒環境。它跑的程式碼跟正式環境一樣,但對應的是獨立的資料庫、獨立的渠道帳戶與模擬資金,因此你可以在不動用真實金流的情況下建構並驗證接入。客戶經理應該已經為你開通一組沙盒 merchant_id、一組沙盒 api_key,並將你的來源 IP 加入網路層白名單。如果這三項你沒有齊全,先停下來聯絡客戶經理,再繼續往下讀。

本章假設你已經讀過 認證與簽章,並在自選語言中已經有可運作的簽章實作。

環境 URL

沙盒與正式環境是部署在不同主機名上的獨立部署。它們共用 schema、回應信封與錯誤代碼,但其他一切都不共用——訂單、餘額、渠道路由與憑證全部隔離。

沙盒 (PRE)正式環境 (OL)
API basehttps://pre-api.open-pay.cohttps://api.open-pay.co
收銀台https://pre-cashier.open-pay.cohttps://cashier.open-pay.co
商戶後台https://pre-merchant.open-pay.cohttps://merchant.open-pay.co

沙盒憑證會被正式環境拒絕,反之亦然。切換環境只需要改一行 base URL——你的簽章程式碼、JSON schema 與 HTTP 標頭都不需要變動。

存取控制

沙盒的來源 IP 管控由 Cloudflare 邊緣統一把關。請求必須通過這層,才會抵達應用程式碼。

Cloudflare Access(網路層)

pre-api.open-pay.co 位於 Cloudflare Access 之後,背後是由 Payment Gateway 後台維護的來源 IP 白名單。從不在白名單上的 IP 發出的請求永遠到不了應用程式——Cloudflare 在邊緣節點就攔截掉,並回傳 TLS handshake 失敗,或是一個 HTML 驗證頁面或封鎖頁面(不是 JSON 信封)。

Cloudflare 層被擋的特徵:HTML 回應(Content-Type: text/html)或 TCP 連線被拒。沒有 code 欄位可以分支判斷,因為 Payment Gateway 的程式碼根本沒跑。

要新增、移除或輪替 Cloudflare Access 白名單上的 IP,聯絡你的客戶經理。不要假設你的沙盒流量從新辦公室、新機房、新 CI runner pool 一定能通——在 IP 加進去之前都不會通。

你的第一個沙盒請求

下面是一個對沙盒建立充值訂單的完整範例。端點、標頭與 body schema 跟正式環境完全相同——只有 base URL 與 api_key 不同。

簽章與送出

認證與簽章 章節提供的 helper 計算簽章。參考實作已對後端逐 byte 驗證:

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

接著送出請求:

bash
# Replace these with the values from your account manager.
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL="https://pre-api.open-pay.co"
TIMESTAMP=$(date +%s)

# Build the params (alphabetical order is for signing, not for transport).
PARAMS="amount=100.00&callback_url=https://your-domain.example/callbacks/deposit&currency_code=THB&merchant_order_no=SANDBOX-0001&timestamp=${TIMESTAMP}"
SIGNATURE=$(./sign.sh --api-key "$API_KEY" --params "$PARAMS")

curl -X POST "${BASE_URL}/api/v1/merchant/orders/deposit" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ${API_KEY}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "X-Signature: ${SIGNATURE}" \
  -d '{
    "merchant_order_no": "SANDBOX-0001",
    "amount": "100.00",
    "currency_code": "THB",
    "callback_url": "https://your-domain.example/callbacks/deposit"
  }'

預期回應

成功建立充值訂單會回傳 HTTP 201 Created 與標準信封。路由到渠道的訂單會帶上 payment_url,而 payment_info 會填入付款人應該轉帳的銀行資訊:

json
{
  "success": true,
  "code": "SUCCESS",
  "message": "OK",
  "data": {
    "order_no": "DEP20260519000001",
    "amount": "100.00",
    "currency_code": "THB",
    "expire_at": "2026-05-19T08:15:00Z",
    "payment_url": "https://pre-cashier.open-pay.co/pay/...",
    "payment_info": {
      "bank_code": "KBANK",
      "bank_name": "ธนาคารกสิกรไทย (Kasikorn Bank)",
      "account_no": "1234567890",
      "account_name": "Mock Payment Co., Ltd.",
      "mock": true
    },
    "bank_code": "KBANK",
    "bank_name": "ธนาคารกสิกรไทย (Kasikorn Bank)",
    "account_no": "1234567890",
    "account_holder": "Mock Payment Co., Ltd."
  }
}

payment_info 內的 "mock": true 旗標是你判別訂單被路由到 mock channel 而非真實渠道的保證訊號。正式環境的回應永遠不會帶這個旗標。

完整的信封契約(codemessagedata)以及非 2xx 狀態回傳的錯誤代碼目錄,見 錯誤

沙盒特有的行為

沙盒跑的後端程式碼跟正式環境一樣,但背後的資料與渠道不同。你需要納入規劃的差異:

  • 資金是模擬的。 沒有真實金流經過沙盒。記到你商戶帳戶上的餘額只是帳上分錄。不要用沙盒去驗證帳務對帳跟銀行對帳單的一致性。
  • 可以使用 mock channel。 Payment Gateway 維運團隊可以把你的沙盒商戶路由到內部的 mock channel adapter。路由到 mock channel 的訂單會回傳確定性的泰國銀行帳戶資訊,並出現在 Payment Gateway 工程師使用的內部 mock-channel UI 中。你可以用 payment_info.mock === true 辨識 mock 訂單。MockAdapter 是內部服務——不對商戶開放,也沒有給商戶使用的入口。
  • 真實渠道在沙盒中不一定可用。 部分上游渠道(TGCPay、MPay 等)不對外提供沙盒端點。你的沙盒商戶可用的渠道集合可能比正式環境小。跟客戶經理確認你的沙盒接了哪些渠道,據此安排整合測試。
  • 狀態轉換可以調整。 維運團隊可以把沙盒渠道設定為回傳指定狀態(PENDINGCOMPLETEDFAILED)以利測試。如果你需要在沙盒商戶上重現特定失敗模式,跟客戶經理講,不要等真實渠道剛好出錯。
  • 回調照常觸發。 沙盒會根據每筆訂單上送的 callback_url 發送回調,簽章方式跟正式環境相同。使用一個你可控的、對外可達的 HTTPS 端點——request-bin、打進你 dev box 的 ngrok tunnel,或是你自家基礎設施中的沙盒專用主機都可以。
  • 速率限制可能不同。 沙盒通常比正式環境寬鬆。不要用沙盒驗證你是否能保持在正式環境的速率限制以下;壓測紀律必須在設計階段就做,而不是靠對 PRE 連續打點。
  • 沙盒資料可能會被清掉。 後台可能定期清理沙盒訂單、帳戶與餘額。把沙盒狀態當成短暫的。

請求側沒有 is_sandboxmode=test 之類的旗標。環境完全由 base URL 與憑證決定。

沙盒與正式環境對照

面向沙盒 (PRE)正式環境 (OL)
API base URLhttps://pre-api.open-pay.cohttps://api.open-pay.co
可用渠道mock + 已開放沙盒的真實渠道全部真實渠道
資金模擬,無真實流動真實流動
憑證僅沙盒使用的 api_key正式環境的 api_key
網路存取Cloudflare Access IP 白名單(後台維護)Cloudflare Access IP 白名單 + 標準認證
資料生命週期維運可能清掉持久保存,受監管
速率限制通常較寬鬆套用正式環境限額
回調依每筆訂單發到你的 callback_url依每筆訂單發到你的 callback_url

兩欄之間的簽章規則、JSON 信封結構、欄位名稱與錯誤代碼完全相同。你客戶端程式碼在沙盒與正式環境之間有任何差異,那都是設定差異,不是邏輯差異。

變更憑證或 IP

要輪替沙盒 api_key、要在 Cloudflare Access 新增或移除來源 IP、要在你的商戶底下開通額外的沙盒子帳戶:聯絡客戶經理。

不要把沙盒憑證 commit 到版控。它們刻意比正式環境金鑰少權限,但風險紀律一樣:洩漏的金鑰會被輪替,而輪替會中斷你的測試。

常見陷阱

下面是商戶開始接沙盒時最常見的失敗樣態。動工前先掃過這份清單——每一項都是真的有人開過工單。

  1. 拿沙盒 api_keyapi.open-pay.co 正式環境會用 401 Unauthorized 拒絕沙盒金鑰。確認你的 base URL 與金鑰前綴對得起來。常見成因是你自家 CI 中 staging 與 production 部署之間的環境變數互相污染。
  2. 不在 Cloudflare Access 白名單上。 如果你每次請求都得到 HTML 回應(不是 JSON),或 TLS handshake 直接失敗,你是在網路層被擋。從一個你確定在白名單上的 IP 測一次;如果那邊能通,就是你的新 IP 還沒被加進去。聯絡客戶經理。
  3. 把沙盒餘額當真實餘額用。 沙盒資金是模擬的。不要用沙盒餘額去驗證財報跟銀行對帳單的一致性,也不要拿沙盒吞吐量去推算營收。
  4. 在正式環境程式路徑硬編碼沙盒 base URL。 Base URL 從設定讀。沒人 review 就躺在你 codebase 裡的 pre-api.open-pay.co 字串,是「我們不小心把 prod 指到沙盒」這類事故最常見的單一成因。
  5. 沙盒重置後重用 merchant_order_no 當後台清理沙盒資料時,你之前用過的 ID 如果重播同一份 fixture 可能撞號。要麼把沙盒訂單號加上跑次專屬的前綴(例如 timestamp 或 CI build ID),要麼接受 fixture 重用可能會冒出重複訂單錯誤。
  6. 把正式環境的回調 URL 指向沙盒流量。 沙盒事件落到你正式環境的回調 handler,會污染你的正式環境日誌、正式環境對帳報表,可能還會污染你的正式環境帳務。每筆沙盒訂單都務必提供一個沙盒專用的 callback_url
  7. 同一個客戶端裡混用沙盒與正式環境憑證。 單一 HTTP client 實例同時握兩把金鑰、靠旗標選一把,是脆弱的設計。明確命名後建立兩個 client,永遠不要讓設定「掉到」錯的那一個。
  8. 以為沙盒渠道的時序跟正式環境渠道一樣。 Mock channel 在毫秒級回應。正式環境的真實渠道可能要幾秒(甚至更久)才確認一筆充值。不要寫針對沙盒調過的逾時或輪詢間隔;要針對你預期支援的最慢真實渠道調。
  9. 沒測 5 分鐘 timestamp 視窗。 沙盒對 X-Timestamp 視窗的執行跟正式環境一樣。如果你的測試 fixture 用凍結時鐘,一旦超過 5 分鐘漂移,你的簽章請求就會開始失敗。每個請求都要重新產生 X-Timestamp
  10. 沒測來源 IP 失敗模式。 一切都通之後,刻意從一個不在 Cloudflare Access 白名單的 IP 發一次請求,確認你客戶端能乾淨地處理邊緣封鎖。來源 IP 只在 Cloudflare 邊緣把關,被擋時看到的是 TLS handshake 失敗或 HTML 封鎖頁面(不是 JSON 信封)。Cloudflare 終究會擋下某些流量(防火牆變更、誤部署、IP 輪替),你的錯誤處理路徑必須在那之前先跑過。
  11. message 文字而非 code 做分支。 錯誤訊息的文字會變。信封中的 code 欄位才是穩定契約。錯誤代碼目錄見 錯誤
  12. 在沙盒中忘了驗證回調簽章。 沙盒回調的簽章方式跟正式環境完全一樣。如果你在沙盒 handler 跳過簽章驗證、理由是「反正只是測試」,你會把同一段程式碼帶到正式環境。兩個環境都要驗。

交叉參考

  • 認證與簽章 — 請求簽章、5 分鐘 timestamp 視窗,以及四種語言的參考簽章實作。
  • 快速開始 — 對沙盒做第一筆充值與回調的端到端走查。
  • 錯誤 — JSON 信封結構,以及你應該用來做分支的 code 值目錄。
  • API 參考 — 完整端點清單、請求/回應 schema,以及欄位層級的說明。