This chapter is the canonical reference for accepting deposits through the Payment Gateway merchant API. It covers both deposit modes (Direct API and Cashier session), their state machines, the exact request and response shapes, and the pitfalls that show up in production.
Concepts and terminology
| Term | Meaning |
|---|---|
| Deposit order | A DepositOrder row that represents a single payment intent from one of your end users. Identified by order_no (assigned by the gateway) and merchant_order_no (assigned by you). |
| Channel | An upstream payment provider (for example, a third-party PSP or a virtual-account aggregator). A channel order is routed to a PaymentChannel and exposes a payment_url or channel-supplied payment_info. |
| Account | A physical bank account owned by the operator. An account order asks the payer to send funds to that account; matching is done by expected_remark or amount float. |
payment_url | A redirect URL returned by some channels. Send the payer here to complete payment. Populated only when the order is routed to a channel that uses a hosted payment page. |
payment_info | A channel-supplied JSON object containing bank details, QR code data, or any other payer-facing instructions. Populated for channel orders that do not use a redirect URL. |
| Cashier session | A CashierSession row that represents a hosted, payer-facing page. Without amount, the payer picks the amount on this page and the actual DepositOrder is created after the payer submits. When the request includes amount, the DepositOrder is created immediately with the session and the hosted page opens directly on payment instructions. |
| Payer | The end user sending funds. Distinct from the merchant. The payer interacts with payment_url, payment_info, or the hosted cashier page. The payer never authenticates against the merchant API. |
| MATCHED state | The deposit has been recognised as paid (a bank transaction matched the order, or the channel reported success), but settlement and ledger commit have not finished. Not a terminal state. |
| Mock channel | A built-in fake channel available in the sandbox. Use it to drive the state machine end-to-end without involving a real PSP. See Sandbox. |
Two deposit modes
| Aspect | Direct API | Cashier session |
|---|---|---|
| Endpoint | POST /api/v1/merchant/orders/deposit | POST /api/v1/merchant/orders/deposit/cashier |
| Who picks the amount | The merchant, in the request body. | Either the payer on the hosted page, or the merchant when amount is provided in the cashier-session request. |
| Who picks the channel | The gateway routing engine, based on currency and amount. | The gateway routing engine after the payer submits, or immediately when the cashier-session request includes amount. |
| Initial response | DepositPaymentInfo with payment_url or bank details. | CashierCreateResponse with cashier_url and cashier_token. |
When DepositOrder row is created | Immediately on POST /deposit. | On payer submit by default. If the cashier request includes amount, it is created immediately with the session and the hosted page opens directly on payment instructions. |
| What the merchant displays to the payer | Your own UI; you render the bank details or redirect to payment_url. | A redirect to cashier_url. The gateway hosts everything. |
order_type on the resulting order | DIRECT | CASHIER |
Decision guide
Use Direct API if you already collected the amount, currency, and payer intent in your own UI. Your application stays in control of the checkout look and feel. You handle the redirect or the bank-details screen yourself.
Use Cashier session if you want Payment Gateway to host the payer-facing screen. Omit amount when the payer should pick from the hosted denomination selector. Provide amount when you already collected the amount and only want the hosted page to display the payment instructions. You hand the payer a single cashier_url and stop touching the flow until you receive the callback.
Both modes converge on the same DepositOrder entity and the same callback handshake. Querying, callback verification, and state semantics are identical.
API surface
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/merchant/orders/deposit | Create a Direct API deposit order. |
POST | /api/v1/merchant/orders/deposit/cashier | Create a Cashier session. |
GET | /api/v1/merchant/query/deposit/{order_no} | Query a deposit order by gateway order_no. |
GET | /api/v1/merchant/query/deposit?merchant_order_no=... | Query a deposit order by your merchant_order_no. |
All endpoints require the standard MD5 signature headers (lowercase-hex MD5 over sorted params + API key). See Authentication.
DepositOrderCreate (Direct API request)
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_order_no | string (1-64) | yes | Your unique reference for this deposit. Must be unique per merchant. |
amount | decimal | yes | Must be greater than 0. Subject to currency precision validation. |
currency_code | string (3) | yes | ISO 4217 code. Must be a currency your merchant account is enabled for. |
callback_url | string (1-500) | yes | HTTPS URL we call on status changes. Required for Direct API. |
extra_data | string | no | Opaque string echoed back in the callback. |
user_id | string (≤64) | no | Your internal user identifier. Used for grouping and audit. |
CashierDepositOrderCreate (Cashier session request)
| Field | Type | Required | Notes |
|---|---|---|---|
order_type | enum | no | Defaults to cashier. The field exists for forward compatibility. |
merchant_order_no | string (1-64) | yes | Same uniqueness rules as Direct API. |
amount | decimal | no | Optional pre-filled amount. Must be greater than 0 and must match a configured cashier denomination, unless custom cashier amounts are enabled and the value is within the configured min/max range. When present, the gateway creates the DepositOrder immediately and the hosted page skips denomination selection. |
currency_code | string (3) | yes | ISO 4217 code. |
callback_url | string (≤500) | no | Optional for Cashier. If omitted, the merchant default is used. |
extra_data | string | no | Echoed back in the callback. |
user_id | string (≤64) | no | Strongly recommended. The gateway reuses an existing PENDING session for the same user_id, which prevents accidental duplicate sessions if your application retries. |
payer_bank_cards | list of objects (≤10) | no | Pre-bound payer cards. When provided, the cashier page forces the payer to pick one. |
DepositPaymentInfo (Direct API response, HTTP 201)
| Field | Populated when | Notes |
|---|---|---|
order_no | always | Gateway-assigned identifier. Use this in queries. |
requested_amount | always | The amount you sent. May differ from amount. |
amount | always | The amount the payer must transfer. Differs from requested_amount when the routing engine applies amount float for matching. |
currency_code | always | Echoed from the request. |
expire_at | always | RFC 3339 timestamp. Default expiry is 15 minutes; configurable per merchant. |
payment_url | channel orders only | Redirect target for the payer. |
payment_info | channel orders only | Channel-supplied JSON. Shape varies by channel. |
bank_code, bank_name, bank_branch, account_no, account_holder | account orders, and convenience-mirrored for channel orders that returned bank details | Bank details to display to the payer. |
remark | account orders only | The string the payer must put in the bank transfer memo. The matcher uses this. |
CashierCreateResponse (Cashier response, HTTP 201)
| Field | Notes |
|---|---|
cashier_url | Full URL to redirect the payer to. Contains the signed token. |
cashier_token | Same token also exposed for back-channel inspection. Not strictly required for the merchant. |
session_id | CashierSession.id. Persist for logging. |
expire_at | Session expiry. Defaults to 15 minutes, configurable per merchant. |
Full schema definitions, including all response fields on DepositOrderResponse, are in the API reference.
Direct API flow
The diagram captures the happy path. The gateway routes the order at step 2-3 based on configured routing rules: a PaymentChannel if one matches, otherwise a managed bank Account. The order row is committed with status=PENDING and expire_at set from the merchant's deposit_expire_minutes config (default 15 minutes).
When the payer pays, the gateway moves the order through MATCHED (payment recognised) to COMPLETED (ledger settled), then fires the callback. Failure branches (FAILED, EXPIRED, CANCELLED) are detailed in State transitions.
Cashier session flow
Key points specific to Cashier mode:
- Without
amount, theDepositOrderrow does not exist until the payer submits. Up to that point you only have aCashierSession. Querying/api/v1/merchant/query/deposit/{order_no}will return 404 because there is noorder_noyet. - With
amount, theDepositOrderis created immediately. The cashier session is returned assubmitted,has_deposit_order=trueon the public cashier API, and the hosted page skips the denomination selector. - The signed token in
cashier_urlis short-lived and tied to the session. It is not a merchant API credential. Do not try to call merchant API endpoints with it. - Session reuse is keyed on
(merchant_id, user_id). If a PENDING session exists for the same user, the gateway updates it with the newmerchant_order_no, currency, and callback URL and returns the same token. This prevents accidental duplicate sessions on retries. expire_aton the session is independent ofexpire_aton the eventual order. When the payer submits, the order gets a fresh expiry from the merchant config.- Cashier pages may use WebSocket status updates to push state changes to the payer's browser without polling. This is transparent to the merchant integration: you still rely on the server-to-server callback.
Worked examples
The snippets below assume you have generated X-Timestamp and X-Signature headers as documented in Authentication. Sign all four samples with the same key:
#!/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));In each example the --data payload is the JSON body that you sign. Sign the body, not the URL.
Example 1: Direct API deposit
Create a direct deposit. The gateway chooses the channel or account; you cannot pin a channel from the merchant API in this version.
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-d '{
"merchant_order_no": "DEP-20260520-0001",
"amount": "1000.00",
"currency_code": "THB",
"callback_url": "https://merchant.example.com/api/pg/callback",
"user_id": "user-42",
"extra_data": "checkout-cart-77"
}'Response (channel-routed, abbreviated):
{
"order_no": "D2026052012345678",
"requested_amount": "1000.00",
"amount": "1000.00",
"currency_code": "THB",
"expire_at": "2026-05-20T08:15:00Z",
"payment_url": "https://pay.example-psp.com/pay/abc123",
"payment_info": {
"bank_code": "SCB",
"bank_name": "Siam Commercial Bank",
"account_no": "1234567890",
"account_name": "OPEN PAY CO LTD"
},
"bank_code": "SCB",
"bank_name": "Siam Commercial Bank",
"account_no": "1234567890",
"account_holder": "OPEN PAY CO LTD"
}Persist order_no against your merchant_order_no. You will need it for queries and callback reconciliation.
Example 2: Cashier session
Create a cashier session and send the payer to cashier_url.
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit/cashier \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-d '{
"merchant_order_no": "DEP-20260520-0002",
"currency_code": "THB",
"user_id": "user-42",
"callback_url": "https://merchant.example.com/api/pg/callback"
}'Response:
{
"cashier_url": "https://pre-cashier.open-pay.co/?token=eyJhbGciOi...",
"cashier_token": "eyJhbGciOi...",
"session_id": "0a3f5c2e-1c0b-4a3b-9c2d-1f4e7a8b9c0d",
"expire_at": "2026-05-20T08:15:00Z"
}Redirect the payer's browser to cashier_url. Do not embed the page in a hidden iframe; the cashier page expects a top-level browser context.
Example 3: Cashier session with a pre-filled amount
Use this mode when your own checkout already collected the amount, but you still want Payment Gateway to host the payment-instructions page. The amount must pass the same denomination/custom-amount validation as the hosted selector.
curl -X POST https://pre-api.open-pay.co/api/v1/merchant/orders/deposit/cashier \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-d '{
"merchant_order_no": "DEP-20260520-0003",
"amount": "1000.00",
"currency_code": "THB",
"user_id": "user-42",
"callback_url": "https://merchant.example.com/api/pg/callback"
}'Response:
{
"cashier_url": "https://pre-cashier.open-pay.co/?token=eyJhbGciOi...",
"cashier_token": "eyJhbGciOi...",
"session_id": "1d0f5c2e-2c0b-4a3b-9c2d-1f4e7a8b9c0d",
"expire_at": "2026-05-20T08:15:00Z"
}Redirect the payer to cashier_url. The hosted page loads payment information directly instead of showing the denomination selector.
Example 4: Query a deposit after creation
Query by gateway order_no:
curl https://pre-api.open-pay.co/api/v1/merchant/query/deposit/D2026052012345678 \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"Or by your merchant_order_no:
curl "https://pre-api.open-pay.co/api/v1/merchant/query/deposit?merchant_order_no=DEP-20260520-0001" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"Both return the full DepositOrderResponse schema, including status, amount, actual_amount, matched_at, completed_at, and callback_status. Use the response to reconcile against your internal record.
Example 5: Duplicate merchant_order_no
If you call POST /deposit (or POST /deposit/cashier) with a merchant_order_no that already exists for your merchant, the backend rejects the request:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"success": false,
"code": "CONFLICT",
"message": "Duplicate merchant order number: DEP-20260520-0001",
"data": null
}Recommended recovery: do not retry blindly. Query first.
curl "https://pre-api.open-pay.co/api/v1/merchant/query/deposit?merchant_order_no=DEP-20260520-0001" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Signature: $SIG"If the existing order is already COMPLETED you are done. If it is still PENDING and not expired, surface its payment_url or bank details to the payer instead of creating a new order.
State transitions
DepositOrder state machine
| From → To | Trigger | Merchant observes |
|---|---|---|
PENDING → MATCHED | A bank webhook matches the order's expected_remark or floated amount, or the channel sends a paid notification. Backend sets matched_at. | No callback yet. Status visible via query. |
MATCHED → COMPLETED | The ledger commits the funds to the merchant balance. Backend sets completed_at, actual_amount. | Signed callback fired. This is the only callback for a successful deposit. |
PENDING → EXPIRED | expire_at has passed and no payment matched. A background job moves the row. The order does not auto-cancel; it explicitly transitions to EXPIRED and stops accepting payments. | No callback for expiry by default. Reconcile via query. |
PENDING → CANCELLED / MATCHED → CANCELLED | Operator or merchant explicitly cancels. For account orders the reserved deposit quota is released. | No callback for cancel. Reconcile via query or operator notification. |
PENDING → FAILED / MATCHED → FAILED | Channel reports a terminal failure (for example, the channel order was rejected by the upstream bank). | Callback may or may not fire depending on channel. Reconcile via query. |
MATCHED is not final
A MATCHED order can still move to CANCELLED or FAILED in rare cases (post-match channel errors, operator intervention). Only treat COMPLETED as final-success and EXPIRED / CANCELLED / FAILED as final-failure.
CashierSession state machine
| From → To | Trigger | Merchant observes |
|---|---|---|
PENDING → SUBMITTED | For sessions without amount, the payer picks a denomination (and bank card, if required) on the cashier page. Backend creates the DepositOrder and links it via deposit_order_id. | No callback at this step. The deposit lifecycle begins now. |
Created as SUBMITTED | The cashier-session request includes amount. Backend creates the DepositOrder immediately, links it via deposit_order_id, and the hosted page skips denomination selection. | No callback at this step. The merchant can query the resulting DepositOrder immediately. |
PENDING → EXPIRED | The session's expire_at has passed and the payer never submitted. The next request that touches the session marks it expired; an explicit operator action can also move it. | No callback. The session cannot be resumed; create a new one. |
There is no SUBMITTED → EXPIRED path. Once a session is submitted, the associated DepositOrder carries its own state machine; the session is effectively a closed record.
Pitfalls
Reusing
merchant_order_noacross different deposit intents. The backend rejects this with HTTP 409 andCONFLICTcode in the standard error envelope. This now correctly uses HTTP 409. Generate a freshmerchant_order_nofor each new deposit.Using a fresh
merchant_order_noper retry. The opposite of pitfall 1. On a network blip duringPOST /deposit, if you retry with a newmerchant_order_no, the first request might still have landed, leaving you with two orders. Always retry with the samemerchant_order_no; the duplicate check then surfaces the conflict and you can query the existing order. SeeCONFLICTandBUSINESS_ERROR.Cashier session expires before the payer completes. Sessions cannot be resumed. The merchant must call
POST /deposit/cashieragain and redirect the payer to the newcashier_url. Setexpire_atexpectations on your end-user UI.Forgetting to verify the callback signature. Every callback is signed with the same MD5-over-sorted-params scheme as outbound requests. Skipping verification opens you to forged callbacks and breaks idempotency assumptions. See Callbacks and
INVALID_SIGNATURE.Mixing Direct API and Cashier mode for the "same" order. Direct API and Cashier produce different
DepositOrderTypevalues (DIRECTvsCASHIER). Even with the samemerchant_order_nothey are not interchangeable. Pick one mode per logical deposit attempt.Branching on
payment_urlpresence to detect Cashier mode. This is wrong. Cashier sessions returncashier_url, notpayment_url. Direct API channel orders returnpayment_url. Direct API account orders return neither. Branch on which endpoint you called, or onorder_typefrom the query response.Treating
MATCHEDas a final state.MATCHEDmeans "the payment is recognised, the ledger has not committed yet." It can still move toCANCELLEDor (rarely)FAILED. The signed callback fires atCOMPLETED, not atMATCHED. See State transitions.Assuming
amountequalsrequested_amount. For currencies and channels that use amount-float matching, the gateway adjusts the payable amount to disambiguate concurrent transfers to the same bank account. Honouramount(what the payer pays) overrequested_amount(what you asked for) when displaying instructions. The fields are clearly named inDepositOrderResponseandDepositPaymentInfo.Ignoring
expire_at. Expired orders move toDepositOrderStatus.EXPIREDand stop accepting payments. The payer's transfer arriving after expiry will not auto-match the order and may need manual reconciliation. SeeBUSINESS_ERRORfor the response shape on operations against expired orders.Hard-coding bank account info in your UI. The
payment_info,bank_code,bank_name,account_no, andaccount_holderfields onDepositPaymentInfochange per order — the routing engine may pick different accounts based on load, currency, or float availability. Render whatever the response returns; never pre-fill from your own database.Polling
/query/deposit/instead of relying on callbacks. Polling wastes your API budget and adds latency. The callback fires once atCOMPLETED. UseGET /query/deposit/{order_no}only as a reconciliation fallback after a missed callback. See Callbacks for retry semantics.Not handling
INSUFFICIENT_MERCHANT_BALANCEon accounts with credit-style configuration. Rare for deposits, but possible if your account has prepaid fee deduction enabled. SeeINSUFFICIENT_MERCHANT_BALANCE.Re-querying a Cashier session by
merchant_order_nobefore submission. TheGET /query/deposit?merchant_order_no=...endpoint queriesDepositOrder, notCashierSession. A PENDING cashier session has noDepositOrderyet, so the query returns 404NOT_FOUND. This is not a bug. When the cashier request includedamount, the order is created immediately and this caveat does not apply.Trusting the
cashier_tokenas a long-lived credential. The cashier token is a short-lived signed JWS scoped to the session. It rotates on session updates. Never persist it; always start fresh withPOST /deposit/cashier.
Going live checklist
Before pointing production traffic at the gateway:
- [ ] Callback URL is HTTPS, monitored, and returns
200 OK(or response body containingsuccess) within 5 seconds. - [ ] Callback signature verification is implemented and unit-tested. See Authentication.
- [ ] Your production NAT exit IPs are in the Cloudflare Access allowlist. See Sandbox for the Cloudflare edge source-IP model.
- [ ] Idempotency strategy is decided: on
POST /depositretries you reuse the samemerchant_order_no, and you have a code path to handle the duplicate response. - [ ]
currency_codevalues are validated against the currencies your merchant account is enabled for. Sending an unsupported currency returnsBUSINESS_ERROR. - [ ] Mock-channel sandbox tests pass for both Direct API and Cashier modes, covering the happy path and at least the
EXPIREDbranch. - [ ] Production API key and signing secret are stored in your secret manager. They are not in source, not in CI logs, and not in client-side bundles.
- [ ] You have decided whether to expose the merchant
order_noto your end users. It is safe to surface for customer-support workflows but is not a payer-facing identifier.
Cross-references
- Authentication — MD5-over-sorted-params signing scheme used on every request and every callback.
- Callbacks — Server-to-server status notifications, retry policy, response format.
- Errors — Canonical error code reference. Cross-linked from the Pitfalls section.
- API reference — Full schema definitions for every request and response field.
- Sandbox — PRE environment, mock channel, Cloudflare edge IP allowlist model.
- Quickstart — End-to-end example of creating a first deposit.
- Withdrawals — The opposite direction; pays funds out from your merchant balance.