Skip to content

Hosted checkout ​

Every response from POST /v1/pay/deposits and every deposit.matched carries a checkout_url. Open it and the user gets a complete payment page without you writing a single line of UI.

json
{
  "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
  "status": "awaiting_payment",
  "amount_minor": 500000,
  "expires_at": "2026-08-21T04:30:47.902Z",
  "checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
  "slip_upload": {
    "token": "pgsut_eyJraWQiOiJvcmRlc...",
    "expires_at": "2026-08-22T04:30:47.902Z",
    "upload_url": "https://slip-api.example.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/slip",
    "max_bytes": 4194304,
    "content_types": ["image/jpeg", "image/png", "image/webp", "application/json"]
  }
}

slip_upload is a bearer capability for a client that uploads directly. Never put its token in the redirect URL or logs. When needed, post to the returned upload_url exactly and precheck max_bytes/content_types; never construct the upload host yourself.

Always start here

The checkout page is the recommended default during onboarding. If you are going to build your own page, do it after your first deposit has run end to end — that way you can tell an integration problem apart from a UI problem.


Three steps ​

Step 1 — create the deposit as usual ​

There is no special endpoint for hosted checkout. It is the same POST /v1/pay/deposits, and you still have to send sender or sender_id.

json
{
  "amount_minor": 500000,
  "currency": "THB",
  "merchant_reference": "ORD-9981",
  "end_user_ref": "u_88213",
  "sender_id": "snd_01K2QF7H3X9M4TB0YR6WCZ8V2N",
  "client": {
    "device_fingerprint": "df_9a1c4e77b2",
    "ip": "203.0.113.44",
    "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
  },
  "return_url": "https://shop.example.com/topup/ORD-9981",
  "metadata": { "order_id": "ORD-9981" }
}

Hosted checkout does not build the sender-account screen for you

sender / sender_id is an input to order creation, so you must already know it before you have a checkout_url. The screen where the user picks the account they will transfer from (populated by GET /v1/pay/senders) is still your work. There is no way around it.

return_url is where the user is sent after they finish on the checkout page.

return_url is not a signal that the money arrived

The user can land back on your page while the order is still slip_processing, or after simply closing the tab. Never credit because the user reached return_url. Only the deposit.settled webhook and GET /v1/pay/deposits/{id} count.

Step 2 — send the user to checkout_url ​

Redirect (recommended)

js
const { body: order } = await createDeposit(payload, idempotencyKey);
// identical handling for 201 (awaiting_payment) and 202 (awaiting_liquidity)
res.redirect(303, order.checkout_url);
php
<?php
$res = pg_post('/v1/pay/deposits', $payload, $idempotencyKey);
header('Location: ' . $res['body']['checkout_url'], true, 303);
exit;

Iframe

html
<!-- title is in Thai because the end users are Thai ("Payment") -->
<iframe
  src="https://pay.example-wl.com/c/9f2c1ab6e0d74f"
  title="ชำระเงิน"
  width="100%"
  height="760"
  style="border:0"
  allow="clipboard-write"
  referrerpolicy="strict-origin"></iframe>

Step 3 — receive webhooks (unchanged, all of it) ​

Hosted checkout removes UI work, not ledger work

You still need a webhook handler that verifies Ed25519 against the raw body, dedupes on event.id, applies the version guard, credits the user with amount_received_minor, and handles deposit.reversed / balance.debited, which cannot be unsubscribed. Every one of those is identical to building the page yourself.

The checkout page only knows what the user did on screen. The truth about money lives in the ledger and reaches you by webhook only.


Compared with building your own page ​

Hosted checkoutYour own page
time for a first integrationabout an hourseveral days
sender-account selection screen🔴 still yours to buildyours to build
showing the QR + destination accountincludedyours to build
countdown from expires_atincluded · re-syncs when the page regains focusyours to build 🔴 people usually hardcode 15 minutes and get it wrong on routes with a ~4-minute TTL
handling legs.length > 1 ("step 1 of 2")includedyours to build · the most commonly forgotten case
the "transfer only from this account" and "single-use QR" noticesincludedyours to build · if they are not prominent enough, your under_review rate spikes
slip-upload button + slip_processing stateincludedyours to build
under_review wordingincluded, from reasons[].display_thyours to build (we supply the text, but you have to render it)
late slip-upload button on expired / canceledincludedyours to build 🔴 forgetting it costs users real money
control over look and brandingyour White Label's brandingtotal
do you have to touch qr_payload?no, neveryes — with the obligations below
webhook handlerrequired in fullrequired in full

Building your own page adds three obligations ​

1. qr_payload is sensitive data you may not store

qr_payload unavoidably contains the withdrawer's PromptPay ID inside its TLV structure. Without that value the user cannot transfer at all. The conditions that come with it:

  • 🔴 Never log it, never store it in a database, never forward it — render it and drop it.
  • legs[].receiver.account_number is never returned in full anyway; you only get account_number_masked (last 4 digits) and account_name_masked.
  • qr_payload lives exactly as long as expires_at, which on some routes is as short as ~4 minutes.
  • An APM or error tracker that auto-dumps request bodies will violate this without you noticing. Configure a scrubber.

