API v1.0

Payment Gateway API

Integrate deposits, payouts, and real-time webhooks into your platform with our simple REST API. Fast, secure, and reliable.

Base URL __BASE_URL__

🔒 Authentication

All API requests must include the following headers for authentication.

Header Value Description
X-API-Key your_api_key Your API key from the Client Management page
X-API-Secret your_api_secret Your API secret from the Client Management page
Content-Type application/json Request body format
The Deposit/Payout create endpoints also require an X-Idempotency-Key header — see the Idempotency section.
Keep your API credentials secure. Never expose them in client-side code or public repositories.

🔁 Idempotency — Prevent Duplicate Transactions

Every Deposit/Payout create request must include the X-Idempotency-Key header to prevent duplicate transactions caused by retries or double-submits.

X-Idempotency-KeyRequired header on create endpoints
  • X-Idempotency-Key: use a UUID v4 — a string unique per transaction, up to 256 characters (generated by the client).
  • 1 transaction = 1 key; never reuse a key for a different transaction.
  • A retry must reuse the same key + identical body (every byte) → the server replays the original response without creating a duplicate.
  • Keys live for 60 minutes; after expiry the request is treated as new.

Error Responses

HTTP Code When
400 IDEMPOTENCY_KEY_REQUIRED X-Idempotency-Key header was not sent
409 IDEMPOTENCY_IN_PROGRESS The original request is still processing — wait, then retry with the same key
422 IDEMPOTENCY_BODY_MISMATCH Same key but a different body — a client bug; use a new key

Deposit API

Create deposit requests to receive funds from customers via QR code or bank transfer.

POST /api/v1/deposit/create Create Deposit

Request Fields

Field Type Description
accountNo Required string Customer bank account number (10-15 digits)
accName Required string Customer account name (2-100 characters)
bankCode Required string Bank code (e.g. KBANK, SCB, BBL)
amount Required number Amount in THB — integer baht only; any decimal portion is silently floored (e.g. 100.99 → 100). Range 1 - 2,000,000.
callbackUrl Required string HTTPS URL for status callbacks
refCode Optional string Client-side reference code (not included in signature, up to 100 chars)
signature Required string HMAC-SHA256 signature over the sorted query string (see Signature)
timestamp Required number Unix timestamp in milliseconds (must be within 5 minutes)

Response Fields

Field Type Description
txnId string Transaction ID (format: DEP{timestamp}{random})
status string Transaction status
pending expired cancelled
channel string Payment channel: QR (PromptPay), TRANSFER (bank transfer) or P2P (matched with another user). Always branch on this field — do not assume qrcode is present
amount number The EXACT amount the customer must transfer, satang included. On some accounts (e.g. GSB) the system adds satang to identify the transaction — a rounded transfer will not match. Always display this value, never requestedAmount
requestedAmount number The amount you asked for, before satang allocation. For your own reconciliation only — never show it as the amount to transfer
fee number Transaction fee (from MDR Profile, calculated on the satang-inclusive amount)
feePercent number Fee percentage
netAmount number Net amount after fee (amount - fee)
expireDate string Expiration date/time (ISO 8601, UTC)
refcode string Client reference code echoed back verbatim (empty string "" if not sent)
qrcode string PromptPay EMV payload for client-side QR rendering (only when channel=QR)
bankData object Bank account for transfer: bankName, bankCode, accountName, accountNumber (only when channel=TRANSFER)
url string The page to hand your customer. Present on every channel: on P2P it is the matching page that shows the account to transfer to, and on QR and TRANSFER it is the page where the customer attaches their transfer slip. Whether they MUST open it depends on the channel — see the table below.
The system selects the channel automatically — if the assigned System Bank has PromptPay it returns QR; otherwise it returns TRANSFER.
channel=P2P carries NO qrcode and NO bankData — only url. A deposit on this channel is fulfilled by matching your customer with another user, so the destination account is not known at create time and cannot be returned in the response. Open (or redirect the customer to) url; that page shows the account to transfer to and takes the slip. An integration that reads qrcode unconditionally will show the customer a blank screen. Status, expireDate and callbacks behave exactly as on the other channels.
Which deposits take this channel: an account enabled for P2P, and an amount from 100 to 950 THB that is a multiple of 50 — 100, 150, 200 … 950. Every other amount goes to a bank account as it always has, with no error and no difference in the request you send. The same range applies during bank maintenance hours, when P2P is the only rail still serving. Treat these figures as our current setting and not as a contract: we tune them, and the ceiling moved from 1,000 to 950 on 11 Sep 2026. Branch on the channel field in the response, never on the amount you sent.

