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.3.0Last updated 2026-10-14
#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 (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 (in Thai), e.g. ยอดเงินต้องอยู่ระหว่าง 50.00 ถึง 1,000,000.00 บาท, and
as numbers in details.min_amount / details.max_amount.
#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.godpay.cash/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. Absent when the receiving account has no PromptPay — then account is set |
qr_code_image | string | data:image/png;base64,… QR image (production only; empty in sandbox) |
account | object | Only when there is no QR: the account the customer transfers to by hand — see below |
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.
#
account— a deposit without a QREvery account marked for deposits takes deposits. When the one rotated onto this deposit has no PromptPay ID there is no QR, and the response names the account instead:
"account": { "bank": "004", "account_number": "1112223334", "account_name": "บจก. ตัวอย่าง" }
bankis the Bank of Thailand code (e.g.004= Kasikornbank). Show the bank, the number (with a copy button, digits only) and the name, and ask for exactlypaid_amountfrom the account the customer declared, beforeexpires_at. Matching is the same as for a QR deposit.qr_code/qr_code_imageandaccountnever both appear: branch on which one is present.payment_url, when on, handles both cases for you.
#
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.
Embedding it in an iframe. The page may be framed from any domain, so you can keep the customer on your own checkout:
<iframe src="https://pay.example.com/p/<token>" allow="clipboard-write; web-share" style="width: 100%; min-height: 720px; border: 0" ></iframe>
allow="clipboard-write"is required for the copy buttons. A framed page may not write to the clipboard unless the parent grants it, so without it the buttons that copy the amount and the account number do nothing. The values are still on screen, but a customer who retypes1000.37by hand is how deposits stop matching.web-sharelets "Save QR" reach the phone's photo library. On a phone the button opens the share sheet ("Save Image"), which a framed page may only do when the parent grantsweb-share; without it saving the QR fails there.- If you add a
sandboxattribute, include at leastallow-scripts allow-same-origin allow-downloads: the page runs scripts to poll the status and upload a slip, calls its own origin to do so, and downloads the QR image on desktop. Leavingsandboxoff is simpler.- Use the
payment_urlvalue exactly as returned (its host differs per deployment), and give the frame enough height for a phone layout; the page scrolls inside the frame otherwise.- Framing does not change what the link is: it is still a credential, so serve the parent page over HTTPS and only to that customer.
The merchant can turn it off, in the merchant dashboard under Settings › General. Then
payment_urlis""; nothing else changes — any account marked for deposits is still used, and a deposit without a QR carriesaccount.What this means for your integration: if you render the payment yourself, show the QR when
qr_codeis present andaccountotherwise, and ignorepayment_url. If you use the link, do not also requireqr_codeto be non-empty.
""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.godpay.cash$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.
- The slip must show the exact amount, a transfer made after the deposit was
created, this transaction's receiving account, and the payer bank and
account declared at
/v1/payments/deposit. - A verified slip normally moves the transaction to
completedand credits the balance immediately (credited: true). Some verified slips instead wait for a FinnixPay review — an amount above the auto-credit limit, or a transfer that may be one already credited to another deposit. Those come back withstatus: "pending_approval",credited: falseand areview_reason; the final result (completedorfailed) arrives through your webhook. - Re-uploading returns
NOT_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
}Held for review:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"reference": "ORD888888888",
"type": "deposit",
"status": "pending_approval",
"amount": 12000.00,
"fee": 0,
"net_amount": 0,
"currency": "THB",
"slip_trans_ref": "01490000...",
"credited": false,
"review_reason": "AMOUNT_OVER_AUTO_LIMIT"
}#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 | Slip was transferred before the deposit was created |
| 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 | PAYER_ACCOUNT_MISMATCH | Slip's sending account differs from the declared payer account |
| 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.

