Quickstart
By the end of this guide you will have signed and sent your first sandbox deposit request, read the payment instructions returned by the API, and queried the order back from the system. The whole loop should take under ten minutes.
This chapter is a worked example. For depth on any topic it touches, follow the cross-links into the rest of the documentation.
What you need
Before you start, make sure you have all five of the following. If any are missing, contact your account manager before continuing — none of the steps below will work without them.
- A sandbox
merchant_idandapi_key, provisioned by your account manager. - Your source IP added to the sandbox Cloudflare Access allowlist. This is handled by your account manager. For background on the Cloudflare Access source-IP layer, see Sandbox.
- The signing helper for your language:
sign.sh(bash),sign.py(Python),sign.php(PHP), orsign.ts(Node.js). All four are byte-for-byte equivalent — pick the one closest to your stack. - Any HTTP client:
curl, Postman, HTTPie, or your language's HTTP library. - A clock that is within ±5 minutes of NTP. The server rejects requests outside this window.
This guide uses Python in the snippets below, but every step works identically in the other three languages.
Step 1: Sign a deposit request
Every merchant API call carries three headers: X-API-Key, X-Timestamp, and X-Signature. The signature is a lowercase-hex MD5 over the sorted request parameters with your api_key appended. The signing reference implementations live in docs-site/shared/code-samples/.
Start by deciding what payload you want to send. The deposit-create endpoint accepts the following fields, defined by DepositOrderCreate in backend/app/schemas/order.py:
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_order_no | string | yes | Your own order identifier. 1–64 chars. Must be unique per merchant. |
amount | decimal | yes | Amount to deposit. Must be > 0. Send as a string to preserve precision. |
currency_code | string | yes | ISO 4217 three-letter code, e.g. THB, VND, IDR. |
callback_url | string | yes | HTTPS URL the gateway will POST asynchronous state updates to. |
user_id | string | no | Your end user's identifier, if you want to attach one. Up to 64 chars. |
extra_data | string | no | Opaque metadata, echoed back to you on callbacks. |
For this example, use the following payload:
{
"merchant_order_no": "QUICKSTART-0001",
"amount": "100.00",
"currency_code": "THB",
"callback_url": "https://your-domain.example/callbacks/deposit"
}Now compute the signature. The Python reference helper looks like this:
#!/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())To use it, pass each request parameter — including the current Unix timestamp — as a k=v&k=v string, then append your API key:
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TIMESTAMP=$(date +%s)
PARAMS="amount=100.00&callback_url=https://your-domain.example/callbacks/deposit¤cy_code=THB&merchant_order_no=QUICKSTART-0001×tamp=${TIMESTAMP}"
SIGNATURE=$(python sign.py --api-key "$API_KEY" --params "$PARAMS")
echo "$SIGNATURE"The same call shape works with sign.sh, sign.php, and sign.ts — only the interpreter changes. The four implementations are pinned together by a CI parity test, so any of them produces an identical digest from the same inputs.
For the underlying algorithm, the worked digest example, and the four full implementations, see Authentication & Signing.
Step 2: Send the request
Once you have a fresh signature, POST the JSON body to the deposit-create endpoint. The path is /api/v1/merchant/orders/deposit — the /orders segment comes from the merchant router prefix and is easy to miss.
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL="https://pre-api.open-pay.co"
TIMESTAMP=$(date +%s)
PARAMS="amount=100.00&callback_url=https://your-domain.example/callbacks/deposit¤cy_code=THB&merchant_order_no=QUICKSTART-0001×tamp=${TIMESTAMP}"
SIGNATURE=$(python sign.py --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": "QUICKSTART-0001",
"amount": "100.00",
"currency_code": "THB",
"callback_url": "https://your-domain.example/callbacks/deposit"
}'A few placeholders to substitute before you run this:
API_KEY— the sandboxapi_keyyour account manager gave you. Sandbox keys are typically prefixedpre_.callback_url— a publicly reachable HTTPS endpoint you control. A request-bin or an ngrok tunnel into your dev machine works for the first run.merchant_order_no— unique per merchant. If you repeat the same value, the second call will fail with a duplicate-order error.
The JSON body holds the same parameters that went into the signature, minus timestamp (which travels in the X-Timestamp header). Note that the signature does not care about the JSON key order in transit — only that the keys were sorted alphabetically when you computed the digest.
Step 3: Read the response
A successful request returns HTTP 201 Created and the standard envelope. The data block is shaped by DepositPaymentInfo from backend/app/schemas/order.py. There are two flavors of data, depending on whether the order was routed to a payment channel or to a bank account on our books:
{
"success": true,
"code": "SUCCESS",
"message": "OK",
"data": {
"order_no": "DEP20260520000001",
"requested_amount": "100.00",
"amount": "100.00",
"currency_code": "THB",
"bank_code": "KBANK",
"bank_name": "ธนาคารกสิกรไทย (Kasikorn Bank)",
"bank_branch": null,
"account_no": "1234567890",
"account_holder": "Mock Payment Co., Ltd.",
"remark": "DEP20260520000001",
"expire_at": "2026-05-20T08:15:00Z",
"payment_url": null,
"payment_info": null
}
}The fields you will use most often:
order_no— the gateway-side identifier for this deposit. Save it. You will use it to query state and to correlate the asynchronous callback.amount— the actual amount the payer should transfer. This may differ fromrequested_amountif the channel applies amount-floating to make orders distinguishable; showamountto the payer.expire_at— the cutoff after which the order will no longer accept payment.bank_code,bank_name,account_no,account_holder,remark— the payment instructions to display to the payer for an account-routed order.payment_url,payment_info— populated instead for channel-routed orders. Redirect the payer topayment_url, or renderpayment_infoyourself.
For the complete envelope contract, including the catalog of error codes you should branch on, see Errors. For every field on every endpoint, see the API Reference.
Step 4: Query the order
To confirm the order landed in the system, query it back by order_no. The query endpoint is GET-only, so there is no body to sign — the signature is computed over the timestamp alone.
ORDER_NO="DEP20260520000001"
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TIMESTAMP=$(date +%s)
PARAMS="timestamp=${TIMESTAMP}"
SIGNATURE=$(python sign.py --api-key "$API_KEY" --params "$PARAMS")
curl -X GET "https://pre-api.open-pay.co/api/v1/merchant/query/deposit/${ORDER_NO}" \
-H "X-API-Key: ${API_KEY}" \
-H "X-Timestamp: ${TIMESTAMP}" \
-H "X-Signature: ${SIGNATURE}"A fresh order will come back with status: "pending" and no matched_at or completed_at yet:
{
"success": true,
"code": "SUCCESS",
"message": "OK",
"data": {
"id": "01J...",
"order_no": "DEP20260520000001",
"merchant_order_no": "QUICKSTART-0001",
"merchant_id": "01J...",
"amount": "100.00",
"currency_code": "THB",
"status": "pending",
"expire_at": "2026-05-20T08:15:00Z",
"matched_at": null,
"completed_at": null,
"callback_url": "https://your-domain.example/callbacks/deposit",
"callback_status": null,
"callback_count": 0,
"created_at": "2026-05-20T08:00:00Z",
"updated_at": "2026-05-20T08:00:00Z"
}
}That status: "pending" is your success criterion. The order is in the gateway, it has accepted your signature, and it is waiting for the payer to transfer. From here, state transitions arrive asynchronously through the callback_url you supplied.
Next steps
You have proven the integration end-to-end. The chapters below go deeper on each piece:
- Authentication — the full signing reference, the 5-minute timestamp window, the worked digest example, and all four language helpers side by side.
- Deposits — the full deposit guide, including cashier-mode integration and the deposit state machine (
PENDING→MATCHED→COMPLETEDand its failure paths). - Callbacks — how the gateway delivers asynchronous state updates, the callback signing scheme, and your retry semantics.
- Sandbox — the full sandbox-versus-production breakdown, the Cloudflare Access source-IP layer, mock-channel behavior, and the common sandbox pitfalls.
- Errors — the envelope contract, the catalog of
codevalues you should branch on, and HTTP-status retry guidance.