Endpoints
Every Payment API endpoint: creating a deposit, creating a withdrawal, reading the balance, fetching or cancelling a payment and uploading a slip.
Generated from public-api.mdVersion 1.2.0Last updated 2026-09-20
#Endpoint Summary
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/payments/deposit | Create a deposit (PromptPay QR) |
| POST | /v1/payments/withdraw | Create a payout to a bank account |
| GET | /v1/payments/get-balance | Current balance summary |
| GET | /v1/payments/{id} | Fetch one payment |
| POST | /v1/payments/{id}/cancel | Cancel a pending payment |
| POST | /v1/payments/{id}/upload-slip | Rescue an expired deposit with a payment slip |
| GET | /v1/merchant-reports/* | ERP reports — see merchant-reports-api.md |
| GET | /health, /health/detailed | Gateway liveness (no authentication) |
| GET | /sandbox/test, /sandbox/credentials, /sandbox/test-amounts | Sandbox helpers (sandbox host only, no authentication) |
There is no transaction list/search endpoint on the payment API and no refund
endpoint; see /v1/merchant-reports/transactions and
/v1/merchant-reports/refunds for read-only history.
#Create Deposit
Create a new deposit payment request. Returns a PromptPay QR code for the customer to pay.
POST /v1/payments/deposit → 201 Created
#Request Body
| Field | Type | Required | Validation / Description |
|---|---|---|---|
amount | number | Yes | > 0 at the gateway; see Amount limits below |
currency | string | Yes | Exactly 3 characters. Only THB is processed |
reference_id | string | Yes | Your order reference, 1–64 characters. Not deduplicated — see note |
bank_code | string | Yes | Payer's bank code, exactly 3 characters — see Bank Codes |
bank_account_no | string | Yes | Payer's bank account number, 10–20 characters |
bank_account_name | string | Yes | Payer's account holder name, 1–100 characters |
customer_email | string | No | Must parse as an email address |
customer_phone | string | No | Max 20 characters |
description | string | No | Max 255 characters |
webhook_url | string | No | Must parse as a URL. Webhooks are only sent when this is present |
Breaking change — 2026-05-13:
bank_code,bank_account_no, andbank_account_nameare required. Submit the depositor's (customer's) bank details so the system can bind the inbound bank notification to the right transaction. Requests missing any of these three fields are rejected withMISSING_PAYER_INFO(400). See Migration: payer info required.
reference_idis not an idempotency key. Re-sending the samereference_idcreates a second, independent transaction; there is no duplicate detection and no409on repeat. Do not retry a deposit on a timeout without first checking whether the first one landed.
#Amount limits
The gateway only checks amount > 0. The payment service then enforces:
- A hard floor of 50.00 THB for deposits, applied regardless of account configuration.
- Your merchant-configured pay-in minimum and maximum (defaults 1 and 1,000,000 THB; the floor above still wins on the low side).
A violation returns 400 INVALID_AMOUNT with the effective range in the
message, e.g. Amount must be between 50.00 and 1000000.00 THB.
#Example Request
{
"amount": 1000.00,
"currency": "THB",
"reference_id": "ORD888888888",
"bank_code": "004",
"bank_account_no": "1234567890",
"bank_account_name": "นาย ทดสอบ ตัวอย่าง",
"webhook_url": "https://example.com/path/to/your/webhook"
}#Error: missing payer info
{
"code": "MISSING_PAYER_INFO",
"message": "กรุณาส่งข้อมูลผู้ฝาก (bank_code, bank_account_no, bank_account_name) ครบทั้ง 3 ฟิลด์ — จำเป็นสำหรับการตรวจสอบรายการกับ SMS ของธนาคาร",
"details": {
"bank_code": "ต้องระบุ — รหัสธนาคารผู้ฝาก 3 หลัก เช่น 004 (KBANK), 014 (SCB), 030 (TTB)",
"bank_account_no": "ต้องระบุ — เลขบัญชีผู้ฝาก 10-20 หลัก (ผู้ที่จะโอนเงินเข้ามา)",
"bank_account_name": "ต้องระบุ — ชื่อบัญชีผู้ฝาก 1-100 ตัวอักษร"
}
}Validation details are keyed by the JSON field name and phrased in Thai. When
fields other than the payer trio fail, the top-level code is INVALID_REQUEST
instead.
#Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference_id": "ORD888888888",
"type": "deposit",
"status": "pending",
"amount": 1000.00,
"paid_amount": 1000.37,
"currency": "THB",
"qr_code": "00020101021129370016A000000677010111011300668123456785802TH5303764...",
"qr_code_image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...",
"payment_url": "https://pay.paymenow.asia/p/THMTrerxgEc08n-tgadZ5a8FeIoIuwDHFo8OqmGsO-4",
"created_at": "2026-09-20T17:30:00+07:00",
"expires_at": "2026-09-20T17:40:00+07:00"
}#Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique transaction ID (UUID) |
reference_id | string | Your order reference |
type | string | deposit |
status | string | pending on creation (sandbox may return a terminal status immediately) |
amount | number | The amount you requested |
paid_amount | number | The exact amount the customer must transfer — see below |
currency | string | Currency code |
qr_code | string | PromptPay QR payload string |
qr_code_image | string | data:image/png;base64,… QR image (production only; empty in sandbox) |
payment_url | string | Hosted payment page for this deposit — see below. "" when not configured |
created_at | string | Creation timestamp (RFC 3339, Asia/Bangkok) |
expires_at | string | Expiration timestamp (RFC 3339, Asia/Bangkok; 10 minutes by default) |
paid_amountis the amount that must actually be transferred. Deposits are matched against bank notifications on payer account + a unique satang amount, so the QR encodesamountplus a small random satang component. Showpaid_amount(or the QR) to your customer, neveramount, or the transfer may fail to match. In sandbox,paid_amountequalsamount.
#
payment_url— the hosted payment pageInstead of rendering the QR yourself, send the customer this link. The page is served by the platform, in Thai and English, and handles the parts that are easy to get wrong:
- It shows the right thing for the account. The platform rotates a receiving account onto each deposit. When that account has a PromptPay ID the page shows the QR; when it has none it shows the account number with a copy button. You do not have to branch on this — and
qr_codealone cannot express the second case.- It shows the exact amount, with a copy button that copies
1000.37, never the grouped1,000.37that a banking app rejects.- It says which account to transfer from. Deposits match on the payer's account and the unique satang, so the page repeats the bank and the last four digits of the account the customer declared. Money sent from a different account arrives but matches nothing.
- It offers the QR as a file. The link is usually opened on the same phone as the banking app, where scanning the screen is impossible; saving the image lets the app pick it from the gallery.
- It updates itself. The page polls and switches to a "payment received" screen without a refresh, and shows a countdown to
expires_at.- It lets the customer prove a transfer. While the QR is live, and again after it expires, the customer can upload their slip; the platform verifies amount, receiving account, payer bank and slip age (<24 h) and completes the deposit on the spot. This is the difference between a late transfer being credited and it becoming a support ticket.
Treat the link as a credential. Its only guard is a random token minted for that one deposit — 256 bits, never the transaction id, and it appears in no other field. Anyone holding the link sees the amount, the receiving account and the status, so send it to the customer over the same channel you would send an invoice, and do not put it in logs you share, analytics, or a page a search engine can reach.
What it does not expose: the merchant, the fee, or the payer's full account number (masked to the last four digits). The response there is deliberately narrower than this API's.
Appearance follows the merchant's own setting in the merchant dashboard (Settings › General › หน้าชำระเงินของลูกค้า): light, dark, or following the customer's device. Nothing to pass here.
The merchant can turn it off, in the merchant dashboard under Settings › General. That switch changes two things together, and they cannot be separated:
page on (default) page off payment_urlthe link ""receiving account used any account marked for deposits only accounts with a PromptPay ID qr_code/qr_code_imagereturned returned The account rule follows from the field. With the page on, a receiving account that has no PromptPay ID is still usable — the page shows its account number to transfer to instead of a QR. With the page off you render
qr_codeyourself, and such an account produces none, so routing a deposit there would hand your customer a blank screen; the platform therefore restricts rotation to PromptPay accounts while the page is off.What this means for your integration: if you render the QR yourself, expect
payment_urlto be""and ignore it. If you use the link, do not also requireqr_codeto be non-empty — with the page on, a deposit may legitimately have no QR at all, and the link is then the only way to pay it. Reading both and preferring whichever is present works in every case.
""is also returned when the deployment has no page configured at all.
#Create Withdraw
Create a new withdrawal/payout request to a bank account.
POST /v1/payments/withdraw → 201 Created
Withdrawals are funded from your available balance. The balance (amount + fee) is reserved up front; an insufficient balance rejects the request outright.
#Request Body
| Field | Type | Required | Validation / Description |
|---|---|---|---|
amount | number | Yes | > 0 at the gateway; hard floor 100.00 THB, plus your configured payout min/max |
currency | string | Yes | Exactly 3 characters |
reference_id | string | Yes | Your reference, 1–64 characters. Not deduplicated |
bank_code | string | Yes | Exactly 3 characters — see Bank Codes |
account_number | string | Yes | Destination account number, 10–20 characters |
account_holder_name | string | Yes | Destination account holder, 1–100 characters |
webhook_url | string | No | Must parse as a URL. Webhooks are only sent when present |
There is no description field on withdraw — it is accepted by the JSON decoder
but ignored.
Duplicate-payout hold. If an identical payout (same merchant, bank code, account number and amount) was created within the last 5 minutes, the new withdrawal is not dispatched. It is created, the balance is reserved, and it waits in
pendingfor manual review. This guards against an upstream retry with a freshreference_idcausing a double payout. PollGET /v1/payments/{id}or wait for the webhook rather than retrying.
#Bank Codes
These are the codes recognised when resolving the human-readable bankName in
webhooks. Submit the 3-digit BOT code.
| Code | Thai Name | English Name |
|---|---|---|
002 | ธนาคารกรุงเทพ | Bangkok Bank PCL (BBL) |
004 | ธนาคารกสิกรไทย | Kasikornbank PCL (KBank) |
006 | ธนาคารกรุงไทย | Krungthai Bank PCL (KTB) |
011 | ธนาคารทหารไทยธนชาต | TMBThanachart Bank PCL (TTB) |
014 | ธนาคารไทยพาณิชย์ | Siam Commercial Bank PCL (SCB) |
017 | ธนาคารซิตี้แบงก์ | Citibank N.A. |
020 | ธนาคารสแตนดาร์ดชาร์เตอร์ด (ไทย) | Standard Chartered Bank (Thai) PCL |
022 | ธนาคารซีไอเอ็มบี ไทย | CIMB Thai Bank PCL |
024 | ธนาคารยูโอบี | United Overseas Bank (Thai) PCL |
025 | ธนาคารกรุงศรีอยุธยา | Bank of Ayudhya PCL (Krungsri) |
026 | ธนาคารเมกะสากลพาณิชย์ | Mega International Commercial Bank |
030 | ธนาคารออมสิน | Government Savings Bank (GSB) |
031 | ธนาคารฮ่องกงและเซี่ยงไฮ้ | HSBC Thailand |
032 | ธนาคารดอยซ์แบงก์ | Deutsche Bank |
033 | ธนาคารอาคารสงเคราะห์ | Government Housing Bank (GHB) |
034 | ธนาคารเพื่อการเกษตรฯ | BAAC |
039 | ธนาคารมิซูโฮ | Mizuho Bank |
045 | ธนาคารบีเอ็นพี พารีบาส์ | BNP Paribas |
052 | ธนาคารแห่งประเทศจีน | Bank of China (Thai) |
065 | ธนาคารธนชาต | Thanachart Bank PCL |
066 | ธนาคารอิสลามแห่งประเทศไทย | Islamic Bank of Thailand |
067 | ธนาคารทิสโก้ | TISCO Bank PCL |
069 | ธนาคารเกียรตินาคินภัทร | Kiatnakin Phatra Bank PCL (KKP) |
070 | ธนาคารไอซีบีซี (ไทย) | ICBC (Thai) PCL |
071 | ธนาคารไทยเครดิต | Thai Credit Retail Bank |
073 | ธนาคารแลนด์ แอนด์ เฮ้าส์ | Land and Houses Bank PCL (LH Bank) |
A code outside this table is accepted as long as it is exactly 3 characters; the
webhook bankName then echoes the code itself.
#PromptPay withdraw — not available on this API
bank_code is validated as exactly 3 characters, so the literal value
PROMPTPAY is rejected with 400 INVALID_REQUEST. The downstream payout
provider does support a PromptPay proxy (a 10-digit account number is sent as a
MOBILE proxy, a longer one as TXID), but that path cannot be reached through
the public API today. Use a bank code and a bank account number.
#Example Request
{
"amount": 5000.00,
"currency": "THB",
"reference_id": "ORD888888889",
"bank_code": "014",
"account_number": "1234567890",
"account_holder_name": "John Doe",
"webhook_url": "https://example.com/path/to/your/webhook"
}#Response
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"reference_id": "ORD888888889",
"type": "withdraw",
"status": "pending",
"amount": 5000.00,
"currency": "THB",
"created_at": "2026-09-20T18:00:00+07:00"
}status at creation is pending when the payout is queued for review or held
as a suspected duplicate, and processing once it has been handed to the payout
provider. Neither is terminal — wait for the webhook or poll.
No qr_code, qr_code_image, paid_amount or expires_at is returned for a
withdraw.
#Code Examples
#Python
# Create withdraw
response = make_request("POST", "/v1/payments/withdraw", {
"amount": 5000.00,
"currency": "THB",
"reference_id": "ORD888888889",
"bank_code": "014",
"account_number": "1234567890",
"account_holder_name": "John Doe",
"webhook_url": "https://example.com/path/to/your/webhook"
})
print(response.json())#Node.js
// Create withdraw
makeRequest("POST", "/v1/payments/withdraw", {
amount: 5000.0,
currency: "THB",
reference_id: "ORD888888889",
bank_code: "014",
account_number: "1234567890",
account_holder_name: "John Doe",
webhook_url: "https://example.com/path/to/your/webhook",
}).then((res) => console.log(res.data));#PHP
// Create withdraw
$response = makeRequest('POST', '/v1/payments/withdraw', [
'amount' => 5000.00,
'currency' => 'THB',
'reference_id' => 'ORD888888889',
'bank_code' => '014',
'account_number' => '1234567890',
'account_holder_name' => 'John Doe',
'webhook_url' => 'https://example.com/path/to/your/webhook'
]);
print_r($response);#Go
// Create withdraw
result, _ := makeRequest("POST", "/v1/payments/withdraw", map[string]interface{}{
"amount": 5000.00,
"currency": "THB",
"reference_id": "ORD888888889",
"bank_code": "014",
"account_number": "1234567890",
"account_holder_name": "John Doe",
"webhook_url": "https://example.com/path/to/your/webhook",
})
fmt.Printf("%+v\n", result)#Get Balance
Return the calling merchant's balance summary for its environment.
GET /v1/payments/get-balance → 200 OK
No parameters — the merchant is taken from the API key.
{
"available_balance": 125340.50,
"pending_balance": 3000.00,
"total_deposited": 980000.00,
"total_withdrawn": 854659.50
}| Field | Type | Description |
|---|---|---|
available_balance | number | Funds available for withdrawal now |
pending_balance | number | Funds reserved by in-flight withdrawals / not yet cleared |
total_deposited | number | Lifetime credited deposits |
total_withdrawn | number | Lifetime debited withdrawals |
Sign it like any other GET: GET|/v1/payments/get-balance|<timestamp>.
#Get Payment
Retrieve payment details by payment ID.
GET /v1/payments/{id} → 200 OK
#Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Payment ID (UUID) returned at creation |
Lookup is scoped to your merchant and to your environment. A payment belonging
to another merchant returns 404 NOT_FOUND, identical to a payment that does
not exist.
#Example Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference_id": "ORD888888888",
"type": "deposit",
"status": "completed",
"amount": 1500.00,
"currency": "THB",
"created_at": "2026-09-20T17:30:00+07:00",
"expires_at": "2026-09-20T17:40:00+07:00"
}QR data is only returned at creation.
qr_code,qr_code_image,paid_amountandpayment_urlare not returned by this endpoint — store them from the create-deposit response.expires_atis omitted for withdrawals.
There is no lookup by reference_id on the payment API. To search by your own
order id, use GET /v1/merchant-reports/transactions?q=<order id>.
#Cancel Payment
Cancel a pending payment. Only payments whose status is exactly pending can be
cancelled; anything else returns 400 PAYMENT_CANNOT_BE_CANCELLED.
POST /v1/payments/{id}/cancel → 200 OK
The request has no body, so sign three segments:
POST|/v1/payments/<id>/cancel|<timestamp>.
#Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Payment ID to cancel |
#Example Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference_id": "ORD888888888",
"type": "deposit",
"status": "failed",
"amount": 1500.00,
"currency": "THB",
"created_at": "2026-09-20T17:30:00+07:00"
}A cancelled payment reports
status: "failed", with an internal failure reason ofCancelled by merchant. The API does not return acancelledstatus for payments created through this API. Treatfailedafter a successful cancel as "cancelled by me".
#Upload Slip (rescue an expired deposit)
Upload a payment slip image against a deposit that reached expired, so it can
be verified and credited without dashboard or admin involvement.
POST /v1/payments/{id}/upload-slip → 200 OK
Content type: multipart/form-data, single file field file (max 10 MB).
#Signature variant
A multipart body cannot be reproduced byte-for-byte across HTTP clients, so this route signs the file hash instead of the body:
SignatureData = METHOD|PATH|TIMESTAMP|X-Content-SHA256X-Content-SHA256 is the lowercase hex SHA-256 of the file bytes and is
required; the server re-hashes the received file and rejects a mismatch.
TXN_ID="550e8400-e29b-41d4-a716-446655440000"
REQ_PATH="/v1/payments/$TXN_ID/upload-slip"
TS=$(date +%s)
HASH=$(sha256sum slip.jpg | cut -d' ' -f1)
SIG=$(printf "POST|%s|%s|%s" "$REQ_PATH" "$TS" "$HASH" \
| openssl dgst -sha256 -hmac "$SECRET_KEY" -hex | sed 's/^.* //')
curl -X POST "https://api.paymenow.asia$REQ_PATH" \
-H "X-API-Key: $API_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Content-SHA256: $HASH" \
-H "X-Signature: $SIG" \
-F "[email protected]"#Rules
- The transaction must be in status
expired— apendingor already completed one is rejected without the slip being examined. - The slip must be uploaded within 24 hours of expiry.
- A successful upload moves the transaction to
completedand credits the balance immediately. Re-uploading returnsNOT_ELIGIBLEand never double credits — one slip credits once.
#Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference": "ORD888888888",
"type": "deposit",
"status": "completed",
"amount": 1000.00,
"fee": 25.00,
"net_amount": 975.00,
"currency": "THB",
"slip_trans_ref": "01490000...",
"credited": true
}#Rejection reasons
| HTTP | code | Meaning |
|---|---|---|
| 400 | NOT_ELIGIBLE | Transaction is not in expired status |
| 400 | EXPIRED_TOO_OLD | More than 24 hours since expiry |
| 400 | AMOUNT_MISMATCH | Slip amount does not match the transaction |
| 400 | RECEIVER_NOT_RECOGNIZED | Slip was not paid into this transaction's receiving account |
| 400 | SLIP_TOO_OLD | Transfer time on the slip is outside the allowed window |
| 400 | SLIP_ALREADY_USED | This slip was already consumed (by you or another merchant) |
| 400 | PAYER_BANK_MISMATCH | Slip's sending bank differs from the declared payer bank |
| 400 | SLIP_INVALID | Slip could not be read / is not a valid slip |
| 400 | CONTENT_HASH_MISMATCH | Uploaded bytes do not match X-Content-SHA256 |
| 400 | FILE_REQUIRED | No file field in the multipart body |
| 401 | MISSING_CONTENT_HASH | X-Content-SHA256 header absent |
| 404 | NOT_FOUND | No transaction with that id for this merchant |
| 413 | FILE_TOO_LARGE | File exceeds 10 MB |
#Merchant Reports (ERP)
GET /v1/merchant-reports/daily-summary, /transactions, /statement,
/statement/entries and /refunds are authenticated exactly like the payment
API (same key, same signature, same IP whitelist) and served on the same hosts.
They return JSON, CSV or XLSX, carry their own per-merchant rate-limit bucket,
and are the supported way to list transactions, reconcile balances and pull
refund history.
They are documented in full in Merchant Reports API (ERP).
#Health
GET /health and GET /health/detailed are unauthenticated and report whether
the gateway and its Redis dependency are up:
{ "status": "healthy", "environment": "production" }503 with "status": "unhealthy" when a dependency is down. These reflect the
API gateway process only — they are not a platform status page and say nothing
about payment providers or bank connectivity.

