Skip to content

Sandbox

PRE is the Payment Gateway sandbox environment. It runs the same code as production against an isolated database, isolated channel accounts, and simulated funds, so you can build and verify your integration without moving real money. Your account manager should already have provisioned you a sandbox merchant_id, a sandbox api_key, and added your source IPs to the network-layer allowlist. If you do not have those three things, stop and contact your account manager before reading further.

This chapter assumes you have read Authentication & Signing and have a working signature implementation in your language of choice.

Environment URLs

Sandbox and production are separate deployments on different hostnames. They share schemas, response envelopes, and error codes, but nothing else is shared — orders, balances, channel routing, and credentials are all isolated.

Sandbox (PRE)Production (OL)
API basehttps://pre-api.open-pay.cohttps://api.open-pay.co
Cashierhttps://pre-cashier.open-pay.cohttps://cashier.open-pay.co
Merchant portalhttps://pre-merchant.open-pay.cohttps://merchant.open-pay.co

Sandbox credentials will be rejected by production and vice versa. Switching environments is a one-line change to the base URL — your signing code, JSON schemas, and HTTP headers do not change.

Access control

Source IP is controlled at the Cloudflare edge before a request ever reaches the application code. Understanding how an edge block looks is essential for debugging, because it does not produce a JSON envelope.

Cloudflare Access (network)

pre-api.open-pay.co sits behind Cloudflare Access with a source-IP allowlist maintained by the Payment Gateway back office. Requests originating from an IP that is not on the allowlist never reach the application — Cloudflare intercepts them at the edge and returns either a TLS handshake failure or an HTML challenge/block page (not a JSON envelope).

Symptom of a Cloudflare-edge block: an HTML response (Content-Type: text/html) or a TCP connection refused. There is no code field to branch on, because no Payment Gateway code ran.

To add, remove, or rotate the IPs on your Cloudflare Access allowlist, contact your account manager. Do not assume your sandbox traffic will work from a new office, a new datacenter, or a new CI runner pool until the IP has been added.

Your first sandbox request

Below is a worked example of creating a deposit order against sandbox. The endpoint, headers, and body schema are identical to production — only the base URL and api_key differ.

Sign and send

Compute the signature using the helpers from Authentication & Signing. The reference implementations are byte-for-byte verified against the backend:

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

Then send the request:

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"
  }'

Expected response

A successful deposit-order creation returns HTTP 201 Created and the standard envelope. Channel-routed orders include payment_url and payment_info populated with the bank details the payer should transfer to:

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."
  }
}

The "mock": true flag inside payment_info is your guaranteed signal that the order was routed to a mock channel rather than a real one. Production responses never carry this flag.

For the full envelope contract (code, message, data) and the catalog of error codes returned at non-2xx statuses, see Errors.

Sandbox-specific behavior

Sandbox runs the same backend code as production, but the data and channels behind it differ. The deltas you need to plan for:

  • Funds are simulated. No real money moves through sandbox. Balances credited to your merchant account are bookkeeping entries only. Do not use sandbox to verify accounting reconciliation against bank statements.
  • Mock channel is available. The Payment Gateway operations team can route your sandbox merchant to an internal mock channel adapter. Orders routed to the mock channel return deterministic Thai bank account details and surface in an internal mock-channel UI used by Payment Gateway engineers. You will recognize mock orders by payment_info.mock === true. The mock adapter is an internal service — it is not redistributed to merchants, and there is no merchant-facing portal for it.
  • Real channels may not be enabled in sandbox. Some upstream channels (TGCPay, MPay, etc.) decline to expose sandbox endpoints. Your sandbox merchant may have a smaller set of channels available than production. Confirm with your account manager which channels are wired in your sandbox so you can structure your integration tests accordingly.
  • Status transitions can be tuned. Sandbox channels can be configured by the operations team to return specific statuses (PENDING, COMPLETED, FAILED) for testing. If you need a specific failure mode reproduced against your sandbox merchant, ask your account manager rather than waiting for a real channel to fail.
  • Callbacks fire normally. Sandbox sends callbacks to whatever callback_url you supply on each order, using the same signing scheme as production. Use a publicly reachable HTTPS endpoint you control — a request-bin, an ngrok tunnel into your dev box, or a sandbox-only host in your own infrastructure.
  • Rate limits may differ. Sandbox is typically more permissive than production. Do not use sandbox to validate that you stay under production rate ceilings; load-test discipline must happen at the design level, not by hammering PRE.
  • Sandbox data may be reset. The back office may periodically clean up sandbox orders, accounts, and balances. Treat sandbox state as ephemeral.

There are no is_sandbox or mode=test flags on the request side. The environment is selected entirely by the base URL and the credentials.

