# Payment Gateway — Full API Reference (`llms-full.md`)

> **Single-file reference (English)** for the payment-gateway REST API, version **1.0**. Drop this entire file into ChatGPT / Claude / Cursor / Copilot together with your task and the model has every endpoint, request body, response shape, callback, error, and integration pattern it needs to write a correct, production-ready integration.
>
> ⭐ **For deposits, use [`POST /payment-flex/create`](#82-post-payment-flexcreate--create-payment-order-flex-hosted-payment-page--recommended) (FLEX, hosted payment page).** It automatically uses every payment channel on the platform and returns a `payment_url` that handles the entire customer flow.
>
> Thai: see `llms-full-th.md`.
>
> Last updated: 2026-06-14

---

## Table of Contents

1. [Concepts & Conventions](#1-concepts--conventions)
2. [Base URLs & Environments](#2-base-urls--environments)
3. [Authentication (HMAC-SHA256)](#3-authentication-hmac-sha256)
4. [Common Request / Response Envelope](#4-common-request--response-envelope)
5. [Common Error Codes](#5-common-error-codes)
6. [Bank Codes (Thailand)](#6-bank-codes-thailand)
7. [Order ID Reference](#7-order-id-reference)
8. [Endpoints](#8-endpoints)
   - 8.1 [`POST /balance` — Get Balance](#81-post-balance--get-balance)
   - 8.2 [`POST /payment-flex/create` — Create Payment Order FLEX (Hosted Payment Page) ⭐ RECOMMENDED](#82-post-payment-flexcreate--create-payment-order-flex-hosted-payment-page--recommended)
   - 8.3 [`POST /payment/query` — Query Payment Order](#83-post-paymentquery--query-payment-order)
   - 8.4 [`POST /withdraw/create` — Create Withdraw Order](#84-post-withdrawcreate--create-withdraw-order)
   - 8.5 [`POST /withdraw/query` — Query Withdraw Order](#85-post-withdrawquery--query-withdraw-order)
   - 8.6 [`POST /slip/upload` — Manual Payment Slip Verification](#86-post-slipupload--manual-payment-slip-verification)
   - 8.7 [`POST /verify-qr` — Verify QR Slip (auxiliary)](#87-post-verify-qr--verify-qr-slip-auxiliary)
   - 8.8 [`POST /payment/cancel` — Cancel Payment Order](#88-post-paymentcancel--cancel-payment-order)
   - 8.9 [`POST /payment/cancel-by-merchant-order-id` — Cancel Payment Order (by Merchant Order ID)](#89-post-paymentcancel-by-merchant-order-id--cancel-payment-order-by-merchant-order-id)
9. [Webhooks (Callbacks)](#9-webhooks-callbacks)
   - 9.1 [Payment Callback](#91-payment-callback)
   - 9.2 [Withdraw Callback](#92-withdraw-callback)
   - 9.3 [Callback Verification (Critical)](#93-callback-verification-critical)
10. [End-to-end Flows](#10-end-to-end-flows)
    - 10.1 [Deposit Flow — FLEX (Recommended)](#101-deposit-flow--flex-recommended)
    - 10.2 [Withdraw Flow (8 steps)](#102-withdraw-flow-8-steps)
11. [State Machines](#11-state-machines)
12. [Code Examples (PHP, Node, Python, Java, C#, Go)](#12-code-examples-php-node-python-java-c-go)
13. [Postman / pre-request scripts](#13-postman--pre-request-scripts)
14. [Operational Limits & Gotchas](#14-operational-limits--gotchas)
15. [Best-practice Integration Checklist](#15-best-practice-integration-checklist)
16. [Embedding the Payment Page via iframe](#16-embedding-the-payment-page-via-iframe)

---

## 1. Concepts & Conventions

This is a **REST-style** Thai payment gateway. Its core capability is letting a merchant:

- **Accept deposits** from end users via Thai PromptPay QR or via direct bank transfer. ⭐ **Recommended:** use the **FLEX** endpoint (§8.2) — one API call returns a `payment_url`; the platform automatically uses every payment channel it has, and the hosted payment page handles the entire customer flow (how-to instructions, QR code or destination bank account, slip submission, live status, redirect back).
- **Send withdrawals** from the merchant's balance to an end user's bank account.
- **Reconcile** (query status, upload slips when a callback was missed).

Conventions used everywhere in the API:

- All requests are **`POST` + `application/json`**.
- All responses are JSON. Success uses HTTP `200` with `{"success": 200, "data": {...}}`. Errors return HTTP `403`, `429`, or `500` with `{"error": {"code": <int>, "message": <string>}}`.
- All amounts are **THB**, sent as **strings** with `2` decimal places, **no thousands separator** (e.g. `"1000.00"`, never `"1,000"` and not `1000`).
- Timestamps that you send are **Unix epoch seconds** (integer).
- Datetimes the API returns are `YYYY-MM-DD HH:MM:SS` in **Asia/Bangkok** (UTC+7).
- Every body is signed with **HMAC-SHA256** using your `secret_key`, sent as the **`X-Signature`** header. See §3.
- The destination of webhooks is the `notify_url` you supplied when creating the order. The payment gateway **retries until it gets `HTTP 200`**, so your handler must be **idempotent** (see §9.3).
- The QR variant of payment uses a **decimal nudge** (`transfer_amount` may differ from `amount` by a few satang). The customer must transfer **exactly** `transfer_amount`, otherwise the system may match the payment to a different order.

---

## 2. Base URLs & Environments

| Environment | Base URL | Notes |
|---|---|---|
| Production (live) | `https://api.example.com` | Real money. Use only after you have your production credentials. |
| Production (alt example) | `https://api.example.com` | Placeholder shown in some examples; replace with the URL given to you by your account manager. |
| Staging (UAT) | `https://api-staging.example.com` | Test credentials, mock provider routing. Safe to script against. |

You will be issued **per-environment** credentials:

| Field | Description |
|---|---|
| `merchant_id` | Public identifier, e.g. `AA12345678`, `TH00000000`. |
| `token` | Long opaque API token. Sent in every JSON body. |
| `secret_key` | HMAC secret. **Never** sent over the wire — used only to compute `X-Signature`. |

**Test credentials** (for the signature/callback simulator examples in §12 only):

```
merchant_id: TH00000000
secret_key:  aaaaaaaaaaaaaaaaaaaa
```

---

## 3. Authentication (HMAC-SHA256)

Every request must include three fields in the JSON body and one header:

| Where | Name | Required | Description |
|---|---|---|---|
| Body | `merchant_id` | yes | Your merchant id. |
| Body | `token` | yes | Your API token. |
| Body | `time` | yes | Current Unix timestamp (seconds). Prevents replay attacks. |
| Header | `Content-Type` | yes | Always `application/json`. |
| Header | `X-Signature` | yes | `hash_hmac("sha256", raw_request_body, secret_key)` — **lowercase hex**. |

> **Critical**: the signature is computed over the **exact byte sequence** you send as the HTTP body. If you re-encode after parsing, key ordering / whitespace / number formatting may change and the signature will break. Always sign the same `string` you write to the wire. See §9.3 for verification.

### Signature recipe

```
signature = hex( HMAC-SHA256( key = secret_key, message = raw_json_body ) )
```

### PHP

```php
$body      = json_encode($data);                     // raw body
$signature = hash_hmac('sha256', $body, $secret_key); // hex, lowercase
```

### Node.js

```js
const crypto = require('crypto');
const body      = JSON.stringify(data);
const signature = crypto.createHmac('sha256', secret_key).update(body).digest('hex');
```

### Worked example

```jsonc
// body the client will send (verbatim)
{"merchant_id":"AA12345678","token":"testtokentesttokentesttokentesttokentesttoken","time":1656272222}
```

```bash
# Resulting header:
X-Signature: <64-hex-char hmac-sha256 of the body using your secret>
```

A failure to validate signature, token, or merchant id yields:

```http
HTTP/1.1 403 Forbidden
{"error":{"code":403,"message":"authentication failed"}}
```

---

## 4. Common Request / Response Envelope

### Mandatory request fields (every endpoint)

```jsonc
{
  "merchant_id": "AA12345678",
  "token":       "testtokentesttokentesttokentesttokentesttoken",
  "time":        1656272222,
  // ...endpoint-specific fields
}
```

### Success envelope

```json
{
  "success": 200,
  "data":   { /* endpoint-specific payload */ }
}
```

### Error envelope

```json
{
  "error": {
    "code":    403,
    "message": "authentication failed"
  }
}
```

The HTTP status mirrors the `code`. `code` is one of: `403` (auth), `429` (rate-limited), `500` (validation/business/server). `200` is reserved for success.

---

## 5. Common Error Codes

| HTTP | `code` | Typical `message` | When |
|---|---|---|---|
| `200` | `200` | — (success) | Normal success. |
| `403` | `403` | `authentication failed` | `merchant_id`, `token`, or `X-Signature` invalid; clock skew on `time` too large. |
| `429` | `429` | `Rate limit exceeded for this bank account. Maximum 5 requests per minute. Please try again in N seconds.` | Same `(bank, account_no)` requested too often during payment creation. |
| `500` | `500` | `amount must be greater than 20` | `amount` below floor (20 THB). |
| `500` | `500` | `amount must be less than 49999` | `amount` above ceiling (49,999 THB; 500,000 for VIP partner ids). |
| `500` | `500` | `invalid bank` / `invalid account no` / `invalid account name` / `please provide customer info.` | Field-level validation. |
| `500` | `500` | `merchant order id not found` | Reference lookup failed (query/slip endpoints), or duplicate creation collision. |
| `500` | `500` | `service error. no available channel.` | No payment provider currently available for the requested band/method. Retry later or reduce amount. |
| `500` | `500` | `concurrent payment channel exceeded limitation` | Global concurrency cap reached. Retry shortly. |
| `500` | `500` | `platform_order_id not found` | Slip/query endpoints when the order id does not exist. |

---

## 6. Bank Codes (Thailand)

Use these short codes for `bank` (in payment / withdraw / transfer requests) and for `deposit_bank` (response of TRANSFER):

| Code | Bank |
|---|---|
| `KBANK` | Kasikorn Bank |
| `BBL`   | Bangkok Bank |
| `KTB`   | Krungthai Bank |
| `TTB`   | TMBThanachart Bank |
| `SCB`   | Siam Commercial Bank |
| `UOB`   | UOB |
| `BAY`   | Krungsri Ayudhya |
| `CIMB`  | CIMB |
| `LH`    | Land and Houses |
| `GSB`   | Government Savings Bank |
| `KK`    | Kiatnakin Phatra |
| `CITI`  | Citibank |
| `GHB`   | Government Housing Bank |
| `BAAC`  | Bank for Agriculture and Agricultural Cooperatives |
| `TISCO` | TISCO |
| `CLICX` | CLICX Bank |

---

## 7. Order ID Reference

The payment gateway generates a **`platform_order_id`** for every order. The prefix encodes the type:

| Prefix | Meaning | Returned by |
|---|---|---|
| `THBP…` | Payment (deposit) | `/payment-flex/create` |
| `THBW…` | Withdraw (client withdraw — end user receives money) | `/withdraw/create` |
| `THBM…` | Merchant withdraw / "settlement" — merchant receives money. Internal flow, not exposed via this public API. | (internal) |

You should **never** generate or guess these — always read them out of the `data.platform_order_id` of the response. Keep your own `merchant_order_id` for reconciliation; pass it back as `platform_order_id` to the query endpoints (the API also accepts your `merchant_order_id` in some query paths).

---

## 8. Endpoints

All endpoints are `POST`, body is JSON, `Content-Type: application/json`, and require the `X-Signature` header. The three mandatory fields (`merchant_id`, `token`, `time`) are listed once in §4 and **omitted from per-endpoint tables for brevity** — they are always required.

---

### 8.1 `POST /balance` — Get Balance

Get the merchant's current wallet balances on the platform.

**Method:** `POST` · **URL:** `/balance`

#### Request body

Just the three mandatory fields:

```json
{
  "merchant_id": "AA12345678",
  "token":       "testtokentesttokentesttokentesttokentesttoken",
  "time":        1656272222
}
```

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "balance":          "10000.0000",
    "freeze_balance":   "0.0000",
    "unsettle_balance": "0.0000"
  }
}
```

| Field | Type | Description |
|---|---|---|
| `balance` | string (decimal) | Available balance, in THB. |
| `freeze_balance` | string (decimal) | Amount currently frozen (e.g. queued withdrawals). |
| `unsettle_balance` | string (decimal) | Amount paid by customers but not yet settled to the merchant ledger. |

#### Error response

```http
HTTP/1.1 403 Forbidden
{"error":{"code":403,"message":"authentication failed"}}
```

---

### 8.2 `POST /payment-flex/create` — Create Payment Order FLEX (Hosted Payment Page) ⭐ RECOMMENDED

> ✅ **This is the recommended way to create deposit orders** — one API call returns a hosted `payment_url` that handles the entire customer flow.

**Why FLEX:**

- **One API, every channel.** FLEX automatically routes each order through **all payment channels available on the platform** and picks the best one for that order — highest availability and success rate.
- The customer receives the transfer instruction as a **QR code or a bank account number**, whichever channel the system selects.
- The customer completes the payment **on the hosted payment page only** (`payment_url`). The page handles the entire customer flow by itself: how-to instructions, the transfer details, slip submission, countdown timers, real-time status, and the redirect back to the merchant site.
- The merchant builds **nothing**: no QR rendering, no bank-account display, no slip-upload UI. Just open `payment_url` for the customer.
- The page look is customizable via `payment_theme` (default theme: `halo`).

> ⚠️ The customer must pay through the hosted payment page. Do **not** scrape the QR/account details out of the page into your own UI — the page flow (instructions → transfer → slip submission) is part of how the order gets confirmed.

**Method:** `POST` · **URL:** `/payment-flex/create`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "merchant_order_id": "ORDER0123456789789445566",
  "amount":            "1000.00",
  "bank":              "KBANK",
  "account_name":      "สมชาย ใสสว่าง",
  "account_no":        "1234567890",
  "notify_url":        "https://merchant.com/callback/payment",
  "redirect_url":      "https://merchant.com/return",
  "payment_theme":     "halo"
}
```

| Field | Required | Description |
|---|---|---|
| `merchant_order_id` | yes | Your unique order id. `[0-9a-zA-Z]`, max 40 chars. |
| `amount` | yes | THB. 2 decimal places. **Min 20**, **max 49,999** (or 500,000 for VIP partner ids 9 / 39). |
| `bank` | yes | Customer's source bank — see [Bank Codes](#6-bank-codes-thailand). |
| `account_name` | yes | Customer's account holder name (Thai or English). |
| `account_no` | yes | Customer's account number (10–14 digits). |
| `notify_url` | yes | HTTPS webhook for payment status. |
| `redirect_url` | no | URL the payment page sends the customer back to after a successful payment. |
| `payment_theme` | no | Payment-page theme id (see theme table below). Resolution: request value → client default → partner default → default theme (**`halo`**). Invalid/unknown values fall back to the default. |

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "THBP20260531...",
    "merchant_order_id": "ORDER0123456789789445566",
    "payment_method":    "TRANSFER",
    "payment_url":       "https://payment.example.com/THBP20260531.../<hash>"
  }
}
```

| Field | Description |
|---|---|
| `platform_order_id` | The gateway's order id (`THBP…`). Persist for reconciliation. |
| `merchant_order_id` | Your id, echoed back. |
| `payment_method` | Channel the system chose: `"TRANSFER"` or `"QR"`. Informational — the payment page presents whichever applies. |
| `payment_url` | The hosted payment page URL. **Open this for the customer** (redirect or new tab). |

#### Integration steps

1. `POST /payment-flex/create` → read `data.payment_url`.
2. Send the customer to `payment_url`.
3. The page guides the customer through paying (instructions, QR or account number, slip if required, live status).
4. You receive the standard payment webhook (§9.1) on `notify_url`; the page redirects the customer to `redirect_url` (if provided).

#### Errors

- `403 authentication failed`
- `409` — **pending order guard**: only one open FLEX order is allowed per customer account at a time. Body: `{"success": false, "code": 409, "error": "you have a pending payment order. complete or cancel it first.", "data": {"pending_order_id": "THBP..."}}`. Re-use/await that order, let the customer cancel it on the payment page, or cancel it from your backend via [`POST /payment/cancel`](#88-post-paymentcancel--cancel-payment-order), before creating a new one.
- `429 Rate limit exceeded…` — max 5 creation requests per minute per `(bank, account_no)`.
- `500` variants: `merchant order id not found`, `amount must be greater than 20`, `amount must be less than 49999`, `invalid account no`, `invalid bank`, `invalid account name`, `please provide customer info.`, `Try another amount or wait 6 minutes.` (duplicate-amount guard), `concurrent payment channel exceeded limitation…`, `service error. no available channel.`

#### Payment-page themes

`payment_theme` accepts these ids:

| id | Name | Note |
|---|---|---|
| `halo` | Halo | **Default** — used when `payment_theme` is omitted and no account default is set. `"default"` also renders Halo. |
| `pristine` | Pristine | |
| `blue` | Blue | |
| `vault` | Vault | |
| `sunset` | Sunset | |
| `obsidian` | Obsidian | |
| `stack` | Stack | |
| `sienna` | Sienna | |
| `mint` | Mint | |
| `pulse` | Pulse | |

A per-merchant default theme can also be configured (client portal or via support); the per-request value always wins.

#### Order status note

FLEX orders can additionally reach status **`cancelled`** (customer pressed *Cancel* on the payment page before transferring). A cancel now fires a `CANCELLED` payment callback. A cancel is **not always final** — in some cases the order may still be paid afterwards, in which case a subsequent `PAID` callback follows for the same `platform_order_id` (treat `PAID` as authoritative); otherwise create a new order when the customer retries (the 409 pending guard is released on cancel). Your backend can trigger a cancel itself via [`POST /payment/cancel`](#88-post-paymentcancel--cancel-payment-order) (§8.8).

---

### 8.3 `POST /payment/query` — Query Payment Order

Pull the live status of a payment order — used as a fallback when a webhook didn't arrive.

**Method:** `POST` · **URL:** `/payment/query`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "platform_order_id": "THBP20240401071230HW2xxxxx"
}
```

| Field | Required | Description |
|---|---|---|
| `platform_order_id` | yes | The id returned from the create call. |

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "THBP2024042821130xxxxxxxxx",
    "merchant_order_id": "ORDER0123456789789445566",
    "order_datetime":    "2024-04-28 21:13:09",
    "amount":            "1000.0000",
    "status":            "open",
    "expire_datetime":   "2024-04-28 21:23:09",
    "payment_datetime":  null
  }
}
```

| Field | Description |
|---|---|
| `status` | One of `open` (waiting), `settled_paid` (paid & credited), `error` (failed/expired), `cancelled` (customer cancelled on the payment page — FLEX), `freeze` (admin hold). |
| `payment_datetime` | Set when `status` becomes `settled_paid`. `null` while pending. |

#### Errors

`403 authentication failed`, `500 platform_order_id not found`.

---

### 8.4 `POST /withdraw/create` — Create Withdraw Order

Initiate a transfer **from your balance to an end user's bank account**. The end user receives the money. Status moves asynchronously; you receive a webhook (§9.2).

**Method:** `POST` · **URL:** `/withdraw/create`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "merchant_order_id": "ORDER112233445566",
  "amount":            "1000.00",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "สมชาย นามสมมุติ",
  "notify_url":        "https://merchant.com/callback/withdraw"
}
```

| Field | Required | Description |
|---|---|---|
| `merchant_order_id` | yes | Your unique id. `[0-9a-zA-Z]`, max 40 chars. |
| `amount` | yes | THB. 2 decimal places. |
| `bank` | yes | Destination bank — see [Bank Codes](#6-bank-codes-thailand). |
| `account_no` | yes | Destination account number. |
| `account_name` | yes | Destination account holder name. |
| `notify_url` | yes | HTTPS webhook for the final status. |

#### Successful response (`200 OK`) — order accepted (status `pending`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    "merchant_order_id": "ORDER112233445566",
    "order_datetime":    "2024-04-28 21:49:39",
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "สมชาย นามสมมุติ",
    "amount":            "1000.00"
  }
}
```

The actual transfer happens asynchronously — final outcome (`SUCCESS` / `FAIL`) arrives via the webhook in §9.2.

#### Errors

`403 authentication failed`, `500 amount must be greater than 20`, `500 invalid bank/account_no/account_name`, `500 insufficient balance`.

---

### 8.5 `POST /withdraw/query` — Query Withdraw Order

Live status lookup for a withdraw order.

**Method:** `POST` · **URL:** `/withdraw/query`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "platform_order_id": "THBW20240428214939exxxxxxx"
}
```

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "THBW20240428214939eAEByOvF",
    "client_order_id":   "ORDER112233445566",
    "order_datetime":    "2024-04-28 21:49:39",
    "bank":              "KBANK",
    "account_no":        "1234567890",
    "account_name":      "สมชาย นามสมมุติ",
    "amount":            "100.0000",
    "status":            "open",
    "done_datetime":     null
  }
}
```

| Field | Description |
|---|---|
| `client_order_id` | Your `merchant_order_id`, echoed (note the field name change vs. the create response). |
| `status` | `open` (pending), `success`, or `failed`. |
| `done_datetime` | Set when the order moves out of `open`. |

#### Errors

`403 authentication failed`, `500 platform_order_id not found`.

---

### 8.6 `POST /slip/upload` — Manual Payment Slip Verification

Customer paid but you never got a callback. While the order is in `open` status you may upload the customer's bank slip image and the platform will reconcile — there is no waiting period.

Conditions:

- Order status must still be `open`.
- Image must be **JPEG or PNG**, **≤ 1 MB**, sent as **base64**.

**Method:** `POST` · **URL:** `/slip/upload`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "platform_order_id": "THBP20240401071230HW2xxxxx",
  "image_base64":      "/9j/4AAQSkZJRgABAQAASABIAAD/4VeGRXhpZgAATU0AK....."
}
```

| Field | Required | Description |
|---|---|---|
| `platform_order_id` | yes | Order id to attach the slip to. |
| `image_base64` | yes | Bare base64 string (no `data:image/...,` prefix) of the JPEG/PNG slip. ≤ 1 MB. |

#### Successful response (`200 OK`)

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

| Field | Description |
|---|---|
| `msg` | Always `"slip upload successful."` |
| `verification_msg` | Pre-verification result derived from the slip's QR. Examples: `amount ok, destination ok, transfer in time.`, `amount mismatch`, etc. |

#### Errors

`403 authentication failed`, `500 platform_order_id not found`, `500 status not open`, `500 order not in an uploadable state`, `500 invalid image / size > 1MB`.

---

### 8.7 `POST /verify-qr` — Verify QR Slip (auxiliary)

Independent of the merchant API token flow. This endpoint validates **any** PromptPay/Thai QR-slip string and returns the decoded transaction (sender, receiver, amount, timestamps). Useful for client-side pre-checks before calling `/slip/upload`.

**Auth:** `Authorization: Bearer <API-KEY>` (a 64-char API key issued separately from your merchant credentials). Not the HMAC scheme above.

**Method:** `POST` · **URL:** `/verify-qr`

#### Request

```http
POST /verify-qr HTTP/1.1
Authorization: Bearer 5FA9D1E4...(64 chars)...F2C3
Content-Type: application/json

{ "qrString": "00020101021230530016A0000006770101110113006689750856020000053037645405499.955802TH..." }
```

| Field | Required | Description |
|---|---|---|
| `qrString` | yes | Raw EMV/Thai-QR payload as it appears in the slip (not URL-encoded). |

#### Successful response (`200 OK`, `code: 100`)

```jsonc
{
  "msg":  "สำเร็จ",
  "code": 100,
  "data": {
    "rqUID":         "154_20250514_i2Ikko47dmHICz",
    "kbankTxnId":    "rrt-3577008363863950059-b-gse1-3012708-40424034-1",
    "statusCode":    "0000",
    "statusMessage": "SUCCESS",
    "data": {
      "language":         "TH",
      "transRef":         "015134200548APP00935",
      "sendingBank":      "004",
      "receivingBank":    "",
      "transDate":        "20250514",
      "transTime":        "20:05:48",
      "sender": {
        "displayName": "นาง ลำพึง เ",
        "name":        "MRS. LAMPUNG K",
        "proxy":   { "type": null,     "value": null },
        "account": { "type": "BANKAC", "value": "xxx-x-x1030-x" }
      },
      "receiver": {
        "displayName": "นาย ณัฐพล เ",
        "name":        "MR. NATTHAPHON C",
        "proxy":   { "type": "MSISDN", "value": "xxx-xxx-6298" },
        "account": { "type": "",       "value": "" }
      },
      "amount":            499.95,
      "paidLocalAmount":   499.95,
      "paidLocalCurrency": "764",
      "countryCode":       "TH",
      "transFeeAmount":    0,
      "ref1":              "",
      "ref2":              "",
      "ref3":              "",
      "toMerchantId":      ""
    }
  },
  "status": true
}
```

#### Errors

| HTTP | `code` | Scenario |
|---|---|---|
| `200` | `500` | Upstream system returned an error (e.g. `ไม่พบข้อมูล`). |
| `400` | – | Malformed JSON / missing `qrString`. |
| `401` | – | Missing / wrong / inactive API key. |
| `502` | – | Network timeout or unreadable upstream. |

---

### 8.8 `POST /payment/cancel` — Cancel Payment Order

Cancel an **open** payment order from your backend (server-to-server) using your merchant credentials. The primary use is clearing the FLEX pending-order `409` (see §8.2): call this with the `pending_order_id`, then retry the create. (The customer can also cancel on the hosted payment page; this is the backend equivalent.)

**Method:** `POST` · **URL:** `/payment/cancel`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "platform_order_id": "THBP20260613123300zBuseUwN"
}
```

| Field | Required | Description |
|---|---|---|
| `platform_order_id` | yes | The order to cancel. Use `data.pending_order_id` from the FLEX `409`, or the `platform_order_id` from create. |

#### When a cancel is allowed

All must hold: the order is **yours**, status is **`open`**, and **no slip** has been uploaded. On success the system releases the **pending-order guard** — so you can immediately create a new order for that customer account — and sends a **`CANCELLED`** payment callback (§9.1).

> ⚠️ A cancel is **not always final**: in some cases the order may still be paid afterwards, in which case a subsequent **`PAID`** callback follows for the same `platform_order_id` (treat `PAID` as authoritative).

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "THBP20260613123300zBuseUwN",
    "merchant_order_id": "ORDER0123456789789445566",
    "status":            "cancelled"
  }
}
```

Idempotent: cancelling an already-cancelled order also returns `200` (with `"already_cancelled": true`).

#### Errors

| HTTP | `code` | Scenario |
|---|---|---|
| `200` | `403` | Auth failed (merchant id / token / signature). |
| `200` | `500` | Order not found / not owned by you, or invalid `platform_order_id`. |
| `409` | `409` | Order exists but not cancellable — not `open`, or a slip was already uploaded. Body: `{"success": false, "code": 409, "error": "order not open"}`. |

---

### 8.9 `POST /payment/cancel-by-merchant-order-id` — Cancel Payment Order (by Merchant Order ID)

Same as §8.8, but keyed by **your own** `merchant_order_id` (the id you sent to `/payment-flex/create`) instead of the gateway's `platform_order_id`. The platform resolves it internally, so you do **not** need to have stored the returned `platform_order_id`. Useful for clearing the FLEX pending-order `409` (§8.2) when you only kept your own order id.

**Method:** `POST` · **URL:** `/payment/cancel-by-merchant-order-id`

#### Request body

```json
{
  "merchant_id":       "AA12345678",
  "token":             "testtokentesttokentesttokentesttokentesttoken",
  "time":              1656272222,
  "merchant_order_id": "ORDER0123456789789445566"
}
```

| Field | Required | Description |
|---|---|---|
| `merchant_order_id` | yes | Your own order id, as sent to `/payment-flex/create`. Must have been created within the last **24 hours** (the internal resolution window). |

#### When a cancel is allowed

All must hold: the order is **yours**, it was created **within the last 24 hours**, status is **`open`**, and **no slip** has been uploaded. On success the system releases the **pending-order guard** — so you can immediately create a new order for that customer account — and sends a **`CANCELLED`** payment callback (§9.1).

> ⚠️ A cancel is **not always final**: in some cases the order may still be paid afterwards, in which case a subsequent **`PAID`** callback follows for the same `platform_order_id` (treat `PAID` as authoritative).

#### Successful response (`200 OK`)

```json
{
  "success": 200,
  "data": {
    "platform_order_id": "THBP20260613123300zBuseUwN",
    "merchant_order_id": "ORDER0123456789789445566",
    "status":            "cancelled"
  }
}
```

Idempotent: cancelling an already-cancelled order also returns `200` (with `"already_cancelled": true`).

#### Errors

| HTTP | `code` | Scenario |
|---|---|---|
| `200` | `403` | Auth failed (merchant id / token / signature). |
| `200` | `500` | Order not found — `merchant_order_id` not created via FLEX, older than 24 h, or not owned by you. Body: `{"error": {"code": 500, "message": "merchant order id not found"}}`. |
| `409` | `409` | Order exists but not cancellable — not `open`, a slip was already uploaded, or past the cancellable window. Body: `{"success": false, "code": 409, "error": "cannot cancel order"}`. |

---

## 9. Webhooks (Callbacks)

Webhooks are how the payment gateway tells you the **final outcome** of a payment or withdraw. They are sent to the `notify_url` you supplied at create time. They are signed with the same `secret_key` and the same scheme as outgoing requests (HMAC-SHA256 over the **raw body** → `X-Signature` header).

### 9.1 Payment Callback

Sent when a payment order moves from `open` → `settled_paid` (or fails). Identical for **FLEX**, QR and TRANSFER orders (TRANSFER/FLEX-transfer callbacks also carry a `payment_method` field). When a customer cancels a FLEX order it produces a `CANCELLED` callback instead of `PAID`; in some cases a `PAID` callback may still follow later if the order is ultimately paid (treat `PAID` as authoritative).

```http
POST /your-callback-path HTTP/1.1
Content-Type: application/json
Connection: Close
X-Signature: b6f9dd313cde39ae1b87e63b9b457029bcea6e9520b5db5de20d3284e4c0259e

{
  "merchant_id":       "AA12345678",
  "platform_order_id": "THBP2024042821130xxxxxxxxx",
  "client_order_id":   "ORDER0123456789789445566",
  "mode":              "PAYMENT",
  "amount":            "1000.00",
  "status":            "PAID",
  "timestamp":         112233445566
}
```

| Field | Description |
|---|---|
| `merchant_id` | Echoed back. |
| `platform_order_id` | The gateway's id (`THBP…`). |
| `client_order_id` | Your `merchant_order_id`. |
| `mode` | Always `"PAYMENT"`. |
| `amount` | The original requested amount. |
| `status` | `"PAID"` on success, `"FAIL"` on failure/expiry. |
| `timestamp` | Unix seconds when the callback was generated. |
| `payment_method` (TRANSFER only) | `"TRANSFER"` for the bank-deposit flow. |

**Required response:** HTTP `200`. Anything else triggers a retry (with backoff).

### 9.2 Withdraw Callback

Sent when a withdraw moves from `pending` → `success` / `failed`.

```http
POST /your-callback-path HTTP/1.1
Content-Type: application/json
Connection: Close
X-Signature: 6cd4a32478f5ec8e69343988dc9137e37d3eb02123adc3248ec4ea0aeca2e922

{
  "merchant_id":       "AA12345678",
  "platform_order_id": "THBW2024042821130xxxxxxxxx",
  "client_order_id":   "ORDER0123456789789445566",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "สมชาย นามสมมุติ",
  "amount":            "1000.00",
  "status":            "SUCCESS",
  "timestamp":         112233445566
}
```

| Field | Description |
|---|---|
| `mode` | Always `"WITHDRAW"`. |
| `bank` / `account_no` / `account_name` | Destination details (echoed). |
| `status` | `"SUCCESS"` or `"FAIL"`. On `"FAIL"` you must restore the customer's available balance. |
| `timestamp` | Unix seconds when the callback was generated. |

**Required response:** HTTP `200`. Otherwise the payment gateway retries.

### 9.3 Callback Verification (Critical)

Always verify `X-Signature` against the **raw HTTP body**, never against re-encoded JSON. Re-encoding changes byte ordering / whitespace / unicode escapes and your signature comparison will fail intermittently.

#### Reference handler — Node.js + Express

```js
const express = require('express');
const crypto  = require('crypto');

const app    = express();
const SECRET = 'aaaaaaaaaaaaaaaaaaaa';

// Capture the raw body BEFORE JSON parsing.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString(); }
}));

app.post('/callback', (req, res) => {
  const received = req.headers['x-signature'];
  const expected = crypto.createHmac('sha256', SECRET)
                         .update(req.rawBody)         // raw body, NOT JSON.stringify(req.body)
                         .digest('hex');
  if (!received || expected !== received) {
    return res.status(403).json({ error: 'Invalid signature' });
  }

  // Idempotency: process each (merchant_id, platform_order_id) at most once.
  // Use a unique index in your DB or Redis SETNX.

  // Mark order paid / failed, credit user, etc...
  res.status(200).json({ success: true });
});

app.listen(3000);
```

#### Reference handler — PHP

```php
<?php
$secret = 'aaaaaaaaaaaaaaaaaaaa';

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

if (!hash_equals($expected, $received)) {
    http_response_code(403);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$data = json_decode($rawBody, true);
// idempotency: ignore if already processed
// credit user / mark paid / etc.
http_response_code(200);
echo json_encode(['success' => true]);
```

#### Why raw body?

```
Same data, different bytes ⇒ different HMAC
{"merchant_id":"TH00000000","amount":"1000.00","status":"PAID"}
{"amount":"1000.00","merchant_id":"TH00000000","status":"PAID"}   ← key order differs
{
  "merchant_id": "TH00000000",
  "amount": "1000.00",
  "status": "PAID"
}                                                                    ← whitespace differs
```

All three are JSON-equal, all three produce **different** HMACs.

#### Idempotency rule

Webhooks **may be delivered more than once**. Always check `(merchant_id, platform_order_id)` (or your `client_order_id`) against your store before crediting the user / mutating the order. A unique index + insert-or-ignore is the simplest pattern.

---

## 10. End-to-end Flows

### 10.1 Deposit Flow — FLEX (Recommended)

```
Customer          Merchant Website          Merchant Backend          Payment API           Payment Page
   |                     |                          |                         |                    |
   |--[Enter Amount]---->|                          |                         |                    |
   |                     |----[Create Deposit]----->|                         |                    |
   |                     |                          |--[POST /payment-flex]-->|                    |
   |                     |                          |<--[payment_url]---------|                    |
   |                     |<---[payment_url]---------|                         |                    |
   |--[Open payment_url]-|--------------------------|-------------------------|------------------->|
   |<--[Instructions + QR or bank account + countdown]------------------------|--------------------|
   |--[Transfer & submit slip on the page]------------------------------------|------------------->|
   |                     |                          |                    [Verify Payment]          |
   |                     |                          |<--[Webhook Callback]---|                    |
   |                     |                    [Credit Account]                |                    |
   |                     |                          |---[HTTP 200 OK]-------->|                    |
   |<--[Redirect to redirect_url]------------------------------------------------------------------|
```

1. **Customer initiates** a deposit on the merchant site.
2. **Merchant backend** signs and `POST /payment-flex/create`.
3. **The payment gateway** picks the best available channel and returns `payment_url`.
4. **Merchant opens** `payment_url` for the customer — nothing else to render.
5. **The hosted payment page** walks the customer through everything: instructions, the QR code or destination bank account, exact `transfer_amount`, countdown, slip submission, live status.
6. **Webhook** (`mode: PAYMENT`, `status: PAID`) arrives at `notify_url`.
7. **Merchant credits** the user (idempotent), returns HTTP `200`.
8. The page redirects the customer back to `redirect_url` (if provided).

### 10.2 Withdraw Flow (8 steps)

```
Customer          Merchant Website          Merchant Backend          Payment API
   |--[Request]--------->|                          |                         |
   |                     |---[Validate]------------>|                         |
   |                     |                    [Deduct user balance]           |
   |                     |                          |---[POST /withdraw]----->|
   |                     |                          |<---[Pending response]--|
   |                     |<--[Show Pending]---------|                         |
   |<--[Pending status]--|                          |                         |
   |        ...wait...                             |                    [Process / Bank Transfer]
   |                     |                          |<---[Webhook callback]---|
   |                     |   [Mark final status; refund balance if FAIL]      |
   |                     |                          |---[HTTP 200 OK]-------->|
   |<--[Final status]----|<--[Notify customer]------|                         |
```

1. **Customer** requests withdrawal.
2. **Merchant backend** validates limits/balance/KYC.
3. **Merchant backend** **deducts the user's available balance immediately** (move to "pending withdrawal") to prevent double-spend.
4. **`POST /withdraw/create`** with full destination bank details.
5. **The payment gateway** accepts (status `pending`) → response `200`.
6. **The payment gateway processes** (queue → fraud checks → bank transfer).
7. **Webhook** arrives with `status: SUCCESS` or `status: FAIL`.
8. **Merchant**: on `SUCCESS` mark complete; on `FAIL` **return the amount to the user's available balance** and notify the user.

If no webhook for ~30–60 min: poll with `/withdraw/query` (with backoff), or escalate to support.

---

## 11. State Machines

### Payment (`/payment/query.status`)

```
              ┌────────────────────────────────────────┐
              ▼                                        │
   open ─────► settled_paid (terminal, paid)           │
     │                                                 │
     ├──────► error          (terminal, failed/expired)│
     ├──────► freeze         (admin hold, can resume) ─┘
     └──────► unsettled_paid (paid, awaiting reconciliation)
```

| Value | Meaning |
|---|---|
| `open` | Created, awaiting payment. |
| `settled_paid` | Customer paid; merchant credited. **Terminal.** |
| `unsettled_paid` | Paid but not yet reconciled (manual / slip flows). |
| `error` | Failed or expired. **Terminal.** |
| `cancelled` | Customer cancelled from the hosted payment page (FLEX orders, before transferring). Fires a `CANCELLED` callback. In some cases the payment may still be completed later (then a `PAID` callback follows, which is authoritative); otherwise create a new order to retry. |
| `freeze` | Admin lock (e.g. fraud review). |

### Withdraw (`/withdraw/query.status`)

```
   open ────► success  (terminal, money landed in user account)
     │
     └─────► failed   (terminal — restore user balance)
```

(Some create responses use `pending`; the query endpoint normalizes to `open`.)

---

## 12. Code Examples (PHP, Node, Python, Java, C#, Go)

All examples assume base URL `https://api.example.com` and the test credentials at the top of §3. Replace before going live.

> ⭐ **Use FLEX for deposits.** Every example below calls `/payment-flex/create` and receives a hosted `payment_url` in `data.payment_url` — open it for the customer (e.g. redirect) and the hosted page handles the entire flow. Add `redirect_url` and `payment_theme` if you want a custom return URL / theme.

### 12.1 PHP — generic helper + balance + payment-flex

```php
<?php
define('API_URL',     'https://api.example.com');
define('SECRET',      'secretkeysecretkeysecretkeysecretkeysecretkeysecretkey');
define('MERCHANT_ID', 'AA12345678');
define('TOKEN',       'testtokentesttokentesttokentesttokentesttoken');

function ww_call(string $path, array $data) {
    $body      = json_encode($data, JSON_UNESCAPED_UNICODE);
    $signature = hash_hmac('sha256', $body, SECRET);
    $ch = curl_init(API_URL . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Signature: ' . $signature,
        ],
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$code, json_decode($res, true)];
}

// Get balance
[$code, $bal] = ww_call('/balance', [
    'merchant_id' => MERCHANT_ID,
    'token'       => TOKEN,
    'time'        => time(),
]);
print_r($bal);

// Create deposit (FLEX — recommended): returns a hosted payment_url
[$code, $payment] = ww_call('/payment-flex/create', [
    'merchant_id'       => MERCHANT_ID,
    'token'             => TOKEN,
    'time'              => time(),
    'merchant_order_id' => 'ORDER' . time(),
    'amount'            => '1000.00',
    'bank'              => 'KBANK',
    'account_name'      => 'สมชาย ใสสว่าง',
    'account_no'        => '1234567890',
    'notify_url'        => 'https://merchant.com/callback/payment',
    'redirect_url'      => 'https://merchant.com/return',   // optional
    'payment_theme'     => 'halo',                          // optional
]);
if ($code === 200) {
    // send the customer here — the hosted page handles the entire flow
    header('Location: ' . $payment['data']['payment_url']);
} elseif ($code === 409) {
    // customer already has an open order — reuse it
    $pendingId = $payment['data']['pending_order_id'];
}
```

### 12.2 Node.js (axios)

```js
const axios  = require('axios');
const crypto = require('crypto');

const API_URL     = 'https://api.example.com';
const SECRET      = 'secretkeysecretkeysecretkeysecretkeysecretkeysecretkey';
const MERCHANT_ID = 'AA12345678';
const TOKEN       = 'testtokentesttokentesttokentesttokentesttoken';

async function ww(path, payload) {
  const body      = JSON.stringify(payload);                              // raw body
  const signature = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
  const res = await axios.post(API_URL + path, body, {
    headers: { 'Content-Type': 'application/json', 'X-Signature': signature },
    transformRequest: [d => d],                                            // disable axios re-serialization
  });
  return res.data;
}

(async () => {
  console.log(await ww('/balance', { merchant_id: MERCHANT_ID, token: TOKEN, time: Math.floor(Date.now()/1000) }));

  console.log(await ww('/payment-flex/create', {
    merchant_id: MERCHANT_ID,
    token:       TOKEN,
    time:        Math.floor(Date.now()/1000),
    merchant_order_id: 'ORDER' + Date.now(),
    amount:       '1000.00',
    bank:         'KBANK',
    account_name: 'สมชาย ใสสว่าง',
    account_no:   '1234567890',
    notify_url:   'https://merchant.com/callback/payment',
  }));
})();
```

### 12.3 Python (requests)

```python
import requests, json, hmac, hashlib, time

API_URL     = 'https://api.example.com'
SECRET      = 'secretkeysecretkeysecretkeysecretkeysecretkeysecretkey'
MERCHANT_ID = 'AA12345678'
TOKEN       = 'testtokentesttokentesttokentesttokentesttoken'

def ww(path, payload):
    body      = json.dumps(payload, ensure_ascii=False, separators=(',', ':'))
    signature = hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
    return requests.post(
        API_URL + path,
        data=body.encode('utf-8'),
        headers={'Content-Type': 'application/json', 'X-Signature': signature},
    ).json()

print(ww('/balance', {'merchant_id': MERCHANT_ID, 'token': TOKEN, 'time': int(time.time())}))

print(ww('/payment-flex/create', {
    'merchant_id': MERCHANT_ID, 'token': TOKEN, 'time': int(time.time()),
    'merchant_order_id': f'ORDER{int(time.time())}',
    'amount': '1000.00', 'bank': 'KBANK',
    'account_name': 'สมชาย ใสสว่าง', 'account_no': '1234567890',
    'notify_url': 'https://merchant.com/callback/payment',
}))
```

### 12.4 Java (java.net.http)

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.time.Instant;

public class WwClient {
    private static final String API_URL     = "https://api.example.com";
    private static final String SECRET      = "secretkeysecretkeysecretkeysecretkeysecretkeysecretkey";
    private static final String MERCHANT_ID = "AA12345678";
    private static final String TOKEN       = "testtokentesttokentesttokentesttokentesttoken";

    public static void main(String[] args) throws Exception {
        long now = Instant.now().getEpochSecond();
        String body = String.format(
            "{\"merchant_id\":\"%s\",\"token\":\"%s\",\"time\":%d}",
            MERCHANT_ID, TOKEN, now);
        System.out.println(call("/balance", body));

        String paymentBody = String.format(
            "{\"merchant_id\":\"%s\",\"token\":\"%s\",\"time\":%d," +
            "\"merchant_order_id\":\"ORDER%d\",\"amount\":\"1000.00\"," +
            "\"bank\":\"KBANK\",\"account_name\":\"สมชาย ใสสว่าง\"," +
            "\"account_no\":\"1234567890\"," +
            "\"notify_url\":\"https://merchant.com/callback/payment\"}",
            MERCHANT_ID, TOKEN, now, now);
        System.out.println(call("/payment-flex/create", paymentBody));
    }

    static String call(String path, String body) throws Exception {
        String sig = sign(body);
        HttpResponse<String> r = HttpClient.newHttpClient().send(
            HttpRequest.newBuilder()
                .uri(URI.create(API_URL + path))
                .header("Content-Type", "application/json")
                .header("X-Signature", sig)
                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
                .build(),
            HttpResponse.BodyHandlers.ofString());
        return r.body();
    }

    static String sign(String data) throws Exception {
        Mac h = Mac.getInstance("HmacSHA256");
        h.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] x = h.doFinal(data.getBytes(StandardCharsets.UTF_8));
        StringBuilder s = new StringBuilder(2 * x.length);
        for (byte b : x) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) s.append('0'); s.append(hex); }
        return s.toString();
    }
}
```

### 12.5 C# (HttpClient)

```csharp
using System;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;

class WwClient {
    const string API_URL     = "https://api.example.com";
    const string SECRET      = "secretkeysecretkeysecretkeysecretkeysecretkeysecretkey";
    const string MERCHANT_ID = "AA12345678";
    const string TOKEN       = "testtokentesttokentesttokentesttokentesttoken";

    static async System.Threading.Tasks.Task Main() {
        long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        Console.WriteLine(await Send("/balance", new {
            merchant_id = MERCHANT_ID, token = TOKEN, time = now }));

        Console.WriteLine(await Send("/payment-flex/create", new {
            merchant_id = MERCHANT_ID, token = TOKEN, time = now,
            merchant_order_id = $"ORDER{now}", amount = "1000.00",
            bank = "KBANK", account_name = "สมชาย ใสสว่าง", account_no = "1234567890",
            notify_url = "https://merchant.com/callback/payment" }));
    }

    static async System.Threading.Tasks.Task<string> Send(string path, object data) {
        string body = JsonConvert.SerializeObject(data);
        string sig  = Hmac(body);
        using var c = new HttpClient();
        var req = new HttpRequestMessage(HttpMethod.Post, API_URL + path) {
            Content = new StringContent(body, Encoding.UTF8, "application/json")
        };
        req.Headers.Add("X-Signature", sig);
        var r = await c.SendAsync(req);
        return await r.Content.ReadAsStringAsync();
    }

    static string Hmac(string body) {
        using var h = new HMACSHA256(Encoding.UTF8.GetBytes(SECRET));
        return BitConverter.ToString(h.ComputeHash(Encoding.UTF8.GetBytes(body))).Replace("-", "").ToLower();
    }
}
```

### 12.6 Go (net/http)

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

const (
    APIURL     = "https://api.example.com"
    Secret     = "secretkeysecretkeysecretkeysecretkeysecretkeysecretkey"
    MerchantID = "AA12345678"
    Token      = "testtokentesttokentesttokentesttokentesttoken"
)

func ww(path string, data map[string]interface{}) (string, error) {
    body, _ := json.Marshal(data)
    h := hmac.New(sha256.New, []byte(Secret))
    h.Write(body)
    sig := hex.EncodeToString(h.Sum(nil))

    req, _ := http.NewRequest("POST", APIURL+path, bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Signature", sig)

    res, err := http.DefaultClient.Do(req)
    if err != nil { return "", err }
    defer res.Body.Close()
    b, _ := io.ReadAll(res.Body)
    return string(b), nil
}

func main() {
    now := time.Now().Unix()
    s, _ := ww("/balance", map[string]interface{}{
        "merchant_id": MerchantID, "token": Token, "time": now,
    })
    fmt.Println(s)
    s, _ = ww("/payment-flex/create", map[string]interface{}{
        "merchant_id": MerchantID, "token": Token, "time": now,
        "merchant_order_id": fmt.Sprintf("ORDER%d", now),
        "amount": "1000.00", "bank": "KBANK",
        "account_name": "สมชาย ใสสว่าง", "account_no": "1234567890",
        "notify_url": "https://merchant.com/callback/payment",
    })
    fmt.Println(s)
}
```

---

## 13. Postman / pre-request scripts

Add this to a Postman collection or request **Pre-request Script** to auto-sign:

```js
var secret_key = 'YOUR_SECRET_KEY_HERE';
var signBytes  = CryptoJS.HmacSHA256(pm.request.body.raw, secret_key);
var signHex    = CryptoJS.enc.Hex.stringify(signBytes);
pm.request.headers.upsert({ key: 'X-Signature', value: signHex });
```

Then in Postman use **Body → raw → JSON** with all your fields and Postman will sign before each send.

---

## 14. Operational Limits & Gotchas

- **Use FLEX for deposits.** `/payment-flex/create` is the recommended endpoint — one call returns a hosted `payment_url`.
- **FLEX pending-order guard:** only one open FLEX order per customer account at a time. A second create returns `409` with `data.pending_order_id` — reuse that order, or cancel it via `/payment/cancel` (§8.8) or on the payment page, instead of retry-looping.
- **FLEX `payment_url`:** send the customer to the hosted page; do not extract the QR/account info into your own UI.
- **Amount ranges (payment):** min `20.00` THB, max `49,999.00` THB (or `500,000.00` for VIP partner ids 9 / 39).
- **Per `(bank, account_no)` rate limit:** 5 payment-creation requests per minute (HTTP `429`).
- **Global concurrency cap:** ~200 simultaneous in-flight payment orders → `concurrent payment channel exceeded limitation`.
- **QR expiry:** typically 10 minutes (`expire_datetime`). After expiry, customer must restart.
- **Decimal nudge:** the customer must transfer **exactly** `transfer_amount`, not `amount`. Misuse can credit a different merchant's order — irreversible.
- **Source account match:** payment must come from the same `bank` + `account_no` you submitted.
- **`qrcode` text field is being deprecated** (was 14 Sep 2025). Use `qrbase64`.
- **Webhook retries** continue until your endpoint returns `HTTP 200`. Implement idempotency on `(merchant_id, platform_order_id)`.
- **Time skew:** the `time` field is checked against server time; large skew may be rejected. Use NTP.
- **Charset:** request and response are UTF-8. Thai names in `account_name` must be UTF-8 encoded.
- **Slip upload:** allowed while order is `open` (no waiting period); image ≤ 1 MB; JPEG/PNG.
- **Withdraw timing:** typically 5–30 min during banking hours; outside banking hours processing waits for next business day.
- **TRANSFER deposit account is per-request:** never cache `deposit_account_no` — it can change between orders.

---

## 15. Best-practice Integration Checklist

- [ ] Use **`/payment-flex/create`** for all deposits; send the customer to `data.payment_url` and let the hosted page handle the rest.
- [ ] Handle the FLEX `409` pending-order response by reusing `data.pending_order_id` (or cancelling the stuck order via `/payment/cancel`) instead of retrying.
- [ ] Store the **raw request body** before signing; never re-encode after parse.
- [ ] Verify webhook `X-Signature` against the **raw incoming body**, then `hash_equals` to compare.
- [ ] Make webhook handlers **idempotent** on `(merchant_id, platform_order_id)`.
- [ ] **Never trust** the webhook source IP alone — always verify the signature.
- [ ] Persist `platform_order_id` from the create response immediately, before showing the QR to the user.
- [ ] Display **`transfer_amount`** to the user, not `amount`. Display **`expire_datetime`** as a countdown.
- [ ] On withdraw create, **deduct** the user's balance immediately; on withdraw `FAIL` callback, **restore** it.
- [ ] Implement a **fallback poll** to `/payment/query` and `/withdraw/query` for stale-pending orders (e.g., > 1h).
- [ ] Wrap all HTTP calls in a circuit breaker; surface `429` and `500 service error. no available channel.` distinctly.
- [ ] Log `platform_order_id`, `merchant_order_id`, and request body hash for every outgoing call (omit secrets).
- [ ] In production, set a sane network timeout (e.g. 10 s) on outgoing calls; 5–60 s on webhook handlers.
- [ ] HTTPS everywhere, including `notify_url`. Reject inbound webhooks over plain HTTP.
- [ ] Keep `secret_key` in your secret store (env var, vault). Rotate via your account manager.

---

## 16. Embedding the Payment Page via iframe

The hosted Payment Page (the `payment_url` returned by `/payment-flex/create`) can be embedded
in your store website with an `<iframe>`. Get the **"Copy"** buttons (account number / amount /
reference) working by following the rules below.

**Most common failure:** the store page that hosts the iframe is served over **HTTP**. The
browser then treats the whole iframe as a **non-secure context** and disables the Clipboard API,
so "Copy" stops working.

**Minimal correct embed:**

```html
<iframe
  src="{{payment_url}}"
  allow="clipboard-write"
  title="Payment"
  style="width:100%; max-width:480px; height:860px; border:0; border-radius:12px;">
</iframe>
```

Key rules:

1. **The embedding page must be HTTPS.** Per the Secure Contexts standard, an iframe is only
   "secure" when both it *and* its parent are HTTPS at every level. The Payment Page is already
   HTTPS; if the parent is HTTP, the whole iframe becomes non-secure and `navigator.clipboard`
   (and camera/geolocation) is disabled. (An HTTPS parent also cannot embed an HTTP iframe —
   blocked as mixed content — but that doesn't apply here since the page is HTTPS.)
2. **Add `allow="clipboard-write"`.** Writing to the clipboard from a cross-origin iframe is
   gated by Permissions Policy; the parent must grant it. Only `clipboard-write` is needed (no
   `clipboard-read`). A copy fallback exists on the Payment Page for when the Clipboard API is
   off, but steps 1–2 are the reliable fix.
3. **Sizing / auto-fit.** The page is mobile-first (~480px reference width). It detects when it
   is embedded and **auto-zooms content to fit the frame width** — no store-side config:
   - ≤ 480px → normal mobile, full width (no zoom)
   - 480–620px → zooms content up to fill the frame (larger text, no side margins)
   - 620–1024px → zoom capped at the 620px size, centered with small margins
   - ≥ 1024px → switches to the 2-column desktop layout
   When zoomed up, content also grows taller, so increase `height` if you widen the frame
   (≈ `height-at-480px × width ÷ 480`; e.g. 480px→~860px, 600px→~1075px). The page auto-fits
   **width** but does **not** auto-resize the iframe **height** — set a sufficient `height` or
   go full-screen on mobile.
4. **Avoid `sandbox`** unless required. If you must, include all of:
   `allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-top-navigation-by-user-activation`.
   `allow-same-origin` (WebSocket/clipboard/persistence) and `allow-scripts` are mandatory or the
   page won't work at all.
5. **"Return to store" button.** It navigates to `redirect_url` *within the iframe*, so pointing
   `redirect_url` at a normal store page loads it inside the small frame. Either (A — recommended)
   point `redirect_url` at a tiny break-out page that does
   `(window.top || window).location.replace(realDestination)`, or (B) make the destination page
   iframe-embeddable (no `X-Frame-Options: DENY` / blocking CSP `frame-ancestors`). Approach A
   requires `allow-top-navigation-by-user-activation` if `sandbox` is used.
6. **Most reliable alternative:** a full-page redirect to the Payment Page (then `redirect_url`
   back), or opening it in a new tab. Neither is subject to iframe secure-context / clipboard /
   third-party-cookie limits (e.g. Safari, bank 3DS, popups).

---

### Document metadata

- **API version:** 1.0
- **This file:** `llms-full.md` — single-file reference for AI coding agents.
- **Thai version:** [`llms-full-th.md`](./llms-full-th.md)
- **Generated:** 2026-06-06
