PayMeNow

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

MethodPathPurpose
POST/v1/payments/depositCreate a deposit (PromptPay QR)
POST/v1/payments/withdrawCreate a payout to a bank account
GET/v1/payments/get-balanceCurrent balance summary
GET/v1/payments/{id}Fetch one payment
POST/v1/payments/{id}/cancelCancel a pending payment
POST/v1/payments/{id}/upload-slipRescue an expired deposit with a payment slip
GET/v1/merchant-reports/*ERP reports — see merchant-reports-api.md
GET/health, /health/detailedGateway liveness (no authentication)
GET/sandbox/test, /sandbox/credentials, /sandbox/test-amountsSandbox 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

FieldTypeRequiredValidation / Description
amountnumberYes> 0 at the gateway; see Amount limits below
currencystringYesExactly 3 characters. Only THB is processed
reference_idstringYesYour order reference, 1–64 characters. Not deduplicated — see note
bank_codestringYesPayer's bank code, exactly 3 characters — see Bank Codes
bank_account_nostringYesPayer's bank account number, 10–20 characters
bank_account_namestringYesPayer's account holder name, 1–100 characters
customer_emailstringNoMust parse as an email address
customer_phonestringNoMax 20 characters
descriptionstringNoMax 255 characters
webhook_urlstringNoMust parse as a URL. Webhooks are only sent when this is present

Breaking change — 2026-05-13: bank_code, bank_account_no, and bank_account_name are 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 with MISSING_PAYER_INFO (400). See Migration: payer info required.

reference_id is not an idempotency key. Re-sending the same reference_id creates a second, independent transaction; there is no duplicate detection and no 409 on 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

json
{
  "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

json
{
  "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

json
{
  "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

FieldTypeDescription
idstringUnique transaction ID (UUID)
reference_idstringYour order reference
typestringdeposit
statusstringpending on creation (sandbox may return a terminal status immediately)
amountnumberThe amount you requested
paid_amountnumberThe exact amount the customer must transfer — see below
currencystringCurrency code
qr_codestringPromptPay QR payload string
qr_code_imagestringdata:image/png;base64,… QR image (production only; empty in sandbox)
payment_urlstringHosted payment page for this deposit — see below. "" when not configured
created_atstringCreation timestamp (RFC 3339, Asia/Bangkok)
expires_atstringExpiration timestamp (RFC 3339, Asia/Bangkok; 10 minutes by default)

paid_amount is the amount that must actually be transferred. Deposits are matched against bank notifications on payer account + a unique satang amount, so the QR encodes amount plus a small random satang component. Show paid_amount (or the QR) to your customer, never amount, or the transfer may fail to match. In sandbox, paid_amount equals amount.

#payment_url — the hosted payment page

Instead 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_code alone cannot express the second case.
  • It shows the exact amount, with a copy button that copies 1000.37, never the grouped 1,000.37 that 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 usedany account marked for depositsonly accounts with a PromptPay ID
qr_code / qr_code_imagereturnedreturned

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_code yourself, 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_url to be "" and ignore it. If you use the link, do not also require qr_code to 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

FieldTypeRequiredValidation / Description
amountnumberYes> 0 at the gateway; hard floor 100.00 THB, plus your configured payout min/max
currencystringYesExactly 3 characters
reference_idstringYesYour reference, 1–64 characters. Not deduplicated
bank_codestringYesExactly 3 characters — see Bank Codes
account_numberstringYesDestination account number, 10–20 characters
account_holder_namestringYesDestination account holder, 1–100 characters
webhook_urlstringNoMust 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 pending for manual review. This guards against an upstream retry with a fresh reference_id causing a double payout. Poll GET /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.

CodeThai NameEnglish 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

json
{
  "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

json
{
  "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
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
javascript
// 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
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
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.

json
{
  "available_balance": 125340.50,
  "pending_balance": 3000.00,
  "total_deposited": 980000.00,
  "total_withdrawn": 854659.50
}
FieldTypeDescription
available_balancenumberFunds available for withdrawal now
pending_balancenumberFunds reserved by in-flight withdrawals / not yet cleared
total_depositednumberLifetime credited deposits
total_withdrawnnumberLifetime 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

ParameterTypeDescription
idstringPayment 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

json
{
  "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_amount and payment_url are not returned by this endpoint — store them from the create-deposit response. expires_at is 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

ParameterTypeDescription
idstringPayment ID to cancel

#Example Response

json
{
  "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 of Cancelled by merchant. The API does not return a cancelled status for payments created through this API. Treat failed after 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:

text
SignatureData = METHOD|PATH|TIMESTAMP|X-Content-SHA256

X-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.

bash
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 — a pending or 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 completed and credits the balance immediately. Re-uploading returns NOT_ELIGIBLE and never double credits — one slip credits once.

#Response

json
{
  "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

HTTPcodeMeaning
400NOT_ELIGIBLETransaction is not in expired status
400EXPIRED_TOO_OLDMore than 24 hours since expiry
400AMOUNT_MISMATCHSlip amount does not match the transaction
400RECEIVER_NOT_RECOGNIZEDSlip was not paid into this transaction's receiving account
400SLIP_TOO_OLDTransfer time on the slip is outside the allowed window
400SLIP_ALREADY_USEDThis slip was already consumed (by you or another merchant)
400PAYER_BANK_MISMATCHSlip's sending bank differs from the declared payer bank
400SLIP_INVALIDSlip could not be read / is not a valid slip
400CONTENT_HASH_MISMATCHUploaded bytes do not match X-Content-SHA256
400FILE_REQUIREDNo file field in the multipart body
401MISSING_CONTENT_HASHX-Content-SHA256 header absent
404NOT_FOUNDNo transaction with that id for this merchant
413FILE_TOO_LARGEFile 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:

json
{ "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.