# Payment Gateway API — Complete Integration Reference

> Version: 1.0
> Format: Single-file LLM-friendly reference (Markdown)
> Audience: Developers integrating with the payment gateway, plus AI assistants helping them
> Source HTML reference: https://<your-docs-domain>/integration/

This document is a **comprehensive, self-contained reference** for the payment gateway REST API. It covers authentication, all HTTP endpoints, all webhook callbacks, the full error catalog, bank codes, and a cookbook of integration patterns. Paste it into an AI assistant (ChatGPT, Claude, Cursor, Copilot, etc.) along with your task to get accurate, production-ready integration code.

The placeholders below appear throughout the document — replace them with the real values issued to you:

| Placeholder | Meaning |
|---|---|
| `<your-api-domain>` | The HTTPS host the gateway exposes for your account, e.g. `api.example.com` |
| `<your-merchant-webhook-URL>` | The HTTPS host on **your** side that will receive callbacks |
| `<your-payment-page-domain>` | A hosted payment-page domain, when configured (returned in `data.payment_url`) |
| `<gateway-user-agent>` | The fixed `User-Agent` string the gateway uses for outbound callbacks; ask your payment gateway admin if you need the exact value |
| `YOUR_MERCHANT_ID` / `YOUR_TOKEN` / `YOUR_SECRET` | Credentials issued to you by your payment gateway admin |

---

## Table of Contents