P2P: paying the wrong amount

A P2P deposit is matched to a withdrawal as one whole unit, and the customer is told to transfer that exact figure. The check on settlement compares the amount on the bank slip with the amount of the match, so anything other than an exact transfer has consequences worth building for.

Customer transfers What happens Deposit credited
The exact amount Settles normally. Yes
Less than the amount The deposit is held for review by our team. It is NOT treated as a part payment and it is NOT topped up by a second transfer — the deposit is finished. The withdrawal it was matched to is released and filled by other depositors, so the withdrawing side is unaffected. No
More than the amount Settles, but only the matched amount is credited. The excess is not returned automatically. Matched amount only
Money that was under-transferred has already reached the other customer's bank account and cannot be recalled — a bank transfer between two customers is final. This is why an under-transfer is held for review instead of being closed automatically, and why your integration should make the amount impossible for the customer to mistype.
There is no callback for "transferred the wrong amount". A deposit held for review sends no deposit.paid, and if it is still unresolved when the window closes your integration receives the ordinary EXPIRED callback — the same one a customer who never paid at all produces. If a customer insists they transferred but you received EXPIRED, contact us with the txnId rather than telling them nothing was received.
On channel=P2P the customer must transfer the EXACT amount. A transfer for less is not a partial payment — the deposit is not credited at all, it is held for review, and the money the customer already sent cannot be pulled back. Tell the customer the exact figure and do not let them edit it after the deposit is created.

url: the page to hand your customer

Every deposit returns url — one field, on every channel. What it points at, and whether your customer has to open it, depends on channel.

channel Must the customer open it? Why
P2P Yes There is no qrcode and no bankData on this channel — the account to transfer to appears only on that page, so a customer who never opens it cannot pay at all.
TRANSFER Yes The customer attaches their transfer slip there. Send them the link after they transfer.
QR Optional The QR is the payment method, so the deposit completes without it. The page is there if you want the customer to attach a slip as well.
url is a link for the CUSTOMER, not for your back office. It carries a signed, expiring token scoped to that one deposit, so it is safe to send by SMS or chat or to show after payment — but a link you file in your own admin gives you nothing. On QR and TRANSFER a slip can be attached once per deposit; to replace one, use your own upload-slip endpoint. When the feature is not enabled for your deployment the field is absent, so read it as optional.
The amount may carry satang — the customer must transfer it EXACTLY. On some destination accounts (e.g. GSB) the bank statement does not identify the payer, so the system adds satang to the amount to identify the transaction (request 1000.00, receive 1000.37). A rounded transfer will not match and the money sits unreconciled. Always display amount; requestedAmount exists only for your own reconciliation.
cURL
curl -X POST '__BASE_URL__/api/v1/deposit/create' \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret' \
  -H 'X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
  -d '{
    "accountNo": "1234567890",
    "accName": "John Doe",
    "bankCode": "KBANK",
    "amount": 1000.00,
    "callbackUrl": "https://your-domain.com/callback",
    "refCode": "ORDER-12345",
    "signature": "abc123...",
    "timestamp": 1709123456789
  }'
