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.
{
"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.
{
"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)
const { body: order } = await createDeposit(payload, idempotencyKey);
// identical handling for 201 (awaiting_payment) and 202 (awaiting_liquidity)
res.redirect(303, order.checkout_url);<?php
$res = pg_post('/v1/pay/deposits', $payload, $idempotencyKey);
header('Location: ' . $res['body']['checkout_url'], true, 303);
exit;Iframe
<!-- 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 checkout | Your own page | |
|---|---|---|
| time for a first integration | about an hour | several days |
| sender-account selection screen | 🔴 still yours to build | yours to build |
| showing the QR + destination account | included | yours to build |
countdown from expires_at | included · re-syncs when the page regains focus | yours 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") | included | yours to build · the most commonly forgotten case |
| the "transfer only from this account" and "single-use QR" notices | included | yours to build · if they are not prominent enough, your under_review rate spikes |
slip-upload button + slip_processing state | included | yours to build |
under_review wording | included, from reasons[].display_th | yours to build (we supply the text, but you have to render it) |
late slip-upload button on expired / canceled | included | yours to build 🔴 forgetting it costs users real money |
| control over look and branding | your White Label's branding | total |
do you have to touch qr_payload? | no, never | yes — with the obligations below |
| webhook handler | required in full | required 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_numberis never returned in full anyway; you only getaccount_number_masked(last 4 digits) andaccount_name_masked.qr_payloadlives exactly as long asexpires_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.
{
"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.
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
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
| topic | rule |
|---|---|
| your webhook endpoint | must 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 string | we 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 deposit | resend the same link next time — the token belongs to that one order |
read the host from the checkout_url the API returned | hardcode https://pay.example-wl.com/c/ and append your own token |
| send the link over a channel only that user can reach | post it in a group chat or forward it |
let it expire on expires_at | store 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.
| status | what 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_review | review.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
- Create a real order and open the actual
checkout_url→ Quickstart - Test the multi-leg case (amount 104.00) and late slips (amount 102.00) → Sandbox
- The sender-account screen that is still yours to build → Sender account rules
- The webhook handler you still need in full → Signature verification and the version guard