2. legs[] may not be sent in the webhook at all

Including legs[] in webhooks is an opt-in at the White Label level (webhook_include_legs). It defaults to on, but a WL can turn it off. With it off, deposit.matched carries only checkout_url + expires_at.

json
{
  "type": "deposit.matched",
  "data": {
    "object": {
      "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
      "object": "deposit_order",
      "version": 3,
      "status": "awaiting_payment",
      "is_terminal": false,
      "amount_minor": 500000,
      "currency": "THB",
      "expires_at": "2026-08-21T04:14:41.000Z",
      "checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f"
    }
  }
}

⇒ A custom page that depends on legs[] from the webhook goes blank the moment the WL changes that policy. The durable approach is to fetch from GET /v1/pay/deposits/{id} when the user opens the page, rather than caching what the webhook sent.

3. The QR image must come from the qr_image_url we give you — never re-host it

qr_image_url is always on the White Label's domain, proxied by us. The user will never see a third-party domain on a payment screen. That is purely a matter of trust.


Origins and CSP ​

What you configure on your side ​

Using a redirect — nothing to configure. The browser leaves your domain entirely.

Using an iframe — the CSP of the embedding page must allow the checkout domain.

http
Content-Security-Policy: frame-src https://pay.example-wl.com; ...

If you load the QR image onto your own page (building your own page, not embedding), you also need

http
Content-Security-Policy: img-src 'self' data: https://pay.example-wl.com; ...

The domain in these examples is made up

pay.example-wl.com is an example. The real domain belongs to your White Label provider. Read the host out of the checkout_url the API returns — never hardcode it, because the domain changes with the brand.

What you have to agree with your White Label provider first ​

The checkout page is protected against being embedded by unknown domains. Before using an iframe you must give the WL the origin of the embedding page (for example https://shop.example.com) so they can register it, and you must confirm on sandbox that the embed actually works before going to production.

Always test the iframe on sandbox first

If the origin is not registered, the browser blocks the embed silently — the user sees an empty frame and your server logs show nothing at all, because the block happens in the browser.

Constraints the system already enforces ​

topicrule
your webhook endpointmust be https:// · TLS ≥ 1.2 · a genuinely valid certificate (self-signed fails)
redirects on the webhook path🔴 we do not follow 3xx — it counts as a failure (SSRF protection, and to keep signatures from reaching somewhere else)
data in the query stringwe never add query parameters to your endpoint and never send data through a query string
IP allowlisting🔴 not possible, we have no fixed IPs — signature verification is the only gate

Using checkout_url correctly ​

✅❌
open it once per depositresend the same link next time — the token belongs to that one order
read the host from the checkout_url the API returnedhardcode https://pay.example-wl.com/c/ and append your own token
send the link over a channel only that user can reachpost it in a group chat or forward it
let it expire on expires_atstore the link in your DB as permanent data

The QR is valid once, for this order only

If the user screenshots the QR and transfers against it again later, outside any order, the money lands in the withdrawer's account with no order behind it, and there is no automatic recovery. It has to be opened manually through POST /v1/bo/claims.

The hosted checkout page already displays this warning. If you build your own page, you have to write it yourself: "QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ ห้ามบันทึกภาพไว้ใช้ครั้งหน้า" ("This QR is valid once, for this order only — do not save the image for next time").


Your page and the checkout page must not contradict each other ​

The user can have both screens open at once — your tab and the checkout page. There is only one source of truth.

statuswhat your page should show
awaiting_liquidity"กำลังจัดหาช่องทางโอน" ("finding a transfer route") · 🔴 never say it failed, never show a QR
awaiting_payment"รอการโอน" ("waiting for your transfer") + a link back to the checkout page
slip_processing"กำลังตรวจสอบสลิป" ("checking the slip") · disable the re-upload button
under_reviewreview.reasons[0].display_th verbatim · yellow/blue, not red · no "transfer again" button
settled"เติมเงินสำเร็จ" ("top-up complete") · the only point at which you may credit the user, with amount_received_minor
expired / canceled with is_terminal: false"ถ้าคุณโอนไปแล้ว อัปโหลดสลิปได้ภายใน 24 ชั่วโมง" ("if you already transferred, you can upload the slip within 24 hours") + a link back to the checkout page
rejected"รายการไม่ผ่านการตรวจสอบ" ("this order did not pass verification") + show the deposit_id so it can be copied
reversed"รายการถูกยกเลิกภายหลัง ยอดถูกปรับกลับ" ("this order was cancelled afterwards and the amount has been adjusted back") + take the balance back from the user

How to choose ​

Use hosted checkout if — this is your first integration · your team is small · you do not want the qr_payload obligations · you want the late slip-upload button on expired without writing it.

Build your own page if — you need pixel control of the brand · you have a team that will maintain all 9 statuses and the multi-leg case for the life of the system · you have a process that keeps qr_payload out of your logs.

You can move from hosted to your own page later without touching the backend

legs[] is already present in the responses of POST /v1/pay/deposits and GET /v1/pay/deposits/{id}, so you can add your own page and stop redirecting whenever you like. The webhook handler needs no changes at all — the two are unrelated.

Next ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01