JSON
{
  "status": "success",
  "message": "Create Success",
  "data": {
    "txnId": "DEP1709123456ABC123",
    "status": "pending",
    "channel": "QR",
    "amount": 1000.00,
    "fee": 15.00,
    "feePercent": 1.5,
    "netAmount": 985.00,
    "expireDate": "2024-01-01T12:10:00Z",
    "refcode": "ORDER-12345",
    "customerData": {
      "accountNo": "1234567890",
      "accName": "John Doe",
      "bankCode": "KBANK"
    },
    "qrcode": "00020101021229370016A000000677010111011300105568164630530376454041000802TH5910COMPANY CO6304ABCD"
  }
}
JSON
{
  "status": "success",
  "message": "Create Success",
  "data": {
    "txnId": "DEP1709123456ABC123",
    "status": "pending",
    "channel": "TRANSFER",
    "amount": 1000.37,
    "requestedAmount": 1000.00,
    "fee": 15.01,
    "feePercent": 1.5,
    "netAmount": 985.36,
    "expireDate": "2024-01-01T12:10:00Z",
    "refcode": "ORDER-12345",
    "customerData": {
      "accountNo": "1234567890",
      "accName": "John Doe",
      "bankCode": "KBANK"
    },
    "bankData": {
      "bankName": "GSB",
      "bankCode": "GSB",
      "accountName": "Company ABC",
      "accountNumber": "020123456789"
    }
  }
}
JSON
{
  "status": "success",
  "message": "Create Success",
  "data": {
    "txnId": "DEP1709123456ABC123",
    "status": "pending",
    "channel": "P2P",
    "amount": 500.00,
    "requestedAmount": 500.00,
    "fee": 7.00,
    "feePercent": 1.4,
    "netAmount": 493.00,
    "expireDate": "2024-01-01T12:10:00Z",
    "refcode": "ORDER-12345",
    "customerData": {
      "accountNo": "1234567890",
      "accName": "John Doe",
      "bankCode": "KBANK"
    },
    "url": "https://p2p.example.com/deposit/9xQ2m4Kd?t=..."
  }
}

Payout API

Create payout requests to transfer funds to customer bank accounts.

POST /api/v1/payout/create Create Payout

Request Fields

Field Type Description
accountNo Required string Destination bank account number
accName Required string Destination account name (must match bank records)
bankCode Required string Destination bank code (e.g. KBANK, SCB, BBL)
amount Required number Amount in THB (must have sufficient balance). Range 1 - 2,000,000.
callbackUrl Required string HTTPS URL for status callbacks
refCode Optional string Client-side reference code (not included in signature, up to 100 chars)
signature Required string HMAC-SHA256 signature over the sorted query string (see Signature)
timestamp Required number Unix timestamp in milliseconds (must be within 5 minutes)

Response Fields

Field Type Description
txnId string Transaction ID (format: PAY{timestamp}{random})
status string Transaction status
pending processing completed failed cancelled
amount number Payout amount
fee number Withdrawal fee (from MDR Profile)
feePercent number Fee percentage
channel string Present only when the payout is fulfilled over the P2P channel, with the value "P2P". Absent on the ordinary bank-transfer channel
refcode string Client reference code echoed back verbatim (empty string "" if not sent)
customerData object Destination account details: accountNo, accName, bankCode
url string Status page for the withdrawing customer (only when channel=P2P). Optional — the payout completes without anyone opening it; it exists because on this channel there is no bank reference to show while the transfer is being matched
Ensure your account has sufficient balance before creating a payout. Insufficient balance will result in an error.
A payout may be fulfilled over the P2P channel, in which case the response carries channel="P2P" and url. Nothing about your integration changes: the wallet is debited at create time exactly as on the bank channel, and payout.success / payout.failed arrive the same way. url is a status page you may pass to the withdrawing customer — it is optional.

P2P: a withdrawal delivered in two parts

A P2P withdrawal is filled by matching it against deposits. If not enough deposits arrive before the deadline, the part already delivered stays delivered and a system bank transfers the remainder to the same customer. Your customer receives the full amount either way; what changes is when.