1. [Quick Start](#1-quick-start)
2. [Authentication](#2-authentication)
3. [Request / Response Conventions](#3-request--response-conventions)
4. [Order ID Format](#4-order-id-format)
5. [Bank Codes (THB)](#5-bank-codes-thb)
6. [Endpoint Reference](#6-endpoint-reference)
   - 6.1 [POST /balance](#61-post-balance)
   - 6.2 [POST /payment/create (QR)](#62-post-paymentcreate-qr)
   - 6.3 [POST /payment/create-transfer](#63-post-paymentcreate-transfer)
   - 6.4 [POST /payment/query](#64-post-paymentquery)
   - 6.5 [POST /withdraw/create](#65-post-withdrawcreate)
   - 6.6 [POST /withdraw/query](#66-post-withdrawquery)
   - 6.7 [POST /thb-settlement/create](#67-post-thb-settlementcreate)
   - 6.8 [POST /thb-settlement/query](#68-post-thb-settlementquery)
   - 6.9 [POST /slip/upload](#69-post-slipupload)
7. [Webhooks (Callbacks)](#7-webhooks-callbacks)
   - 7.1 [Overview, Delivery & Retry](#71-overview-delivery--retry)
   - 7.2 [Payment Callback](#72-payment-callback)
   - 7.3 [Withdraw / Settlement Callback](#73-withdraw--settlement-callback)
8. [Error Catalog](#8-error-catalog)
9. [Flow Diagrams](#9-flow-diagrams)
10. [Cookbook — Common Integration Patterns](#10-cookbook--common-integration-patterns)
11. [Quick Reference Card](#11-quick-reference-card)

---

## 1. Quick Start

### What this API does

A multi-tenant Thai Baht (THB) payment gateway. As a merchant integrating with it, you can:

- **Accept deposits** from your end customers via PromptPay QR (`/payment/create`) or bank transfer instructions (`/payment/create-transfer`).
- **Pay out to end customers** (cashout) via `/withdraw/create`.
- **Settle your own balance** to your company's bank account via `/thb-settlement/create`.
- **Check balance & status** via `/balance` and `*/query` endpoints.
- **Receive async notifications** at your `notify_url` when orders complete (webhooks).

### Credentials you need

Issued by your payment gateway admin:

1. **API base URL** — `https://<your-api-domain>` (per-tenant; varies)
2. **`merchant_id`** — alphanumeric identifier for your merchant account
3. **`token`** — API token, sent in every request body
4. **`secret`** — HMAC-SHA256 key. Sign every outgoing request and verify every incoming webhook with this key. **Server-side only — never expose.**

### Hello-world: call `/balance`

The simplest end-to-end test. Replace `YOUR_*` placeholders, run it, you should get a JSON balance.

**cURL**
```bash
TIME=$(date +%s)
BODY="{\"merchant_id\":\"YOUR_MERCHANT_ID\",\"token\":\"YOUR_TOKEN\",\"time\":$TIME}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/balance \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```

**Node.js (fetch + node:crypto, no dependencies)**
```js
import crypto from 'node:crypto';

const BASE   = 'https://<your-api-domain>';
const MID    = 'YOUR_MERCHANT_ID';
const TOKEN  = 'YOUR_TOKEN';
const SECRET = 'YOUR_SECRET';

const body = JSON.stringify({
  merchant_id: MID,
  token:       TOKEN,
  time:        Math.floor(Date.now() / 1000),
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');

const r = await fetch(`${BASE}/balance`, {
  method:  'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body,                       // send the SAME string we signed
});
console.log(r.status, await r.json());
```

**PHP (curl)**
```php
<?php
$base   = 'https://<your-api-domain>';
$mid    = 'YOUR_MERCHANT_ID';
$token  = 'YOUR_TOKEN';
$secret = 'YOUR_SECRET';

$body = json_encode(
    ['merchant_id' => $mid, 'token' => $token, 'time' => time()],
    JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init("$base/balance");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Expected response**
```json
{
  "code":    200,
  "message": "Success",
  "data":    { "balance": 1500.00, "freeze_balance": 200.00, "unsettle_balance": 50.00 },
  "success": true
}
```

If you get `403 signature-error` instead, you re-serialized the JSON body somewhere between signing and sending. See [§2 Authentication](#2-authentication).

---

## 2. Authentication

### Required headers (every authenticated endpoint)

| Header | Required | Description |
|---|---|---|
| `Content-Type` | Yes | Must be `application/json` |
| `X-SIGNATURE` | Yes | HMAC-SHA256 of the **raw request body**, hex-encoded (lowercase, 64 chars) |

> No `Authorization` header. No `Bearer` token. No `X-API-KEY` header. Identity is established entirely through the `token` in the request body and the HMAC over that body.

### Required body fields

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` | string | Yes | Alphanumeric, ends with a digit. Issued by admin. |
| `token` | string | Yes | API token; must match exactly. |
| `time` | number \| string | Yes | Unix epoch in **seconds**, e.g. `1656272222`. Compute fresh per request — don't hardcode. Examples: `Math.floor(Date.now()/1000)` (Node), `time()` (PHP), `$(date +%s)` (bash). |

Endpoint-specific fields (e.g. `merchant_order_id`, `amount`, `bank`) are added on top — see each endpoint section.

### Signature algorithm

```
X-SIGNATURE = lowercase_hex( HMAC-SHA256( key = secret, message = raw_request_body ) )
```

**Critical rules:**

1. **Sign the exact bytes you send.** Build the JSON string once, sign that string, send that same string. Don't parse-then-re-stringify.
2. **UTF-8 encoding.** Strings are UTF-8 bytes before HMAC.
3. **Lowercase hex output.** 64 chars (a–f, 0–9).
4. **Field order matters at byte level.** Reordering keys or changing whitespace invalidates the signature.

### Common pitfall: re-serializing the body

Frameworks like Express, Laravel, Django, ASP.NET often parse JSON into objects and serialize on the way out. The output may differ from the input in whitespace, key order, or unicode escaping — and the signature breaks.

**Fix:** in Node.js, always `JSON.stringify` once and pass that string both to HMAC and to the request body. In PHP, use `json_encode` once with consistent flags (`JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`) and reuse the string. Don't pass an array to a higher-level HTTP client that does its own serialization.

### Worked example

Given:
```
secret = "s3cr3t-key-xyz"
body   = {"merchant_id":"AA12345678","token":"abc-token-123","time":"1746692400"}
```

The signature is:
```
HMAC-SHA256("{\"merchant_id\":\"AA12345678\",\"token\":\"abc-token-123\",\"time\":\"1746692400\"}", "s3cr3t-key-xyz")
= <hex digest>
```

Compute it, drop into `X-SIGNATURE`, send. The server runs the same HMAC over the bytes it receives and compares.

### How the server verifies (for reference)

1. Verify HTTP method is `POST`.
2. Read raw body bytes (before any JSON parsing).
3. Look up the merchant by `merchant_id` in the body; verify `token` matches.
4. Compute `HMAC-SHA256(raw_body, merchant.secret)`; compare against `X-SIGNATURE`.
5. If an IP whitelist is configured for the merchant, verify source IP is in the list.
6. If all pass, route to the endpoint handler.

### IP whitelist (optional)

Your admin may restrict source IPs. When configured, requests from other IPs return `403 ip-not-whitelisted`. If you operate behind NAT or a CDN, ensure your **egress IP** matches what's registered. Contact your admin to update the list.

### Authentication errors (409/403/400)

| HTTP | Error code | Meaning |
|---|---|---|
| 405 | `method-not-allowed` | Used a non-POST method on a POST-only endpoint |
| 403 | `authentication-failed` | Wrong `merchant_id`, wrong `token`, or merchant not found |
| 403 | `signature-required` | Missing `X-SIGNATURE` header |
| 403 | `signature-error` | HMAC doesn't match the body — usually re-serialization |
| 403 | `ip-not-whitelisted` | Source IP not allowed |
| 400 | `invalid-inputs` | Body missing or not valid JSON |

See [§8 Error Catalog](#8-error-catalog) for full handling guidance.

---

## 3. Request / Response Conventions

### HTTP

- **Method:** `POST` for every endpoint (except `GET /health` if exposed).
- **Protocol:** HTTPS only; plain HTTP is rejected.
- **Encoding:** JSON body, UTF-8.

### Success response shape

Every successful response uses the same envelope:

```json
{
  "code":    200,
  "message": "Success",
  "data":    { ... endpoint-specific payload ... },
  "success": true
}
```

- `code` — HTTP status mirrored as a number
- `message` — human-readable, may change wording
- `data` — endpoint-specific result object (or an empty object on rare write-only endpoints)
- `success` — always `true` on 200

### Error response shape

```json
{
  "code":    422,
  "error":   "invalid-inputs",
  "success": false,
  "message": "amount must be at least 20"
}
```

- `code` — HTTP status mirrored as a number
- `error` — **stable** kebab-case identifier; branch your code on this
- `success` — always `false`
- `message` — human description; may change wording — **do not parse**

### Numeric values

All amounts (THB) come back as JSON `number` with **2 decimal places**. For exact arithmetic, parse with a decimal-safe library (`BigDecimal` in Java, `Decimal` in Python, `decimal.js` in JS) instead of native floats.

### Datetime values

- API request body `time` field: **Unix epoch in seconds** (e.g. `1746692400`)
- API response `*_datetime` fields: **`YYYY-MM-DD HH:mm:ss` in GMT+7** (string)
- Webhook `timestamp` field: **Unix epoch in milliseconds** (e.g. `1746692400000`)

> Note the unit difference: requests use seconds, webhook payloads use milliseconds.

### Idempotency

- **Payments / Settlements:** unique `merchant_order_id` per merchant for 7 days.
- **Withdrawals:** typically no `merchant_order_id` uniqueness check, but the same `bank` + `account_no` + `amount` within 3 minutes may be blocked as a duplicate (when enabled).
- **Slip upload:** rate-limited per order (1 per 5 minutes, plus a configurable initial wait time).

### Common HTTP statuses

| HTTP | Meaning | Typical `error` |
|---|---|---|
| 200 | Success | — |
| 400 | Bad input or auth issue | `invalid-inputs`, `signature-error`, `signature-required`, `authentication-failed` |
| 403 | Permission/auth/IP | `permission-denied`, `ip-not-whitelisted`, etc. |
| 404 | Resource not found | `not-found` |
| 405 | Wrong HTTP method | `method-not-allowed` |
| 409 | Conflict / duplicate | `duplicate-entry` |
| 422 | Unprocessable | `invalid-inputs` |
| 429 | Rate limited | `too-many-requests`, `channel-limit-reached` |
| 503 | Backend unavailable | `service-unavailable` |

---

## 4. Order ID Format

Every order created on the gateway gets a 24-character `platform_order_id`:

```
<3-char prefix> <mode marker> <YYYYMMDD> <12 random alphanumeric chars>
└── 3 chars ──┘└── 1 char ──┘└── 8 chars ──┘└────── 12 chars ─────┘
                    ▲
                    │
                    └─ 'P' = payment, 'W' = withdraw, 'M' = settlement
```

**Examples** (3-char prefix `ABC` is illustrative — your prefix is set by the gateway):

| Order kind | Example `platform_order_id` | Mode marker (4th char) |
|---|---|---|
| Payment | `ABCP20260508abc123XYZ456` | `P` |
| Withdraw (to end customer) | `ABCW20260508abc123XYZ456` | `W` |
| Settlement (to your own bank) | `ABCM20260509abc123XYZ456` | `M` |

**Why this matters for callbacks:** withdraw and settlement callbacks share the **same payload shape** and `mode` value (`"WITHDRAW"`). To distinguish them, look at `platform_order_id[3]` (the mode marker, 4th char). `'M'` ⇒ settlement; otherwise withdraw.

```js
// Node.js
const modeMarker = event.platform_order_id[3];   // 'W' or 'M'
const kind       = modeMarker === 'M' ? 'settlement' : 'withdraw';
```
```php
// PHP
$modeMarker = $event['platform_order_id'][3] ?? '';
$kind       = $modeMarker === 'M' ? 'settlement' : 'withdraw';
```

> **Don't say "starts with W"** — the W is at position 3 (0-indexed), not position 0. The first 3 chars are your account's prefix.

---

## 5. Bank Codes (THB)

Endpoints that accept a `bank` field (`/payment/create`, `/payment/create-transfer`, `/withdraw/create`, `/thb-settlement/create`) require the canonical uppercase code.

| Code | Bank |
|---|---|
| `BAAC` | Bank for Agriculture and Agricultural Cooperatives |
| `BAY`  | Bank of Ayudhya (Krungsri) |
| `BBL`  | Bangkok Bank |
| `CIMB` | CIMB Thai |
| `CITI` | Citibank |
| `GHB`  | Government Housing Bank |
| `GSB`  | Government Savings Bank |
| `KBANK`| Kasikornbank |
| `KK`   | Kiatnakin Phatra Bank |
| `KTB`  | Krungthai Bank |
| `LH`   | Land and Houses Bank |
| `SC`   | Standard Chartered |
| `SCB`  | Siam Commercial Bank |
| `SCIB` | Siam City Bank |
| `TISCO`| Tisco Bank |
| `TTB`  | TMBThanachart Bank |
| `UOB`  | UOB Thailand |

Sending an unknown code returns `422 invalid-inputs` with `message: "invalid bank code"`. The list may grow over time.

---

## 6. Endpoint Reference

All endpoints use `POST` with `Content-Type: application/json` and require headers/body fields from [§2 Authentication](#2-authentication). Below, only endpoint-specific fields and responses are detailed.

---

### 6.1 POST /balance

Retrieve current balance, frozen balance, and unsettled balance. Read-only.

**URL**: `https://<your-api-domain>/balance`

**Request body** — only the auth fields (`merchant_id`, `token`, `time`).

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "balance":          1500.00,
    "freeze_balance":   200.00,
    "unsettle_balance": 50.00
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.balance` | number | Available balance — withdrawable. 2 decimal places. |
| `data.freeze_balance` | number | Held by pending withdrawals/settlements. Released back to `balance` on failure or timeout. |
| `data.unsettle_balance` | number | From successful payments not yet settled into the withdrawable pool. |

**Errors:** Only auth errors (see [§8](#8-error-catalog)).

**Notes**

- Withdrawable amount = `balance`. Items in `freeze_balance` and `unsettle_balance` are **not** available.
- Polling /balance hot is fine but unnecessary. Balance only changes on payment / withdraw / settlement completion.

---

### 6.2 POST /payment/create (QR)

Create a deposit order. Returns a PromptPay QR code for the customer to scan.

**URL**: `https://<your-api-domain>/payment/create`

**Request body**

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` | string | Yes | (auth) |
| `token` | string | Yes | (auth) |
| `time` | number\|string | Yes | (auth) |
| `merchant_order_id` | string | Yes | Your unique order ID. Alphanumeric, max 40 chars. Unique per merchant for 7 days. |
| `amount` | number\|string | Yes | THB. Min 20.00. 2 decimal places. |
| `bank` | string | Yes | Customer's bank code (uppercase, see [§5](#5-bank-codes-thb)) |
| `account_name` | string | Yes | Customer's name (display / matching) |
| `account_no` | string | Yes | Customer's account number, 10–15 digits. Non-digit chars are stripped. |
| `notify_url` | string | Optional | HTTPS URL we POST the callback to. If omitted, you must poll `/payment/query`. |

**Code samples**

```bash
TIME=$(date +%s)
BODY="{\"merchant_id\":\"AA12345678\",\"token\":\"YOUR_TOKEN\",\"time\":$TIME,\"merchant_order_id\":\"ORDER-2026-001\",\"amount\":\"500.00\",\"bank\":\"KBANK\",\"account_name\":\"สมชาย ใจดี\",\"account_no\":\"1234567890\",\"notify_url\":\"https://<your-merchant-webhook-URL>/payment-callback\"}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/payment/create \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```
```js
import crypto from 'node:crypto';
const SECRET = 'YOUR_SECRET';
const body = JSON.stringify({
  merchant_id:       'AA12345678',
  token:             'YOUR_TOKEN',
  time:              Math.floor(Date.now() / 1000),
  merchant_order_id: 'ORDER-2026-001',
  amount:            '500.00',
  bank:              'KBANK',
  account_name:      'สมชาย ใจดี',
  account_no:        '1234567890',
  notify_url:        'https://<your-merchant-webhook-URL>/payment-callback',
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const r = await fetch('https://<your-api-domain>/payment/create', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body
});
console.log(await r.json());
```
```php
<?php
$secret = 'YOUR_SECRET';
$body = json_encode([
    'merchant_id'       => 'AA12345678',
    'token'             => 'YOUR_TOKEN',
    'time'              => time(),
    'merchant_order_id' => 'ORDER-2026-001',
    'amount'            => '500.00',
    'bank'              => 'KBANK',
    'account_name'      => 'สมชาย ใจดี',
    'account_no'        => '1234567890',
    'notify_url'        => 'https://<your-merchant-webhook-URL>/payment-callback',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init('https://<your-api-domain>/payment/create');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCP20260508abc123XYZ456",
    "merchant_order_id": "ORDER-2026-001",
    "uuid":              "0190b4a2-7c1d-7000-9f3a-2c8e5b1a4d6f",
    "order_datetime":    "2026-05-08 10:30:00",
    "expire_datetime":   "2026-05-08 10:45:00",
    "amount":            500.00,
    "transfer_amount":   500.03,
    "payment_type":      "QR",
    "qrcode":            "00020101021129370016A0000006770101110113006611234567895802TH540...",
    "payment_url":       "https://<your-payment-page-domain>/p/0190b4a2-7c1d-7000-9f3a-2c8e5b1a4d6f"
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.platform_order_id` | string | Gateway-assigned 24-char order ID (mode marker `P`) |
| `data.merchant_order_id` | string | Echo of input |
| `data.uuid` | string (UUID v7) | Globally-unique order UUID; used in `payment_url` |
| `data.order_datetime` | string | Creation time, `YYYY-MM-DD HH:mm:ss` GMT+7 |
| `data.expire_datetime` | string | Expiration time, GMT+7. Stop showing the QR after this passes. |
| `data.amount` | number | The amount you requested |
| `data.transfer_amount` | number | The amount the customer must actually transfer (may be `amount` + a small adjustment for slot uniqueness — e.g. `+0.03`). **Always show this**, not `amount`. |
| `data.payment_type` | enum | Always `"QR"` on this endpoint |
| `data.qrcode` | string | EMV-formatted PromptPay QR string. Render as a QR image. |
| `data.payment_url` | string\|null | Hosted payment page URL with QR + countdown. `null` if no payment-page domain configured. |

> **CRITICAL:** Display `transfer_amount` to the customer, not `amount`. If the customer pays `amount`, our settlement system can't match the deposit to this order.

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing/invalid field, amount < 20, invalid bank, malformed URL |
| 409 | `duplicate-entry` | Same `merchant_order_id` used in past 7 days |
| 403 | `permission-denied` | Payment disabled on your merchant |
| 503 | `service-unavailable` | No deposit account currently available — retry shortly |
| 429 | `channel-limit-reached` | Deposit channel hit hourly/daily limit |
| 404 | `not-found` | Partner config missing — contact admin |

**Notes**

- **Idempotency:** retry with same `merchant_order_id` returns `duplicate-entry` for 7 days.
- **Order TTL:** stays `open` until paid or until configured expiration window.
- **Slot allocation:** the system reserves a unique `transfer_amount` slot on a deposit account for ~15 min to disambiguate concurrent deposits.
- **Fees** are calculated on completion; not in this response.
- **Always also display the destination account info** as a fallback (in case the QR can't be scanned).

---

### 6.3 POST /payment/create-transfer

Same as `/payment/create`, but returns destination bank info instead of a QR. Use when the receiving deposit account doesn't support PromptPay (Thailand's standard instant transfer service); also when a customer specifically needs to transfer by account number.

**URL**: `https://<your-api-domain>/payment/create-transfer`

**Request body** — **identical** to [§6.2 /payment/create](#62-post-paymentcreate-qr).

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id":    "ABCP20260508abc123XYZ456",
    "merchant_order_id":    "ORDER-2026-001",
    "uuid":                 "0190b4a2-7c1d-7000-9f3a-2c8e5b1a4d6f",
    "order_datetime":       "2026-05-08 10:30:00",
    "expire_datetime":      "2026-05-08 10:45:00",
    "amount":               500.00,
    "transfer_amount":      500.03,
    "payment_type":         "TRANSFER",
    "deposit_bank":         "KBANK",
    "deposit_account_no":   "9876543210",
    "deposit_account_name": "บริษัท เมอร์แชนต์ จำกัด",
    "qrcode":               null,
    "payment_url":          "https://<your-payment-page-domain>/p/0190b4a2-7c1d-7000-9f3a-2c8e5b1a4d6f"
  },
  "success": true
}
```

**Differences from `/payment/create`**

| Field | Difference |
|---|---|
| `data.payment_type` | Always `"TRANSFER"` |
| `data.qrcode` | Always `null` |
| `data.deposit_bank` | Destination bank code (e.g. `"KBANK"`) — show to customer |
| `data.deposit_account_no` | Destination account number — show to customer |
| `data.deposit_account_name` | Destination account holder name — show to customer |

All other fields are identical to `/payment/create`.

> **CRITICAL:** Customer must transfer **exactly** `transfer_amount`. A different amount won't be matched to this order.

**Errors:** Same as [§6.2 /payment/create](#62-post-paymentcreate-qr).

---

### 6.4 POST /payment/query

Read-only lookup for a payment order created via `/payment/create` or `/payment/create-transfer`. Use as a fallback when a callback is missed or to reconcile state.

**URL**: `https://<your-api-domain>/payment/query`

> **Webhook is the source of truth.** Don't poll this endpoint as your primary signal. Listen for the webhook callback first; only call `/payment/query` if the callback fails to arrive.

**Request body**

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` / `token` / `time` | — | Yes | (auth) |
| `platform_order_id` | string | Yes | The 24-char order ID from `/payment/create` (mode marker `P`) |

**Code samples**

```bash
TIME=$(date +%s)
BODY="{\"merchant_id\":\"AA12345678\",\"token\":\"YOUR_TOKEN\",\"time\":$TIME,\"platform_order_id\":\"ABCP20260508abc123XYZ456\"}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/payment/query \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```
```js
import crypto from 'node:crypto';
const SECRET = 'YOUR_SECRET';
const body = JSON.stringify({
  merchant_id:       'AA12345678',
  token:             'YOUR_TOKEN',
  time:              Math.floor(Date.now() / 1000),
  platform_order_id: 'ABCP20260508abc123XYZ456',
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const r = await fetch('https://<your-api-domain>/payment/query', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body
});
console.log(await r.json());
```
```php
<?php
$secret = 'YOUR_SECRET';
$body = json_encode([
    'merchant_id'       => 'AA12345678',
    'token'             => 'YOUR_TOKEN',
    'time'              => time(),
    'platform_order_id' => 'ABCP20260508abc123XYZ456',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init('https://<your-api-domain>/payment/query');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCP20260508abc123XYZ456",
    "merchant_order_id": "ORDER-2026-001",
    "order_datetime":    "2026-05-08 10:30:00",
    "amount":            500.00,
    "status":            "settled_paid",
    "expire_datetime":   "2026-05-08 10:45:00",
    "payment_datetime":  "2026-05-08 10:32:11"
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.platform_order_id` | string | Echo of input |
| `data.merchant_order_id` | string | Your original order ID |
| `data.order_datetime` | string | Creation time, GMT+7 |
| `data.amount` | number | THB |
| `data.status` | enum | `open`, `unsettled_paid`, `settled_paid`, `error`, `freeze` (see below) |
| `data.expire_datetime` | string | Expiry, GMT+7 |
| `data.payment_datetime` | string\|null | When payment was confirmed; `null` if not paid yet |

**Status semantics**

| `status` | Meaning |
|---|---|
| `open` | Waiting for payment |
| `unsettled_paid` | Paid; settling into your balance |
| `settled_paid` | Paid and credited to your balance — terminal success |
| `error` | Expired or rejected — terminal failure |
| `freeze` | Held by admin (manual review) |

> **Status mapping vs webhook callback:** the webhook payment callback uses uppercase `PAID` / `FAIL`. This query endpoint uses internal lower-case status. Roughly: `PAID` ↔ `settled_paid` (briefly via `unsettled_paid`); `FAIL` ↔ `error`.

> **No `account_no` / `qrcode` / `payment_type` here.** Those are returned only by the create endpoints. Store them on your side from the create response.

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing or malformed `platform_order_id` |
| 404 | `not-found` | Order not found, or owned by a different merchant |

**Notes**

- Results may be cached for ~5 minutes. Once status is terminal (`settled_paid` / `error`), it doesn't change.
- Reasonable polling: every 30s while `open` and you suspect a missed callback. Stop on terminal status.

---

### 6.5 POST /withdraw/create

Pay out from your merchant balance to an **end customer's** bank account (e.g. cashout). Per-transaction; typically uses a percentage fee.

> **Withdraw vs settlement:** this endpoint is for paying **end customers**. To withdraw your own merchant balance into your **company's** bank account, use [§6.7 /thb-settlement/create](#67-post-thb-settlementcreate). Different fee model.

**URL**: `https://<your-api-domain>/withdraw/create`

**Request body**

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` / `token` / `time` | — | Yes | (auth) |
| `merchant_order_id` | string | Yes | Alphanumeric, max 40 chars |
| `amount` | number\|string | Yes | THB. Default min 20.00. 2 decimal places. |
| `bank` | string | Yes | Destination bank code (uppercase, see [§5](#5-bank-codes-thb)) |
| `account_name` | string | Yes | Destination account holder name |
| `account_no` | string | Yes | 10–15 digits |
| `notify_url` | string | Optional | HTTPS URL for the callback. If omitted, poll `/withdraw/query`. |

**Code samples**

```bash
TIME=$(date +%s)
BODY="{\"merchant_id\":\"AA12345678\",\"token\":\"YOUR_TOKEN\",\"time\":$TIME,\"merchant_order_id\":\"PAYOUT-2026-001\",\"amount\":\"1000.00\",\"bank\":\"KBANK\",\"account_name\":\"ลูกค้า ปลายทาง\",\"account_no\":\"1234567890\",\"notify_url\":\"https://<your-merchant-webhook-URL>/withdraw-callback\"}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/withdraw/create \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```
```js
import crypto from 'node:crypto';
const SECRET = 'YOUR_SECRET';
const body = JSON.stringify({
  merchant_id:       'AA12345678',
  token:             'YOUR_TOKEN',
  time:              Math.floor(Date.now() / 1000),
  merchant_order_id: 'PAYOUT-2026-001',
  amount:            '1000.00',
  bank:              'KBANK',
  account_name:      'ลูกค้า ปลายทาง',
  account_no:        '1234567890',
  notify_url:        'https://<your-merchant-webhook-URL>/withdraw-callback',
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const r = await fetch('https://<your-api-domain>/withdraw/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body
});
console.log(await r.json());
```
```php
<?php
$secret = 'YOUR_SECRET';
$body = json_encode([
    'merchant_id'       => 'AA12345678',
    'token'             => 'YOUR_TOKEN',
    'time'              => time(),
    'merchant_order_id' => 'PAYOUT-2026-001',
    'amount'            => '1000.00',
    'bank'              => 'KBANK',
    'account_name'      => 'ลูกค้า ปลายทาง',
    'account_no'        => '1234567890',
    'notify_url'        => 'https://<your-merchant-webhook-URL>/withdraw-callback',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init('https://<your-api-domain>/withdraw/create');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCW20260508abc123XYZ456",
    "merchant_order_id": "PAYOUT-2026-001",
    "order_datetime":    "2026-05-08 11:00:00",
    "amount":            1000.00,
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "ลูกค้า ปลายทาง"
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.platform_order_id` | string | 24-char withdraw order ID (mode marker `W`) |
| `data.merchant_order_id` | string | Echo |
| `data.order_datetime` | string | Creation time, GMT+7 |
| `data.amount` | number | Payout amount |
| `data.bank` | string | Echo of input |
| `data.account_no` | string | Echo of input |
| `data.account_name` | string | Echo of input |

> **No `status` in the create response.** The order is implicitly `open` right after creation. The final status (`SUCCESS` / `FAIL`) arrives via the webhook callback (or is visible via `/withdraw/query`).

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing/invalid fields; amount outside min/max; insufficient available balance |
| 409 | `duplicate-entry` | Same `bank` + `account_no` + `amount` within past 3 minutes (when duplicate prevention is enabled) |
| 403 | `permission-denied` | Withdraw disabled on your merchant |
| 404 | `not-found` | Partner config missing — contact admin |

**Notes**

- **Balance hold (10 minutes).** On successful creation, `amount + fixed_fee` is held from your balance. Released if the order fails.
- **Status flow.** `open` → `success` or `failed`. Once final, never reverts.
- **Bank rejection.** Closed account, name mismatch, etc. → callback delivers `status: "FAIL"`.

---

### 6.6 POST /withdraw/query

Read-only lookup for a withdraw order. Use as a fallback when the webhook is missed.

**URL**: `https://<your-api-domain>/withdraw/query`

**Request body**

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` / `token` / `time` | — | Yes | (auth) |
| `platform_order_id` | string | Yes | The 24-char withdraw order ID (mode marker `W`) |

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCW20260508abc123XYZ456",
    "merchant_order_id": "PAYOUT-2026-001",
    "order_datetime":    "2026-05-08 11:00:00",
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "ลูกค้า ปลายทาง",
    "amount":            1000.00,
    "status":            "success",
    "done_datetime":     "2026-05-08 11:00:42"
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.platform_order_id` | string | Echo |
| `data.merchant_order_id` | string | Your original `merchant_order_id` |
| `data.order_datetime` | string | Creation time, GMT+7 |
| `data.bank` / `account_no` / `account_name` | string | Echo of destination details |
| `data.amount` | number | Payout amount |
| `data.status` | enum | `open` (in progress) \| `success` \| `failed` |
| `data.done_datetime` | string\|null | Completion time, GMT+7; `null` while `open` |

> **Internal-status mapping:** internal `new`/`queue`/`pending` → `open`; `success` → `success`; `failed`/`error`/`cancelled`/`rejected` → `failed`.

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing or malformed `platform_order_id` |
| 404 | `not-found` | Order not found or owned by a different merchant |

---

### 6.7 POST /thb-settlement/create

Withdraw funds from your **own merchant balance** to your **company's** bank account in THB. The periodic settlement of accumulated profits/revenue. **Fixed per-transaction fee.** May be subject to operating-hour restrictions.

**URL**: `https://<your-api-domain>/thb-settlement/create`

> **Settlement vs withdraw analogy (casino):** if your merchant balance is the cashier's float, `/withdraw/create` pays a *player* their winnings (B2C, percentage fee), while `/thb-settlement/create` moves the float into the casino's *own* corporate account at end of shift (B2B, flat fee).

**Request body** — schema is identical to [§6.5 /withdraw/create](#65-post-withdrawcreate). Differences are in fee model and behavior.

| Field | Notes specific to settlement |
|---|---|
| `amount` | Min/max set per account (typical 100 – 500,000) |
| `merchant_order_id` | Atomic dedup (Redis) for 7 days — duplicate calls fail immediately |

**Code samples**

```bash
TIME=$(date +%s)
BODY="{\"merchant_id\":\"AA12345678\",\"token\":\"YOUR_TOKEN\",\"time\":$TIME,\"merchant_order_id\":\"SETTLE-2026-001\",\"amount\":\"50000.00\",\"bank\":\"KBANK\",\"account_name\":\"บริษัท เมอร์แชนต์ จำกัด\",\"account_no\":\"1234567890\",\"notify_url\":\"https://<your-merchant-webhook-URL>/settlement-callback\"}"
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/thb-settlement/create \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```
```js
import crypto from 'node:crypto';
const SECRET = 'YOUR_SECRET';
const body = JSON.stringify({
  merchant_id:       'AA12345678',
  token:             'YOUR_TOKEN',
  time:              Math.floor(Date.now() / 1000),
  merchant_order_id: 'SETTLE-2026-001',
  amount:            '50000.00',
  bank:              'KBANK',
  account_name:      'บริษัท เมอร์แชนต์ จำกัด',
  account_no:        '1234567890',
  notify_url:        'https://<your-merchant-webhook-URL>/settlement-callback',
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const r = await fetch('https://<your-api-domain>/thb-settlement/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body
});
console.log(await r.json());
```
```php
<?php
$secret = 'YOUR_SECRET';
$body = json_encode([
    'merchant_id'       => 'AA12345678',
    'token'             => 'YOUR_TOKEN',
    'time'              => time(),
    'merchant_order_id' => 'SETTLE-2026-001',
    'amount'            => '50000.00',
    'bank'              => 'KBANK',
    'account_name'      => 'บริษัท เมอร์แชนต์ จำกัด',
    'account_no'        => '1234567890',
    'notify_url'        => 'https://<your-merchant-webhook-URL>/settlement-callback',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init('https://<your-api-domain>/thb-settlement/create');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCM20260509abc123XYZ456",
    "merchant_order_id": "SETTLE-2026-001",
    "order_datetime":    "2026-05-09 03:00:00",
    "amount":            50000.00,
    "fee":               30.00,
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "บริษัท เมอร์แชนต์ จำกัด",
    "status":            "open"
  },
  "success": true
}
```

**Response fields** (additions vs withdraw)

| Field | Type | Description |
|---|---|---|
| `data.platform_order_id` | string | 24-char settlement order ID (mode marker `M`) |
| `data.fee` | number | Fixed merchant settlement fee. Total deducted from balance = `amount + fee`. |
| `data.status` | string | Always `"open"` on creation |
| (other fields: `merchant_order_id`, `order_datetime`, `amount`, `bank`, `account_no`, `account_name`) | — | Echo / standard |

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing/invalid fields; amount outside min/max; insufficient balance for `amount + fee` |
| 409 | `duplicate-entry` | Same `merchant_order_id` already used (atomic, 7 days) |
| 403 | `permission-denied` | Settlement disabled, OR current time is outside the configured operating hours |
| 404 | `not-found` | Partner config missing — contact admin |

**Notes**

- **Atomic dedup.** Unlike regular withdraws (3-min window), settlement uses atomic Redis — second call fails immediately.
- **Operating hours.** May be restricted to business hours; outside the window → `permission-denied`.
- **Fixed fee model.** Flat THB amount per settlement, independent of `amount`. Confirm value with admin.
- **Callback uses the WITHDRAW callback shape**, with `M` mode marker in `platform_order_id`. See [§7.3](#73-withdraw--settlement-callback).

---

### 6.8 POST /thb-settlement/query

Read-only lookup for a settlement order.

**URL**: `https://<your-api-domain>/thb-settlement/query`

**Request body** — same as [§6.6 /withdraw/query](#66-post-withdrawquery), but `platform_order_id` mode marker must be `M`.

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "platform_order_id": "ABCM20260509abc123XYZ456",
    "merchant_order_id": "SETTLE-2026-001",
    "order_datetime":    "2026-05-09 03:00:00",
    "amount":            50000.00,
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "บริษัท เมอร์แชนต์ จำกัด",
    "status":            "success",
    "done_datetime":     "2026-05-09 03:05:18"
  },
  "success": true
}
```

Field semantics match `/withdraw/query`: `status` is one of `open` \| `success` \| `failed`; `done_datetime` is `null` while open.

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 400 | `invalid-inputs` | Missing or malformed `platform_order_id` (mode marker must be `M`) |
| 404 | `not-found` | Settlement not found or owned by a different merchant |

---

### 6.9 POST /slip/upload

Submit a payment slip image (base64-encoded) for an existing payment order. The system extracts QR data from the slip, validates it against the order's amount/destination/timing, and either flags the order for admin review or rejects it.

**URL**: `https://<your-api-domain>/slip/upload`

> Use this only when payment is suspected but not auto-detected (e.g. customer transferred wrong amount, or used a different account). Most payments don't require slip upload — the system detects them and fires the callback automatically.

**Request body**

| Field | Type | Required | Notes |
|---|---|---|---|
| `merchant_id` / `token` / `time` | — | Yes | (auth) |
| `platform_order_id` | string | Yes | Payment order ID (mode marker must be `P`). Slip upload is **only** valid for payments. |
| `image_base64` | string | Yes | Base64-encoded image bytes (PNG or JPG). Max ~2 MB after decoding. **Don't include the `data:image/...;base64,` prefix** — encoded bytes only. |

**Code samples**

```bash
B64=$(base64 -i slip.jpg | tr -d '\n')

BODY=$(jq -nc \
  --arg mid   "AA12345678" \
  --arg tok   "YOUR_TOKEN" \
  --argjson t $(date +%s) \
  --arg pid   "ABCP20260508abc123XYZ456" \
  --arg img   "$B64" \
  '{merchant_id:$mid, token:$tok, time:$t, platform_order_id:$pid, image_base64:$img}')

SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" | awk '{print $2}')

curl -X POST https://<your-api-domain>/slip/upload \
  -H "Content-Type: application/json" \
  -H "X-SIGNATURE: $SIG" \
  -d "$BODY"
```
```js
import crypto from 'node:crypto';
import fs from 'node:fs';

const SECRET = 'YOUR_SECRET';
const imageB64 = fs.readFileSync('./slip.jpg').toString('base64');

const body = JSON.stringify({
  merchant_id:       'AA12345678',
  token:             'YOUR_TOKEN',
  time:              Math.floor(Date.now() / 1000),
  platform_order_id: 'ABCP20260508abc123XYZ456',
  image_base64:      imageB64,
});
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const r = await fetch('https://<your-api-domain>/slip/upload', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-SIGNATURE': sig },
  body
});
console.log(await r.json());
```
```php
<?php
$secret = 'YOUR_SECRET';
$imageB64 = base64_encode(file_get_contents('slip.jpg'));

$body = json_encode([
    'merchant_id'       => 'AA12345678',
    'token'             => 'YOUR_TOKEN',
    'time'              => time(),
    'platform_order_id' => 'ABCP20260508abc123XYZ456',
    'image_base64'      => $imageB64,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $body, $secret);

$ch = curl_init('https://<your-api-domain>/slip/upload');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', "X-SIGNATURE: $sig"],
]);
echo curl_exec($ch);
```

**Success response (HTTP 200)**

```json
{
  "code":    200,
  "message": "Success",
  "data":    {
    "msg":              "slip upload successful.",
    "verification_msg": "amount ok, destination ok, transfer in time."
  },
  "success": true
}
```

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.msg` | string | Always `"slip upload successful."` on success — confirms the upload was received |
| `data.verification_msg` | string | Human-readable QR/transaction validation result |

> **HTTP 200 ≠ slip accepted as proof of payment.** It only means the upload was processed. Read `verification_msg`. Order status only advances when validation passes.

**Sample `verification_msg` values**

The string is composed from comma-separated check results.

| Message | Meaning |
|---|---|
| `amount ok, destination ok, transfer in time.` | QR-type slip — all three checks passed |
| `amount ok, destination ok (TRANSFER), transfer in time.` | TRANSFER-type slip — all three checks passed |
| `ref ok, transfer in time.` | QR slip — payment reference matched (preferred path) |
| `destination error, amount mismatch, transfer expired.` | All three checks failed |
| `… , transfer before create.` | Suffix: slip's transaction time is earlier than order creation (recycled / wrong slip) |
| `… , ref error` | Suffix on QR slips when the QR ref doesn't match the order |
| `qrcode error` | QR could not be parsed from the slip |
| `qrcode error - missing transaction date/time` | QR parsed but missing transaction timestamp |
| `can't read qr data.` | No QR detected in the image |

**Errors**

| HTTP | `error` | When |
|---|---|---|
| 422 | `invalid-inputs` | Missing field, invalid `platform_order_id` format, image > 2,000,000 bytes, not PNG/JPG. `message` tells you which. |
| 404 | `not-found` | Payment order not found or owned by a different merchant. `message`: `"payment order not found"` |
| 403 | `permission-denied` | Order is not in `open` status — `"order status is not open"`. Or order > 30 days old — `"order is older than 30 days"` |
| 429 | `too-many-requests` | Rate limit. Either uploaded another slip for same order in past 5 min (`"please wait 5 minutes before next upload"`), or order too fresh (`"please wait N seconds before next upload"`) |

Auth errors (`signature-error`, `authentication-failed`, etc.) are handled by the auth middleware — see [§2](#2-authentication).

**Example error response**

```http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "code":    422,
  "error":   "invalid-inputs",
  "success": false,
  "message": "image too large (max 2,000,000 bytes)"
}
```

**Notes**

- **Order must be in `open` status.** Already-`success`/`failed` orders return error.
- **Order must be ≤ 30 days old.** Older orders return `"slip upload expired."`.
- **Minimum wait after creation.** Some accounts have `slip_upload_wait_time` (seconds) — first upload too early returns `"please wait N seconds and try uploading again."`. Ask admin for the value.
- **Rate limit:** 1 upload per order per 5 minutes.
- **Image format:** PNG/JPG only (verified by magic bytes); max 2,000,000 bytes after base64 decode.
- **What happens on success:** image is stored in S3, attached to the order, order is flagged for admin review (regardless of whether QR validation passed). Operations team reviews and either marks `success` or investigates further. `verification_msg` is informational — final settlement decisions go through human review.

---

## 7. Webhooks (Callbacks)

When you create an order with a `notify_url`, the gateway sends an HTTP POST to that URL once the order reaches a terminal state. This is your asynchronous notification — you do not need to poll.

### 7.1 Overview, Delivery & Retry

| Trigger | Callback type | `mode` in payload |
|---|---|---|
| Customer pays for a payment order | Payment Callback | `"PAYMENT"` |
| Withdraw order completes (success or fail) | Withdraw / Settlement Callback | `"WITHDRAW"` |
| Settlement order completes | Withdraw / Settlement Callback | `"WITHDRAW"` |

> **Settlements use the WITHDRAW callback shape.** A settlement callback is shaped exactly like a withdraw callback. The only difference is the **mode marker** (4th char) of `platform_order_id`: `M` (settlement) instead of `W` (withdraw).

### Wire-level shape

```http
POST /your-callback-path HTTP/1.1
Host: <your-merchant-webhook-URL>
User-Agent: <gateway-user-agent>
Content-Type: application/json
Connection: close
X-Signature: 7b3d4e8f1a2c5b6e9d0f1a2c3b4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d

{"merchant_id":"AA12345678","platform_order_id":"...","merchant_order_id":"...",...}
```

**Headers**

| Header | Value / Note |
|---|---|
| `User-Agent` | Static identifier set by the gateway. Ask admin for the exact value if you want to filter/log on it. |
| `Content-Type` | Always `application/json` |
| `Connection` | Always `close` — no keep-alive |
| `X-Signature` | HMAC-SHA256 hex of the raw JSON body, using your `secret`. **Title-cased** (`X-Signature`, not `X-SIGNATURE`). Most HTTP frameworks normalize header case. |

### Verifying the signature

**Always verify `X-Signature`.** Without verification, anyone can forge a fake callback and trick your system into marking unpaid orders as paid. Same HMAC-SHA256 over raw body, same `secret` you use for outgoing requests.

**Bash (test/debug)**
```bash
RAW_BODY=$(cat captured-payload.json)
RECEIVED_SIG="<signature from X-Signature header>"
SECRET='YOUR_SECRET'

EXPECTED=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

[ "$EXPECTED" = "$RECEIVED_SIG" ] && echo "OK" || echo "FAIL"
```

**Node.js (Express, raw body)**
```js
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.GATEWAY_SECRET;
const app = express();

// IMPORTANT: capture raw body BEFORE JSON parsing
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody  = req.body;                                  // Buffer
    const received = req.get('x-signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(rawBody)
                           .digest('hex');

    // Use timing-safe comparison
    const ok = received.length === expected.length &&
               crypto.timingSafeEqual(
                 Buffer.from(received, 'hex'),
                 Buffer.from(expected, 'hex')
               );

    if (!ok) return res.status(401).send('bad signature');

    const event = JSON.parse(rawBody.toString('utf8'));
    // ... handle event idempotently ...
    res.status(200).send('ok');
  }
);
```

**PHP**
```php
<?php
$secret = getenv('GATEWAY_SECRET');

// Read raw body BEFORE any framework parsing
$rawBody = file_get_contents('php://input');

$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);

// Timing-safe comparison
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'bad signature';
    exit;
}

$event = json_decode($rawBody, true);
// ... handle $event idempotently ...

http_response_code(200);
echo 'ok';
```

> **Verify the raw body — not your re-serialized copy.** If your framework auto-parses JSON and you re-`stringify` before HMAC, key order or whitespace differences will break verification. Capture `req.rawBody` / `php://input` first, then parse.

### How to acknowledge

Respond with **HTTP 200** to acknowledge receipt. Any other status code (or a timeout) counts as failure → retry.

| Your response | Outcome |
|---|---|
| HTTP 200 (any body) | ✅ Acknowledged. No further attempts. |
| HTTP 4xx / 5xx | 🔄 Retry up to 5 attempts, 60s apart. |
| No response within 60s | 🔄 Counted as failure → retry. |
| All 5 attempts fail | ⛔ Final failure logged on our side. We will *not* retry further. The order's status itself is unaffected — only the notification is dropped. |

### Retry policy

| Setting | Value |
|---|---|
| Max attempts | 5 |
| Delay between attempts | 60 seconds (constant; no exponential backoff) |
| Per-request timeout | 60 seconds |
| Success criterion | HTTP 200 |
| Total max delivery window | ~5 minutes (5 × 60s + processing) |
| Concurrency | Up to ~50 callbacks delivered in parallel system-wide |

> **No ordering guarantee.** Callbacks are not guaranteed to arrive in order. Two orders that completed seconds apart may have their callbacks delivered in either sequence (especially after retries).

### Idempotency

Duplicate deliveries are possible (e.g. your endpoint times out at second 59 after committing state — our retry sends the same payload again). **Your handler must be safe to call twice.**

**Recommended handler pattern**

1. **Verify signature** against the raw body. Reject 401 on mismatch.
2. **Parse JSON.** Extract `platform_order_id` as the unique key.
3. **Insert-or-ignore** a row in your callback log table on `(platform_order_id, status)`. If the row already exists with the same status, return 200 immediately — already processed.
4. **Update your order** within a transaction.
5. **Return HTTP 200** only after the DB transaction commits.

---

### 7.2 Payment Callback

Sent when a payment order reaches `PAID` (success) or `FAIL` (failure).

**Example payloads**

Success:
```json
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCP20260508abc123XYZ456",
  "merchant_order_id": "ORDER-2026-001",
  "mode":              "PAYMENT",
  "amount":            500.00,
  "status":            "PAID",
  "timestamp":         1746692400000
}
```

Failure:
```json
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCP20260508abc123XYZ456",
  "merchant_order_id": "ORDER-2026-001",
  "mode":              "PAYMENT",
  "amount":            500.00,
  "status":            "FAIL",
  "timestamp":         1746692400000
}
```

**Field reference**

| Field | Type | Description |
|---|---|---|
| `merchant_id` | string | Your merchant identifier (echo) |
| `platform_order_id` | string | 24-char order ID (mode marker `P`). **Use as your unique key for dedup.** |
| `merchant_order_id` | string | Your `merchant_order_id` from `/payment/create` |
| `mode` | enum | Always `"PAYMENT"` for this callback |
| `amount` | number | THB. **Always cross-check against your stored order.** |
| `status` | enum | `"PAID"` on success, `"FAIL"` on failure (expired, rejected). Terminal — won't change. |
| `timestamp` | number | Unix epoch in **milliseconds**. The moment the order's status was marked. |

### Reference handler

**Node.js (Express)**
```js
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.GATEWAY_SECRET;
const app = express();

app.post('/payment-callback',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody  = req.body;          // Buffer
    const received = req.get('x-signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(rawBody)
                           .digest('hex');

    // 1. Timing-safe signature check
    if (received.length !== expected.length ||
        !crypto.timingSafeEqual(
          Buffer.from(received, 'hex'),
          Buffer.from(expected, 'hex'))) {
      return res.status(401).send('bad signature');
    }

    const event = JSON.parse(rawBody.toString('utf8'));
    if (event.mode !== 'PAYMENT') {
      return res.status(400).send('wrong mode');
    }

    // 2. Idempotency: insert-or-ignore on platform_order_id
    const inserted = await db.insertCallbackLog({
      platform_order_id: event.platform_order_id,
      status:            event.status,
      raw_body:          rawBody.toString('utf8'),
    });
    if (!inserted) return res.status(200).send('ok'); // already processed

    // 3. Cross-check amount against stored order
    const order = await db.findOrder(event.merchant_order_id);
    if (!order || Number(order.amount) !== Number(event.amount)) {
      return res.status(400).send('amount mismatch');
    }

    // 4. Update order status atomically
    if (event.status === 'PAID') {
      await db.markOrderPaid(order.id);
    } else {
      await db.markOrderFailed(order.id);
    }

    res.status(200).send('ok');
  }
);
```

**PHP**
```php
<?php
// payment-callback.php
$secret = getenv('GATEWAY_SECRET');

// 1. Read RAW body before any framework parsing
$rawBody  = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);

// 2. Timing-safe signature check
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'bad signature';
    exit;
}

$event = json_decode($rawBody, true);
if (($event['mode'] ?? null) !== 'PAYMENT') {
    http_response_code(400);
    echo 'wrong mode';
    exit;
}

// 3. Idempotency — insert-or-ignore on (platform_order_id, status)
$pdo = new PDO(/* ... */);
$stmt = $pdo->prepare(
    'INSERT IGNORE INTO callback_log
        (platform_order_id, status, raw_body, received_at)
     VALUES (?, ?, ?, NOW())'
);
$stmt->execute([
    $event['platform_order_id'],
    $event['status'],
    $rawBody,
]);
if ($stmt->rowCount() === 0) {
    http_response_code(200);
    echo 'ok';   // already processed
    exit;
}

// 4. Cross-check amount
$order = $pdo->prepare('SELECT * FROM orders WHERE merchant_order_id = ?');
$order->execute([$event['merchant_order_id']]);
$row = $order->fetch();
if (!$row || (float)$row['amount'] !== (float)$event['amount']) {
    http_response_code(400);
    echo 'amount mismatch';
    exit;
}

// 5. Mark order
$newStatus = $event['status'] === 'PAID' ? 'paid' : 'failed';
$pdo->prepare('UPDATE orders SET status = ? WHERE id = ?')
    ->execute([$newStatus, $row['id']]);

http_response_code(200);
echo 'ok';
```

**Bash (local testing)**
```bash
SECRET='YOUR_SECRET'
BODY='{"merchant_id":"AA12345678","platform_order_id":"ABCP20260508abc123XYZ456","merchant_order_id":"ORDER-2026-001","mode":"PAYMENT","amount":500,"status":"PAID","timestamp":1746692400000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://<your-merchant-webhook-URL>/payment-callback \
  -H "User-Agent: <gateway-user-agent>" \
  -H "Content-Type: application/json" \
  -H "Connection: close" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
```

**Quick checklist**

- ✅ Read the raw body before any JSON parsing
- ✅ Verify `X-Signature` with timing-safe comparison
- ✅ Reject if `mode` is not `"PAYMENT"`
- ✅ Use `platform_order_id` as the unique key for dedup
- ✅ Cross-check `amount` against your stored order
- ✅ Return HTTP 200 only after your DB transaction commits

---

### 7.3 Withdraw / Settlement Callback

Sent when a withdraw OR settlement order reaches `SUCCESS` or `FAIL`. Both share the same payload shape — distinguish by the **mode marker** (4th char) of `platform_order_id`.

| Origin | `platform_order_id` mode marker | `mode` |
|---|---|---|
| `/withdraw/create` | `W` (e.g. `ABC**W**20260508abc123XYZ456`) | `"WITHDRAW"` |
| `/thb-settlement/create` | `M` (e.g. `ABC**M**20260509abc123XYZ456`) | `"WITHDRAW"` |

**Format reminder:** 24 chars = `<3-char prefix>` + `P/W/M` (mode marker) + `YYYYMMDD` + 12 random chars.

**Example payloads**

Withdraw success:
```json
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCW20260508abc123XYZ456",
  "merchant_order_id": "PAYOUT-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "ลูกค้า ปลายทาง",
  "amount":            1000.00,
  "status":            "SUCCESS",
  "timestamp":         1746694842000
}
```

Settlement success:
```json
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCM20260509abc123XYZ456",
  "merchant_order_id": "SETTLE-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "บริษัท เมอร์แชนต์ จำกัด",
  "amount":            50000.00,
  "status":            "SUCCESS",
  "timestamp":         1746780318000
}
```

Failed payout:
```json
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCW20260508abc123XYZ456",
  "merchant_order_id": "PAYOUT-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "ลูกค้า ปลายทาง",
  "amount":            1000.00,
  "status":            "FAIL",
  "timestamp":         1746694842000
}
```

**Field reference**

| Field | Type | Description |
|---|---|---|
| `merchant_id` | string | Echo |
| `platform_order_id` | string | 24-char order ID. **Use the 4th char to tell withdraw (`W`) from settlement (`M`).** Use as dedup key. |
| `merchant_order_id` | string | Your `merchant_order_id` from the create call |
| `mode` | enum | Always `"WITHDRAW"` for both withdraw and settlement |
| `bank` / `account_no` / `account_name` | string | Echo of destination |
| `amount` | number | Payout amount (THB). Cross-check against stored order. |
| `status` | enum | `"SUCCESS"` on success, `"FAIL"` on failure (bank rejection, insufficient funds, account closed). Terminal. |
| `timestamp` | number | Unix epoch in **milliseconds** |

> **PAYMENT vs WITHDRAW status values differ.** Payment callbacks use `PAID`/`FAIL`; withdraw/settlement use `SUCCESS`/`FAIL`. Branch on `mode` first, then on `status`.

### Reference handler (covers both withdraw and settlement)

**Node.js (Express)**
```js
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.GATEWAY_SECRET;
const app = express();

app.post('/withdraw-callback',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody  = req.body;
    const received = req.get('x-signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(rawBody)
                           .digest('hex');

    // 1. Signature
    if (received.length !== expected.length ||
        !crypto.timingSafeEqual(
          Buffer.from(received, 'hex'),
          Buffer.from(expected, 'hex'))) {
      return res.status(401).send('bad signature');
    }

    const event = JSON.parse(rawBody.toString('utf8'));
    if (event.mode !== 'WITHDRAW') {
      return res.status(400).send('wrong mode');
    }

    // 2. Distinguish withdraw vs settlement by mode marker (4th char)
    const modeMarker = event.platform_order_id[3];   // 'W' or 'M'
    const kind       = modeMarker === 'M' ? 'settlement' : 'withdraw';

    // 3. Idempotency
    const inserted = await db.insertCallbackLog({
      platform_order_id: event.platform_order_id,
      status:            event.status,
      raw_body:          rawBody.toString('utf8'),
    });
    if (!inserted) return res.status(200).send('ok');

    // 4. Cross-check + update
    const order = await db.findOrder(kind, event.merchant_order_id);
    if (!order || Number(order.amount) !== Number(event.amount)) {
      return res.status(400).send('amount mismatch');
    }

    if (event.status === 'SUCCESS') {
      await db.markOrderSuccess(kind, order.id);
    } else {
      await db.markOrderFailed(kind, order.id);
    }

    res.status(200).send('ok');
  }
);
```

**PHP**
```php
<?php
// withdraw-callback.php — handles both withdraw and settlement callbacks
$secret = getenv('GATEWAY_SECRET');

$rawBody  = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);

// 1. Signature
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'bad signature';
    exit;
}

$event = json_decode($rawBody, true);
if (($event['mode'] ?? null) !== 'WITHDRAW') {
    http_response_code(400);
    echo 'wrong mode';
    exit;
}

// 2. Distinguish withdraw vs settlement by mode marker (4th char)
$modeMarker = $event['platform_order_id'][3] ?? '';
$kind       = $modeMarker === 'M' ? 'settlement' : 'withdraw';

// 3. Idempotency
$pdo = new PDO(/* ... */);
$stmt = $pdo->prepare(
    'INSERT IGNORE INTO callback_log
        (platform_order_id, status, raw_body, received_at)
     VALUES (?, ?, ?, NOW())'
);
$stmt->execute([
    $event['platform_order_id'],
    $event['status'],
    $rawBody,
]);
if ($stmt->rowCount() === 0) {
    http_response_code(200);
    echo 'ok';
    exit;
}

// 4. Cross-check + update
$table = $kind === 'settlement' ? 'settlements' : 'withdraws';
$lookup = $pdo->prepare("SELECT * FROM {$table} WHERE merchant_order_id = ?");
$lookup->execute([$event['merchant_order_id']]);
$row = $lookup->fetch();

if (!$row || (float)$row['amount'] !== (float)$event['amount']) {
    http_response_code(400);
    echo 'amount mismatch';
    exit;
}

$newStatus = $event['status'] === 'SUCCESS' ? 'success' : 'failed';
$pdo->prepare("UPDATE {$table} SET status = ? WHERE id = ?")
    ->execute([$newStatus, $row['id']]);

http_response_code(200);
echo 'ok';
```

**Bash (local testing)**
```bash
SECRET='YOUR_SECRET'
BODY='{"merchant_id":"AA12345678","platform_order_id":"ABCW20260508abc123XYZ456","merchant_order_id":"PAYOUT-2026-001","mode":"WITHDRAW","bank":"KBANK","account_no":"1234567890","account_name":"ลูกค้า ปลายทาง","amount":1000,"status":"SUCCESS","timestamp":1746694842000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://<your-merchant-webhook-URL>/withdraw-callback \
  -H "User-Agent: <gateway-user-agent>" \
  -H "Content-Type: application/json" \
  -H "Connection: close" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
```

**Quick checklist**

- ✅ Read the raw body before any JSON parsing
- ✅ Verify `X-Signature` with timing-safe comparison
- ✅ Reject if `mode` is not `"WITHDRAW"`
- ✅ Distinguish withdraw vs settlement by the mode marker (4th char `W`/`M`) of `platform_order_id` — they typically live in different tables on your side
- ✅ Use `platform_order_id` as the unique key for dedup
- ✅ Cross-check `amount` against your stored order
- ✅ Return HTTP 200 only after your DB transaction commits

---

## 8. Error Catalog

### Error response shape

```json
{
  "code":    422,
  "error":   "invalid-inputs",
  "success": false,
  "message": "amount must be at least 20"
}
```

- `code` — HTTP status mirrored as a number
- `error` — **stable** kebab-case identifier — branch your code on this
- `success` — always `false` on errors
- `message` — human-readable; **do not parse**

All endpoints (including `/slip/upload`) use this format. Build one error handler, use it everywhere.

### Authentication errors (any authenticated endpoint)

| HTTP | `error` | Meaning | What to do |
|---|---|---|---|
| 405 | `method-not-allowed` | Used a non-POST method | Switch to POST |
| 403 | `authentication-failed` | Wrong `merchant_id`, wrong `token`, or merchant not found | Re-check credentials with admin |
| 403 | `signature-required` | Missing `X-SIGNATURE` header | Compute and add the header |
| 403 | `signature-error` | HMAC doesn't match the body | Sign the exact bytes you send (don't re-serialize) |
| 403 | `ip-not-whitelisted` | Source IP not allowed | Ask admin to add your egress IP, or remove whitelist |
| 400 | `invalid-inputs` | Body missing or not valid JSON | Verify `Content-Type` and JSON syntax |

### Endpoint-specific errors

| HTTP | `error` | Endpoints | When |
|---|---|---|---|
| 400 | `invalid-inputs` | All | Missing/invalid fields, amount out of range, bank not recognized, malformed URL, insufficient balance for create |
| 409 | `duplicate-entry` | `/payment/create`, `/payment/create-transfer`, `/withdraw/create`, `/thb-settlement/create` | Duplicate `merchant_order_id` within 7 days (payment, settlement) or `bank+account+amount` within 3 minutes (withdraw, when enabled) |
| 403 | `permission-denied` | `/payment/create`, `/withdraw/create`, `/thb-settlement/create`, `/slip/upload` | Operation disabled on your merchant; or settlement called outside operating hours; or order not in `open` status; or order > 30 days old |
| 503 | `service-unavailable` | `/payment/create`, `/payment/create-transfer` | No deposit account currently available — retry shortly |
| 429 | `channel-limit-reached` | `/payment/create`, `/payment/create-transfer` | Deposit channel hit hourly/daily limit |
| 429 | `too-many-requests` | `/slip/upload` | Rate limit — >1 slip upload per order in 5 minutes, or order too fresh |
| 404 | `not-found` | All query endpoints; all create endpoints (partner config) | Order not found / not owned by you, or partner config missing on the merchant |

### Recommended handling

| Error class | Strategy |
|---|---|
| `signature-error`, `signature-required` | Bug in your code — fix and redeploy. **Don't retry the same request.** |
| `authentication-failed`, `ip-not-whitelisted` | Configuration issue — escalate to your payment gateway admin |
| `invalid-inputs`, `permission-denied` | Caller error — log and surface to your operator. No retry. |
| `duplicate-entry` | If you intended idempotent retry, treat as success — query the existing order to confirm |
| `service-unavailable`, `channel-limit-reached` | Retry with exponential backoff (e.g. 30s → 1m → 2m → 5m). Surface to operator after a few minutes. |
| `not-found` | For queries: investigate. For creates: contact admin. |
| 5xx | Retry with backoff. If persistent, escalate. |

> **Never retry signature/auth errors.** They indicate deterministic bugs — retrying with the same payload will fail every time. Worse, retries against payment/withdraw/settlement create endpoints can create duplicate orders if the bug is in your body-building code path.

---

## 9. Flow Diagrams

### Typical payment (deposit) flow

```mermaid
sequenceDiagram
    actor Customer
    participant Merchant
    participant PG as Payment Gateway
    participant Bank

    autonumber
    Customer->>Merchant: Request topup
    Merchant->>PG: Create payment order (with notify_url)
    PG-->>Merchant: Return QR code or account number
    Merchant-->>Customer: Show QR code or account number
    Customer->>Bank: Pay
    autonumber off
    PG<<->>Bank: Syncs bank statements
    autonumber 6
    PG->>Merchant: Callback payment completed
    Merchant-->>PG: HTTP 200 ack
    Merchant-->>Customer: Credit topup success
```

Steps:
1. **Create order** — call `/payment/create` with `merchant_order_id`, `amount`, customer bank info, `notify_url`.
2. **Show payment instruction** — display the returned QR code or transfer details. Note `transfer_amount` may be slightly higher than `amount` for slot uniqueness.
3. **Wait for callback** — when the customer pays, we POST a callback to `notify_url`. **Always verify the X-Signature header.**
4. **Acknowledge** — respond HTTP 200. Anything else triggers a retry (up to 5, 60s apart).
5. **(Optional) Poll** — if you don't receive a callback, call `/payment/query` to check status.

### Typical withdraw (payout) flow

```mermaid
sequenceDiagram
    actor Customer
    participant Merchant
    participant PG as Payment Gateway
    participant Bank

    autonumber
    Customer->>Merchant: Withdraw
    Merchant->>PG: Create withdraw order (with notify_url)
    PG-->>Merchant: {platform_order_id, status:'open'}
    Note over PG: Hold amount + fee from balance (10-min hold)
    PG->>Bank: Initiate transfer to destination account
    Bank-->>PG: Confirm (success or fail)
    PG->>Merchant: Callback withdraw completed (SUCCESS or FAIL)
    Merchant-->>PG: HTTP 200 ack
```

Steps:
1. **Create payout** — call `/withdraw/create` with destination bank, account, amount, `notify_url`. Order is created `open` and your balance gets a 10-minute hold.
2. **Wait for callback** — final status (`SUCCESS`/`FAIL`) is delivered async.
3. **Acknowledge with HTTP 200.** Same retry rules as payment.

> **Duplicate prevention** (when enabled): two consecutive `/withdraw/create` calls with the same `bank` + `account_no` + `amount` within 3 minutes are blocked. Use a unique `merchant_order_id` and your own idempotency layer.

---

## 10. Cookbook — Common Integration Patterns

### 10.1 Verify a webhook signature (any language)

The pattern is always the same:
1. Read the **raw bytes** of the request body before any JSON parser touches them.
2. `expected = HMAC-SHA256(raw_bytes, your_secret)` → hex.
3. Compare with `X-Signature` header in **constant time**.

| Language | Raw body | HMAC | Constant-time compare |
|---|---|---|---|
| Node.js (Express) | `express.raw({ type: 'application/json' })` middleware → `req.body` Buffer | `crypto.createHmac('sha256', secret).update(buf).digest('hex')` | `crypto.timingSafeEqual(Buffer.from(a,'hex'), Buffer.from(b,'hex'))` |
| Node.js (Fastify) | `app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body))` → `req.body` Buffer | same | same |
| Python (Flask) | `request.get_data(cache=True, as_text=False)` → bytes | `hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()` | `hmac.compare_digest(a, b)` |
| Python (FastAPI) | `body = await request.body()` → bytes | same | same |
| PHP | `file_get_contents('php://input')` | `hash_hmac('sha256', $raw, $secret)` | `hash_equals($expected, $received)` |
| Go | `body, _ := io.ReadAll(r.Body)` (don't call before, or buffer with `r.Body = io.NopCloser(bytes.NewBuffer(body))`) | `mac := hmac.New(sha256.New, []byte(secret)); mac.Write(body); hex.EncodeToString(mac.Sum(nil))` | `hmac.Equal([]byte(a), []byte(b))` |
| Ruby (Rails) | `request.raw_post` | `OpenSSL::HMAC.hexdigest('sha256', secret, raw)` | `ActiveSupport::SecurityUtils.secure_compare(a, b)` |
| Java | Capture raw body via a custom `HttpServletRequestWrapper` (Spring's `ContentCachingRequestWrapper`) | `Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); HexFormat.of().formatHex(mac.doFinal(raw))` | `MessageDigest.isEqual(a.getBytes(), b.getBytes())` |

### 10.2 Idempotent webhook handler skeleton

The 4-step pattern that survives every duplicate delivery:

```js
async function handleCallback(rawBody, headers) {
  // 1. Verify signature (raw bytes!)
  const sig = headers['x-signature'];
  if (!verifyHmac(rawBody, sig, SECRET)) throw new Error('bad signature');

  const event = JSON.parse(rawBody.toString('utf8'));

  // 2. Validate the event type (defense-in-depth)
  if (!['PAYMENT', 'WITHDRAW'].includes(event.mode)) {
    throw new Error(`unexpected mode: ${event.mode}`);
  }

  // 3. Insert-or-ignore the callback log row.
  //    UNIQUE INDEX on (platform_order_id, status).
  //    rowCount === 0 means we've already processed this exact transition.
  const inserted = await db.insertOrIgnore('callback_log', {
    platform_order_id: event.platform_order_id,
    status:            event.status,
    raw_body:          rawBody,
    received_at:       new Date(),
  });
  if (!inserted) return; // already processed — return 200

  // 4. Apply business effects in a transaction.
  await db.transaction(async (tx) => {
    const order = await tx.findOrder(event.merchant_order_id);
    if (!order) throw new Error('order not found');
    if (Number(order.amount) !== Number(event.amount)) {
      throw new Error('amount mismatch');
    }
    await tx.applyTerminalStatus(order, event.status);
  });
}
```

**SQL for the dedup table (MySQL):**

```sql
CREATE TABLE callback_log (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  platform_order_id VARCHAR(24) NOT NULL,
  status            VARCHAR(16) NOT NULL,
  raw_body          MEDIUMBLOB  NOT NULL,
  received_at       DATETIME    NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_status (platform_order_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 10.3 Reconciliation polling fallback

Webhooks can fail (your service was down, ack was slow, network glitch). Run a periodic reconciliation job to pick up missed updates.

```js
// every 5 minutes
async function reconcile() {
  const stale = await db.findOrders({
    status: 'open',
    created_before: new Date(Date.now() - 60_000),  // > 1 min old
    created_after:  new Date(Date.now() - 24*60*60_000), // < 24h old
  });

  for (const order of stale) {
    const r = await callQueryEndpoint(order.kind, order.platform_order_id);
    if (!r.success) continue;

    // Map query status to terminal status
    const queryStatus = r.data.status;
    if (order.kind === 'payment' && queryStatus === 'settled_paid') {
      await markPaid(order);
    } else if (queryStatus === 'success') {
      await markSuccess(order);
    } else if (['error', 'failed'].includes(queryStatus)) {
      await markFailed(order);
    }
    // else: still open, leave it alone
  }
}
```

**Reasonable polling cadence**

- Check orders that have been `open` for more than 60s (don't race the webhook).
- Check at most every 30s per order.
- Stop polling once you see a terminal status.
- Don't poll orders older than the relevant TTL (e.g. 24h for payments).

### 10.4 Distinguish withdraw vs settlement in your callback handler

```js
const modeMarker = event.platform_order_id[3];
const kind       = modeMarker === 'M' ? 'settlement' : 'withdraw';
```

Why position 3 (the 4th char)? See [§4 Order ID Format](#4-order-id-format).

If you have separate tables/services for payouts vs settlements (most teams do), use `kind` to route to the right one:

```js
const repo = kind === 'settlement' ? settlementRepo : withdrawRepo;
const order = await repo.findByMerchantOrderId(event.merchant_order_id);
```

### 10.5 Retry strategy by error class

```js
async function call(endpoint, body) {
  const r = await postSigned(endpoint, body);

  if (r.status === 200) return r.data;

  // Don't retry deterministic bugs
  if (['signature-error', 'signature-required',
       'authentication-failed', 'ip-not-whitelisted',
       'invalid-inputs', 'permission-denied'].includes(r.data.error)) {
    throw new FatalError(r.data);
  }

  // Idempotent retries
  if (r.data.error === 'duplicate-entry') {
    // We already created this order — confirm via query
    return await queryOrder(body.merchant_order_id);
  }

  // Transient — bounded backoff retry
  if (['service-unavailable', 'channel-limit-reached'].includes(r.data.error)
      || r.status >= 500) {
    return await retryWithBackoff(endpoint, body, [30_000, 60_000, 120_000, 300_000]);
  }

  throw new FatalError(r.data);
}
```

### 10.6 Show the right amount to your customer (payment)

When a customer is about to pay, your UI should show **`transfer_amount`**, not the original `amount`:

```js
const { qrcode, transfer_amount, deposit_account_no, deposit_bank } = paymentOrder.data;

ui.show({
  amountToPay:      transfer_amount,                  // 500.03, NOT 500.00
  qr:               qrcode,                            // for /payment/create
  fallbackAccount:  `${deposit_bank} ${deposit_account_no}`, // for /payment/create-transfer
  expiresAt:        paymentOrder.data.expire_datetime, // GMT+7 string
});
```

### 10.7 Generate a unique `merchant_order_id`

Constraints: alphanumeric, max 40 chars, unique per merchant for at least 7 days.

```js
import { randomUUID } from 'node:crypto';
const id = `ORDER-${Date.now()}-${randomUUID().split('-')[0]}`;
// e.g. "ORDER-1746692400000-a1b2c3d4"
```

```php
$id = 'ORDER-' . time() . '-' . substr(bin2hex(random_bytes(8)), 0, 8);
```

```bash
ID="ORDER-$(date +%s)-$(openssl rand -hex 4)"
```

### 10.8 Hold-to-balance accounting (withdraw)

When `/withdraw/create` returns success:

| Time | Your `balance` | Your `freeze_balance` | What changed |
|---|---|---|---|
| Before call | 10,000 | 0 | — |
| After 200 OK | 9,000 | 1,000 + fee | Held |
| Bank confirms SUCCESS (callback) | 9,000 (unchanged) | 0 | Hold consumed |
| Bank confirms FAIL (callback) | 10,000 | 0 | Hold released |
| Hold timeout (no callback in 10 min) | 10,000 | 0 | Hold released — but order may still settle later |

**Implication:** if you display "available balance" to your operators, fetch from `/balance` after each terminal callback (or do your own ledger and reconcile against `/balance` periodically).

### 10.9 Slip upload retry rule

The `/slip/upload` endpoint enforces:

1. The order must still be `open` (not yet `success`/`failed`).
2. The order must be ≤ 30 days old.
3. `slip_upload_wait_time` seconds must have passed since order creation.
4. ≤ 1 upload per order per 5 minutes.

A reasonable client-side flow:

```js
async function uploadSlip(orderId, imagePath) {
  const order = await queryPaymentOrder(orderId);
  if (order.data.status !== 'open') throw new Error('order is no longer open');

  const ageMs = Date.now() - new Date(order.data.order_datetime).getTime();
  if (ageMs > 30 * 24 * 60 * 60 * 1000) throw new Error('order > 30 days old');

  try {
    return await callSlipUpload(orderId, imagePath);
  } catch (e) {
    if (e.error === 'too-many-requests') {
      // Show "wait 5 minutes" to the user — don't auto-retry
      return { error: 'rate-limited', message: e.message };
    }
    throw e;
  }
}
```

### 10.10 Local-test a callback receiver

Use the bash recipes in [§7.2](#72-payment-callback) and [§7.3](#73-withdraw--settlement-callback) to fire fake callbacks at your handler. Tunnel your local dev server to a public URL (`ngrok`, `cloudflared tunnel`, `tailscale funnel`) before pointing real `notify_url` values at it.

---

## 11. Quick Reference Card

### Field name & type cheatsheet

| Field | Type | Where | Notes |
|---|---|---|---|
| `merchant_id` | string | All requests + all callbacks | Issued by admin |
| `token` | string | All requests | Issued by admin |
| `time` | number\|string | All requests | **Unix epoch in seconds** |
| `merchant_order_id` | string | Payment/withdraw/settlement create + queries (echo) | ≤ 40 chars, alphanumeric |
| `platform_order_id` | string | Create responses, queries, callbacks | 24 chars, mode marker at position 3 |
| `amount` | number\|string in / number out | Most endpoints | THB, 2 decimals |
| `bank` | string | Create endpoints + withdraw callback | Uppercase code (see [§5](#5-bank-codes-thb)) |
| `account_no` | string | Create endpoints + withdraw callback | 10–15 digits |
| `account_name` | string | Create endpoints + withdraw callback | UTF-8 |
| `notify_url` | string | Create endpoints | HTTPS only |
| `transfer_amount` | number | `/payment/create*` response | What customer must transfer (≥ amount) |
| `qrcode` | string\|null | `/payment/create` response | EMV PromptPay |
| `deposit_bank` / `deposit_account_no` / `deposit_account_name` | string | `/payment/create-transfer` response | Destination bank info |
| `payment_url` | string\|null | `/payment/create*` response | Hosted payment-page URL |
| `*_datetime` | string | Most responses | `YYYY-MM-DD HH:mm:ss` GMT+7 |
| `timestamp` | number | All callbacks | **Unix epoch in milliseconds** |
| `mode` | enum | All callbacks | `"PAYMENT"` or `"WITHDRAW"` |
| `status` | enum | Callbacks + queries | Different vocabularies — see below |
| `fee` | number | `/thb-settlement/create` response | Fixed THB |
| `image_base64` | string | `/slip/upload` request | Bare base64, no `data:` prefix |

### Status vocabulary cheatsheet

| Endpoint / callback | Possible statuses | Terminal |
|---|---|---|
| `/payment/query` | `open`, `unsettled_paid`, `settled_paid`, `error`, `freeze` | `settled_paid`, `error`, `freeze` |
| Payment callback | `PAID`, `FAIL` | both |
| `/withdraw/query`, `/thb-settlement/query` | `open`, `success`, `failed` | `success`, `failed` |
| Withdraw / settlement callback | `SUCCESS`, `FAIL` | both |

### Mode marker → kind

```
platform_order_id[3] === 'P' → payment
platform_order_id[3] === 'W' → withdraw
platform_order_id[3] === 'M' → settlement
```

### URL cheatsheet

```
POST  https://<your-api-domain>/balance
POST  https://<your-api-domain>/payment/create
POST  https://<your-api-domain>/payment/create-transfer
POST  https://<your-api-domain>/payment/query
POST  https://<your-api-domain>/withdraw/create
POST  https://<your-api-domain>/withdraw/query
POST  https://<your-api-domain>/thb-settlement/create
POST  https://<your-api-domain>/thb-settlement/query
POST  https://<your-api-domain>/slip/upload
```

### Decision tree: which endpoint?

```
Need money INTO your merchant balance?
├── From a customer paying with QR → POST /payment/create
└── From a customer paying via plain bank transfer → POST /payment/create-transfer

Need money OUT of your merchant balance?
├── To an end customer's bank account (per-tx, percent fee)
│     → POST /withdraw/create
└── To your own company's bank account (periodic, flat fee)
      → POST /thb-settlement/create

Need to check status?
├── Payment → POST /payment/query
├── Withdraw → POST /withdraw/query
└── Settlement → POST /thb-settlement/query

Need to provide proof of payment for a missed deposit?
└── POST /slip/upload (payment orders only)

Need balance info?
└── POST /balance
```

---

## End of reference

This document was assembled from:
- `01-getting-started.html` through `15-error-codes.html`
- Source: payment-gateway external API, version 1.0

Last updated: 2026-05-09. If you spot a discrepancy with the live API behavior, the live behavior wins — please notify your payment gateway admin and they'll update this document.