Sandbox vs production comparison

AspectSandbox (PRE)Production (OL)
API base URLhttps://pre-api.open-pay.cohttps://api.open-pay.co
Channels availableMock + sandbox-enabled real channelsAll real channels
FundsSimulated, no real flowReal flow
CredentialsSandbox-only api_keyProduction api_key
Network accessCloudflare Access IP allowlist (back office)Cloudflare Access IP allowlist (back office)
Data lifecycleMay be reset by operationsPersistent, regulated
Rate limitsTypically more permissiveProduction limits enforced
CallbacksFire to your callback_url per orderFire to your callback_url per order

The signing rules, the JSON envelope shape, the field names, and the error codes are identical across both columns. Anything that differs in your client code between sandbox and production is configuration, not logic.

Changing credentials or IPs

To rotate your sandbox api_key, to add or remove source IPs on the Cloudflare Access allowlist, or to provision additional sandbox sub-accounts under your merchant: contact your account manager.

Do not commit sandbox credentials to source control. They are intentionally less privileged than production keys, but the same risk discipline applies: leaked keys get rotated, and rotations interrupt your testing.

Common pitfalls

The following are the failure patterns we see most often when merchants start on sandbox. Skim the list before you start integrating — each item is a real ticket someone has filed.

  1. Sandbox api_key against api.open-pay.co. Production rejects sandbox keys with 401 Unauthorized. Confirm your base URL and your key prefix match. A common cause is environment-variable bleed between staging and production deploys in your own CI.
  2. Missing from the Cloudflare Access allowlist. If you get an HTML response (not JSON) on every request, or your TLS handshake fails outright, you are being blocked at the Cloudflare edge. Test from an IP you know is on the allowlist; if it works there, your new IP needs to be added. Contact your account manager.
  3. Treating sandbox balances as real. Sandbox funds are simulated. Do not use sandbox balances to validate financial reporting against bank statements, and do not extrapolate sandbox throughput to revenue forecasts.
  4. Hard-coding the sandbox base URL in production code paths. Read the base URL from configuration. Hard-coded pre-api.open-pay.co strings sitting unreviewed in your codebase are the single most common cause of "we accidentally pointed prod at sandbox" incidents.
  5. Reusing merchant_order_no after a sandbox reset. When the back office cleans up sandbox data, IDs you previously used can collide if you replay the same fixture set. Either prefix sandbox order numbers with a run-specific token (e.g. a timestamp or a CI build ID) or accept that fixture reuse may surface duplicate-order errors.
  6. Pointing your production callback URL at sandbox traffic. Sandbox events landing in your production callback handler will pollute your production logs, your production reconciliation reports, and possibly your production accounting. Always provide a sandbox-only callback_url on each sandbox order.
  7. Mixing sandbox and production credentials in the same client. A single HTTP client instance holding both keys, picking one based on a flag, is brittle. Instantiate two clients, named explicitly, and never let configuration "fall through" to the wrong one.
  8. Assuming sandbox-channel timing matches production-channel timing. Mock channels respond in milliseconds. Real channels in production may take seconds (or longer) to confirm a deposit. Do not write timeouts or polling intervals tuned to sandbox; tune them to the slowest real channel you expect to support.
  9. Not testing the 5-minute timestamp window. Sandbox enforces the X-Timestamp window the same as production. If your test fixtures use a frozen clock, your signed requests will start failing the moment they drift past five minutes. Generate X-Timestamp fresh for every request.
  10. Not testing Cloudflare-edge IP-failure modes. Once everything works, deliberately make a request from an IP that is not on the Cloudflare Access allowlist and confirm your client handles the edge block cleanly. Source-IP enforcement happens at the Cloudflare edge, so a block shows up as a TLS handshake failure or an HTML challenge/block page (not a JSON 403). Cloudflare WILL eventually block you (firewall change, mis-deploy, IP rotation) and your error-handling code path needs to be exercised before that happens.
  11. Branching on message text rather than code. Error messages are subject to wording changes. The code field in the envelope is the stable contract. See Errors for the catalog.
  12. Forgetting to verify callback signatures in sandbox. Sandbox callbacks are signed the same way as production callbacks. If you skip signature verification in your sandbox handler "because it's just testing," you will ship that code path to production. Verify in both environments.

Cross-references

  • Authentication & Signing — request signing, the 5-minute timestamp window, and the reference signing implementations in four languages.
  • Quickstart — end-to-end walkthrough of your first deposit and callback against sandbox.
  • Errors — the JSON envelope shape and the catalog of code values you should branch on.
  • API Reference — full endpoint listing, request/response schemas, and field-level documentation.