Situation What happens Callback
Filled by deposits Settles normally. payout.success
Filled in part, bank pays the rest The remainder is transferred from a system bank to the same account. The customer ends up with the full amount, usually within minutes. payout.success
Filled in part while the banks are closed The banks that carry withdrawals close overnight. Rather than hold your withdrawal until they reopen, the unpaid remainder is returned to your wallet and the withdrawal is closed. Your customer keeps what was already sent. payout.success with the amounts
Bank transfer for the remainder fails The withdrawal is held by our team until the remainder is transferred. It is not cancelled and the amount already sent is never refunded, because it cannot be recalled and your customer asked for the whole amount. None until it resolves
Silence is not failure. While a withdrawal is waiting for its remaining transfer you receive no callback at all — not payout.failed, not payout.success. Do not treat the absence of a callback as a failed withdrawal and do not return the amount to your user's balance: part of it is already in their bank account. The callback arrives when the withdrawal finishes.
When a withdrawal is closed with part of it returned, the callback carries paidAmount and refundedAmount alongside amount. amount is still what your customer ASKED for; paidAmount is what reached their bank account. Credit your user against paidAmount, not amount — an integration that ignores these two fields will credit a player the full figure while the player is holding less, and the difference is already back in your wallet.
Conversely, payout.failed on this channel always means your customer received nothing. A withdrawal that delivered any money cannot end as failed or cancelled — the only ending available to it is completion. So payout.failed is safe to act on exactly as you do today, and payout.success means the full amount arrived, whether it came from one transfer or two.
cURL
curl -X POST '__BASE_URL__/api/v1/payout/create' \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret' \
  -H 'X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
  -d '{
    "accountNo": "9876543210",
    "accName": "Jane Doe",
    "bankCode": "SCB",
    "amount": 5000.00,
    "callbackUrl": "https://your-domain.com/callback",
    "refCode": "WD-98765",
    "signature": "def456...",
    "timestamp": 1709123456789
  }'
JSON
{
  "status": "success",
  "message": "Create Success",
  "data": {
    "txnId": "PAY1709123456XYZ789",
    "amount": 5000.00,
    "fee": 75.00,
    "feePercent": 1.5,
    "status": "pending",
    "refcode": "WD-98765",
    "customerData": {
      "accountNo": "9876543210",
      "accName": "Jane Doe",
      "bankCode": "SCB"
    }
  }
}
JSON
{
  "status": "success",
  "message": "Create Success",
  "data": {
    "txnId": "PAY1709123456XYZ789",
    "amount": 500.00,
    "fee": 1.00,
    "feePercent": 0.2,
    "status": "pending",
    "channel": "P2P",
    "refcode": "WD-98765",
    "customerData": {
      "accountNo": "9876543210",
      "accName": "Jane Doe",
      "bankCode": "SCB"
    },
    "url": "https://p2p.example.com/withdraw/7bT1n8Le?t=..."
  }
}

💰 Balance API

Check your wallet balance including available and frozen amounts.

GET /api/v1/client/balance Check Wallet Balance

Response Fields

Field Type Description
clientId string Client ID
name string Client name
balance number Total balance
available number Available balance (balance - frozen)
frozen number Frozen/locked amount
updatedAt string Last updated timestamp (Asia/Bangkok)
cURL
curl -X GET '__BASE_URL__/api/v1/client/balance' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret'
JSON
{
  "status": "success",
  "data": {
    "clientId": "65a1b2c3d4e5f6a7b8c9d0e1",
    "name": "ACME-01",
    "balance": 150000.00,
    "available": 148500.00,
    "frozen": 1500.00,
    "updatedAt": "2024-01-01T19:05:00+07:00"
  }
}

🔔 Callback / Webhook

The system sends a POST request to your callback URL whenever a transaction status changes.

Your callback endpoint must respond with HTTP 200 within 10 seconds. Failed callbacks will be retried.
POST your_callback_url Deposit Callback
JSON
{
  "event": "deposit.paid",
  "txnId": "DEP1709123456ABC123",
  "amount": 1000.00,
  "status": "PAID",
  "paidAt": "2024-01-01T12:05:00Z",
  "signature": "callback_signature_here",
  "timestamp": 1709123456789
}
POST your_callback_url Payout Callback

