PaygateDeveloper documentation

Paygate API

Take PromptPay and Thai wallet payments from a server-to-server API. Start in test mode in minutes.

A REST API for taking payments in Thailand — PromptPay, TrueMoney, LINE Pay, ShopeePay, bank transfer, direct debit and installments — plus refunds with approval, and webhooks for everything that happens afterwards.

The whole integration is three calls: create a payment, show the payer whatever next_action says, and handle the webhook that tells you it succeeded. Everything else on this site is detail you can come back for.

Where to start

  • The API reference is generated from the same OpenAPI document the server serves, so every field, enum and example on it is what the server will actually accept. Each operation page has a Try it panel that makes a real test-mode call with your own key.
  • The error catalogue has a page per error code, reachable directly from the type URL in any error response — paste that URL in your browser and you land on what to do about it.
  • The changelog lists every dated API version and the exact diff between consecutive ones.

Environments and modes

There are two independent dimensions, and confusing them is the most common source of a mystifying 404.

Environment is which deployment you are talking to, and it is the hostname. Mode is test or live, and on the partner surface it comes from the API key itself — pg_test_… keys act in test mode, pg_live_… keys act in live mode. Nothing in the request body or the query string selects mode.

Test and live data are fully separate. A payment created with a test key does not exist for a live key: reading it back answers payments.not_found, not a permission error. Test mode needs no onboarding, moves no money, and is where you should build the whole integration.

Authentication

Every partner request carries an API key as a bearer token:

POST /api/v1/payments HTTP/1.1
Authorization: Bearer pg_test_2f9c…
Content-Type: application/json
Idempotency-Key: 0192f8a1-4c7e-7c3a-9f11-6b2d8e4a1c55

A key is server-side only. Never put one in a browser, a mobile app, or anything else a user can read — a published key is a compromised key, and rotating it is the only fix. Keys carry scopes, matched by dotted prefix: a key scoped payments covers payments.refund, and refunds is a registered alias for it. Each operation page names the permission it needs.

A valid key with the wrong scope gets permission_denied; anything wrong with the key itself gets a uniform unauthenticated.

Request conventions

Headers

HeaderOnNotes
Authorization: Bearer <key>every requestThe key's prefix chooses the mode.
Content-Type: application/jsonrequests with a body
Idempotency-Key: <uuid>required on every partner writeOne per logical operation; the same one on every retry.
PG-Version: <version>optionalA dated version from the changelog. Omitted, you get the default.
Accept-Language: th | enoptionalAffects human-readable messages, not field names.

Every response carries X-Request-Id. Log it. It is the one thing that lets us find your request, and it is also the instance field of any error — so an error you captured is an error we can look up.

Money

Money is always an integer in the currency's minor units, plus the currency code:

{ "amount": { "minor": 125000, "currency": "THB" } }

That is 1,250.00 THB. Never send a decimal, and never compute minor units with floating point — 12.5 * 100 is 1249.9999999999998 in IEEE-754, and truncating it loses a satang that will eventually be a reconciliation meeting.

Idempotency

Partner writes require Idempotency-Key and it is not optional. Generate one UUID per logical operation before the first attempt, store it alongside the work, and send the same value on every retry. Replay with the same key and the same body returns the original response; the same key with a different body is refused as idempotency_key_reused, and a retry that arrives while the first is still running gets idempotency_in_progress — retry that after a short delay rather than switching keys.

The mistake to avoid is generating the key inside the retry loop. That makes every retry a new operation, which is precisely what the header exists to prevent.

Errors

Every error is application/problem+json:

{
  "type": "https://docs.paygate.example/errors/payments.invalid_amount/",
  "title": "Unprocessable Entity",
  "status": 422,
  "code": "payments.invalid_amount",
  "detail": "amount must be positive and in an enabled currency",
  "instance": "req_01JABCDEF0123456789",
  "errors": [{ "field": "amount.minor", "code": "invalid", "message": "must be greater than 0" }]
}

Branch on code, never on detailcode is the contract and detail is prose that may be reworded or translated. type is a URL on this site, so an error in your logs is one click from its remediation. errors[] is present on validation failures and names the offending field.

code is always <module>.<snake_case> for a module error, or a bare snake_case name for a transport or framework one. Codes beginning client. never come from the API: those are minted by @paygate/api-client when no response arrived at all, and they are deliberately absent from the catalogue here.

Pagination

Lists return a cursor envelope:

{ "data": [ /* … */ ], "next_cursor": "eyJjIjoi…", "has_more": true }

Pass limit (1–200, default 50) and follow next_cursor until has_more is false. Cursors are opaque — do not parse, construct or store them as if they encoded an offset.

Rate limits

Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a rate_limited response adds Retry-After. Honour Retry-After and back off with jitter. If you are polling payments for their outcome, use webhooks instead — polling is the usual reason an integration meets this limit at all.

Versioning

API versions are dates. Pin one you have tested against, send it in PG-Version, and change it deliberately after reading its entry in the changelog, which lists operations and fields added, removed and renamed against the previous version. Additive changes can arrive within a version; anything that could break an existing integration gets a new date.

Going live

Test mode first, all the way through: create, attempt, succeed, fail, expire, refund, and every webhook you intend to handle. Then complete merchant onboarding — until the profile is active, live payments are refused with payments.merchant_not_active while test mode keeps working unchanged. Swap the key prefix last. Nothing else about the integration changes.