沙盒
PRE 是 Payment Gateway 的沙盒環境。它跑的程式碼跟正式環境一樣,但對應的是獨立的資料庫、獨立的渠道帳戶與模擬資金,因此你可以在不動用真實金流的情況下建構並驗證接入。客戶經理應該已經為你開通一組沙盒 merchant_id、一組沙盒 api_key,並將你的來源 IP 加入網路層白名單。如果這三項你沒有齊全,先停下來聯絡客戶經理,再繼續往下讀。
本章假設你已經讀過 認證與簽章,並在自選語言中已經有可運作的簽章實作。
環境 URL
沙盒與正式環境是部署在不同主機名上的獨立部署。它們共用 schema、回應信封與錯誤代碼,但其他一切都不共用——訂單、餘額、渠道路由與憑證全部隔離。
| 沙盒 (PRE) | 正式環境 (OL) | |
|---|---|---|
| API base | https://pre-api.open-pay.co | https://api.open-pay.co |
| 收銀台 | https://pre-cashier.open-pay.co | https://cashier.open-pay.co |
| 商戶後台 | https://pre-merchant.open-pay.co | https://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 驗證:
#!/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#!/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
/**
* 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']);#!/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));接著送出請求:
# 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¤cy_code=THB&merchant_order_no=SANDBOX-0001×tamp=${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 會填入付款人應該轉帳的銀行資訊:
{
"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 而非真實渠道的保證訊號。正式環境的回應永遠不會帶這個旗標。
完整的信封契約(code、message、data)以及非 2xx 狀態回傳的錯誤代碼目錄,見 錯誤。
沙盒特有的行為
沙盒跑的後端程式碼跟正式環境一樣,但背後的資料與渠道不同。你需要納入規劃的差異:
- 資金是模擬的。 沒有真實金流經過沙盒。記到你商戶帳戶上的餘額只是帳上分錄。不要用沙盒去驗證帳務對帳跟銀行對帳單的一致性。
- 可以使用 mock channel。 Payment Gateway 維運團隊可以把你的沙盒商戶路由到內部的 mock channel adapter。路由到 mock channel 的訂單會回傳確定性的泰國銀行帳戶資訊,並出現在 Payment Gateway 工程師使用的內部 mock-channel UI 中。你可以用
payment_info.mock === true辨識 mock 訂單。MockAdapter 是內部服務——不對商戶開放,也沒有給商戶使用的入口。 - 真實渠道在沙盒中不一定可用。 部分上游渠道(TGCPay、MPay 等)不對外提供沙盒端點。你的沙盒商戶可用的渠道集合可能比正式環境小。跟客戶經理確認你的沙盒接了哪些渠道,據此安排整合測試。
- 狀態轉換可以調整。 維運團隊可以把沙盒渠道設定為回傳指定狀態(
PENDING、COMPLETED、FAILED)以利測試。如果你需要在沙盒商戶上重現特定失敗模式,跟客戶經理講,不要等真實渠道剛好出錯。 - 回調照常觸發。 沙盒會根據每筆訂單上送的
callback_url發送回調,簽章方式跟正式環境相同。使用一個你可控的、對外可達的 HTTPS 端點——request-bin、打進你 dev box 的 ngrok tunnel,或是你自家基礎設施中的沙盒專用主機都可以。 - 速率限制可能不同。 沙盒通常比正式環境寬鬆。不要用沙盒驗證你是否能保持在正式環境的速率限制以下;壓測紀律必須在設計階段就做,而不是靠對 PRE 連續打點。
- 沙盒資料可能會被清掉。 後台可能定期清理沙盒訂單、帳戶與餘額。把沙盒狀態當成短暫的。
請求側沒有 is_sandbox 或 mode=test 之類的旗標。環境完全由 base URL 與憑證決定。
沙盒與正式環境對照
| 面向 | 沙盒 (PRE) | 正式環境 (OL) |
|---|---|---|
| API base URL | https://pre-api.open-pay.co | https://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 到版控。它們刻意比正式環境金鑰少權限,但風險紀律一樣:洩漏的金鑰會被輪替,而輪替會中斷你的測試。
常見陷阱
下面是商戶開始接沙盒時最常見的失敗樣態。動工前先掃過這份清單——每一項都是真的有人開過工單。
- 拿沙盒
api_key打api.open-pay.co。 正式環境會用401 Unauthorized拒絕沙盒金鑰。確認你的 base URL 與金鑰前綴對得起來。常見成因是你自家 CI 中 staging 與 production 部署之間的環境變數互相污染。 - 不在 Cloudflare Access 白名單上。 如果你每次請求都得到 HTML 回應(不是 JSON),或 TLS handshake 直接失敗,你是在網路層被擋。從一個你確定在白名單上的 IP 測一次;如果那邊能通,就是你的新 IP 還沒被加進去。聯絡客戶經理。
- 把沙盒餘額當真實餘額用。 沙盒資金是模擬的。不要用沙盒餘額去驗證財報跟銀行對帳單的一致性,也不要拿沙盒吞吐量去推算營收。
- 在正式環境程式路徑硬編碼沙盒 base URL。 Base URL 從設定讀。沒人 review 就躺在你 codebase 裡的
pre-api.open-pay.co字串,是「我們不小心把 prod 指到沙盒」這類事故最常見的單一成因。 - 沙盒重置後重用
merchant_order_no。 當後台清理沙盒資料時,你之前用過的 ID 如果重播同一份 fixture 可能撞號。要麼把沙盒訂單號加上跑次專屬的前綴(例如 timestamp 或 CI build ID),要麼接受 fixture 重用可能會冒出重複訂單錯誤。 - 把正式環境的回調 URL 指向沙盒流量。 沙盒事件落到你正式環境的回調 handler,會污染你的正式環境日誌、正式環境對帳報表,可能還會污染你的正式環境帳務。每筆沙盒訂單都務必提供一個沙盒專用的
callback_url。 - 同一個客戶端裡混用沙盒與正式環境憑證。 單一 HTTP client 實例同時握兩把金鑰、靠旗標選一把,是脆弱的設計。明確命名後建立兩個 client,永遠不要讓設定「掉到」錯的那一個。
- 以為沙盒渠道的時序跟正式環境渠道一樣。 Mock channel 在毫秒級回應。正式環境的真實渠道可能要幾秒(甚至更久)才確認一筆充值。不要寫針對沙盒調過的逾時或輪詢間隔;要針對你預期支援的最慢真實渠道調。
- 沒測 5 分鐘 timestamp 視窗。 沙盒對
X-Timestamp視窗的執行跟正式環境一樣。如果你的測試 fixture 用凍結時鐘,一旦超過 5 分鐘漂移,你的簽章請求就會開始失敗。每個請求都要重新產生X-Timestamp。 - 沒測來源 IP 失敗模式。 一切都通之後,刻意從一個不在 Cloudflare Access 白名單的 IP 發一次請求,確認你客戶端能乾淨地處理邊緣封鎖。來源 IP 只在 Cloudflare 邊緣把關,被擋時看到的是 TLS handshake 失敗或 HTML 封鎖頁面(不是 JSON 信封)。Cloudflare 終究會擋下某些流量(防火牆變更、誤部署、IP 輪替),你的錯誤處理路徑必須在那之前先跑過。
- 依
message文字而非code做分支。 錯誤訊息的文字會變。信封中的code欄位才是穩定契約。錯誤代碼目錄見 錯誤。 - 在沙盒中忘了驗證回調簽章。 沙盒回調的簽章方式跟正式環境完全一樣。如果你在沙盒 handler 跳過簽章驗證、理由是「反正只是測試」,你會把同一段程式碼帶到正式環境。兩個環境都要驗。