Payout Callback Events: payout.success, payout.failed, payout.cancelled

JSON
// Payout Success
{
  "event": "payout.success",
  "txnId": "PAY1709123456XYZ789",
  "amount": 5000.00,
  "fee": 15.00,
  "status": "SUCCESS",
  "completedAt": "2024-01-01T14:30:00Z",
  "signature": "callback_signature_here",
  "timestamp": 1709123456789
}

// Payout Failed
{
  "event": "payout.failed",
  "txnId": "PAY1709123456XYZ789",
  "amount": 5000.00,
  "fee": 15.00,
  "status": "FAILED",
  "signature": "callback_signature_here",
  "timestamp": 1709123456789
}

// Payout Cancelled
{
  "event": "payout.cancelled",
  "txnId": "PAY1709123456XYZ789",
  "amount": 5000.00,
  "fee": 15.00,
  "status": "CANCELLED",
  "signature": "callback_signature_here",
  "timestamp": 1709123456789
}
Verify Callback Signature
Always verify the callback signature by re-generating the HMAC-SHA256 hash from the callback data using your API Secret. This ensures the callback is genuinely from our system.

🔑 Signature Generation

Generate the signature with HMAC-SHA256 over a sorted query string of exactly these 6 fields.

Fields included in the signature
  • accName
  • accountNo
  • amount
  • bankCode
  • callbackUrl
  • timestamp
Use exactly these 6 fields — no more, no less.
Fields NOT included in the signature
  • refCode (optional client reference)
  • signature (this is the output)
  • timeout (optional)
  • apiKey / apiSecret (HTTP headers only, never in signature)

Steps

  1. Pick the 6 fields listed above — do NOT include refCode/signature/timeout.
  2. Sort field names alphabetically A-Z (case-sensitive).
  3. Build the string: key1=value1&key2=value2&...
  4. Use raw values (no URL-encoding required — server accepts both).
  5. Compute HMAC-SHA256 using API Secret as the key.
  6. Output as a lowercase hex string (64 chars).
📅
timestamp = Unix epoch milliseconds. Server accepts a ±5 min window — generate the timestamp right before signing.
💰
amount: use the same value as in the body. The server normalizes 201, 201.00, 201.0 automatically.

Example (Node.js)

JavaScript
const crypto = require('crypto');

// Allowed fields for signature — IMPORTANT: must be exactly these 6.
// Do NOT include refCode, signature, timeout, apiKey, or any other field.
const SIG_FIELDS = ['accName', 'accountNo', 'amount', 'bankCode', 'callbackUrl', 'timestamp'];

function generateSignature(data, apiSecret) {
  // 1. Pick only the allowed fields + sort alphabetically (case-sensitive)
  const sortedKeys = SIG_FIELDS.slice().sort();

  // 2. Build query string: key=value joined by '&', raw values (no URL encoding)
  const queryString = sortedKeys
    .map(key => `${key}=${data[key]}`)
    .join('&');

  // 3. HMAC-SHA256 with API Secret as key, output as lowercase hex
  return crypto
    .createHmac('sha256', apiSecret)
    .update(queryString)
    .digest('hex');
}

// Usage (Deposit example)
const data = {
  accName: 'John Doe',
  accountNo: '1234567890',
  bankCode: 'KBANK',
  amount: 1000,
  callbackUrl: 'https://your-site.com/callback',
  refCode: 'ORDER-123',           // stays in body but NOT in signature
  timestamp: Date.now(),          // Unix epoch ms, generate right before signing
};

data.signature = generateSignature(data, 'your_api_secret');
// data is now ready to POST as JSON
🧪
Verifiable example — test your code with these values
If your code produces the expected signature, your integration is ready.
JavaScript
// Test inputs (use these exact values in your code)
const apiSecret = 'test_secret_for_docs';
const data = {
  accName: 'John Doe',
  accountNo: '1234567890',
  amount: 1000,
  bankCode: 'KBANK',
  callbackUrl: 'https://your-site.com/callback',
  timestamp: 1709123456789,
};

