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 而非真實渠道的保證訊號。正式環境的回應永遠不會帶這個旗標。

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

沙盒特有的行為 ​

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

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

請求側沒有 is_sandbox 或 mode=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_key 打 api.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,以及欄位層級的說明。