// Step 2 output (sorted query string):
//   accName=John Doe&accountNo=1234567890&amount=1000&bankCode=KBANK&callbackUrl=https://your-site.com/callback×tamp=1709123456789

// Expected HMAC-SHA256 output (lowercase hex):
//   7fcd73b8eb6a5e2f5250ab0011caa230171b11ca6af65b0954567f3dc9e78e25

const sig = generateSignature(data, apiSecret);
console.log(sig);
// if your code prints the same value the server computes, your integration is ready.

🏦 Banks API

Retrieve the list of supported banks with pagination and search (uses API Key like Deposit/Payout).

GET /api/v1/client/banks Get Banks List

Query Parameters

Field Type Description
page Optional number Page number (default: 1)
limit Optional number Items per page (default: 100)
search Optional string Search by bank name, bank code, or international code

Response Fields

Field Type Description
id string Bank ID (MongoDB ObjectId)
bank_name string Bank name
bank_code string Bank code (e.g. KBANK, SCB)
int_code string International code (e.g. 004, 014)
bank_logo string Bank logo URL
cURL
curl -X GET '__BASE_URL__/api/v1/client/banks?page=1&limit=100' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret'

📄 Other Endpoints

Additional endpoints for managing deposits and payouts.

GET /api/v1/transaction/:txnId Check transaction status (deposit or payout, by a single txnId)

Normalized status poll — resolves the txnId across both deposits and payouts and returns a lean, type-agnostic status view. Requires API Key. For full type-specific detail use /api/v1/deposit/:txnId or /api/v1/payout/:txnId.

cURL
curl -X GET '__BASE_URL__/api/v1/transaction/DEP1709123456ABC123' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret'

Response

JSON
{
  "status": "success",
  "message": "Get Success",
  "data": {
    "txnId": "DEP1709123456ABC123",
    "type": "deposit",
    "status": "pending",
    "amount": 1000.00,
    "netAmount": 985.00,
    "refcode": "ORDER-12345",
    "createdAt": "2024-01-01T12:00:00Z"
  }
}

Deposit Endpoints

Method Endpoint Description
GET /api/v1/deposit/:requestId Get deposit details by ID
GET /api/v1/deposit/status/:requestId Get deposit status (public, no auth required)
GET /api/v1/deposits List all deposits with pagination
POST /api/v1/deposit/:requestId/cancel Cancel a pending deposit
POST /api/v1/deposit/:txnId/cancel

Cancel a pending deposit. Only deposits with pending status can be cancelled.

cURL
curl -X POST '__BASE_URL__/api/v1/deposit/DEP1709123456ABC123/cancel' \
  -H 'X-API-Key: your_api_key' \
  -H 'X-API-Secret: your_api_secret'

Response

JSON
{
  "status": "success",
  "message": "Deposit cancelled successfully"
}

Payout Endpoints

Method Endpoint Description
GET /api/v1/payout/:requestId Get payout details by ID
GET /api/v1/payout/status/:requestId Get payout status (public, no auth required)
GET /api/v1/payouts List all payouts with pagination

🏧 Bank Codes

Supported bank codes and their logos.

Bank logo URL pattern
https://white-lable.sgp1.cdn.digitaloceanspaces.com/banks-logos/{CODE}.png
KBANK
KBANK004
SCB
SCB014
BBL
BBL002
KTB
KTB006
BAY
BAY025
TTB
TTB011
GSB
GSB030
CIMB
CIMB022
UOB
UOB024
TISCO
TISCO067
KKP
KKP069
CITI
CITI017
HSBC
HSBC031
ICBC
ICBC070
GHB
GHB033
IBANK
IBANK066
BAAC
BAAC034
LHB
LHB073
TCRB
TCRB071