Hosted checkout
ทุก response ของ POST /v1/pay/deposits และทุก deposit.matched มี field checkout_url ติดมาด้วย — เปิด URL นั้นแล้วผู้ใช้จะได้หน้าจ่ายเงินที่ครบทุกอย่าง โดยที่คุณไม่ต้องเขียน 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 เป็น bearer capability สำหรับ client ที่ต้องอัปโหลดเอง ห้ามใส่ token ใน redirect URL หรือ log; ถ้าต้องใช้ให้ส่งไป upload_url ที่คืนมาโดยตรงและตรวจ max_bytes/content_types ก่อน ห้ามประกอบ upload host เอง
เริ่มด้วยทางนี้ก่อนเสมอ
หน้า checkout เป็น default ที่แนะนำตอน onboarding · ถ้าจะทำหน้าเอง ให้ทำหลังจากรายการฝากใบแรกวิ่งจนจบแล้ว จะได้แยกออกว่าปัญหาอยู่ที่ integration หรืออยู่ที่ UI ของคุณ
สามขั้น
ขั้นที่ 1 — สร้างรายการฝากตามปกติ
ไม่มี endpoint พิเศษสำหรับ hosted checkout · ใช้ POST /v1/pay/deposits ตัวเดียวกัน และ ยังต้องส่ง sender หรือ 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 ไม่ได้ทำหน้าจอเลือกบัญชีผู้โอนให้
sender / sender_id เป็น input ของการสร้างรายการ ⇒ ต้องรู้ตั้งแต่ก่อนได้ checkout_url · หน้าจอให้ผู้ใช้เลือกบัญชีที่จะใช้โอน (จาก GET /v1/pay/senders) ยังเป็นงานฝั่งคุณ ไม่มีทางเลี่ยง
return_url คือที่ที่ผู้ใช้จะถูกส่งกลับหลังจบขั้นตอนบนหน้า checkout
return_url ไม่ใช่สัญญาณว่าเงินเข้าแล้ว
ผู้ใช้กลับมาที่หน้าคุณได้ทั้งตอนที่รายการยัง slip_processing และตอนที่ปิดแท็บทิ้งไปเฉย ๆ · ห้ามเครดิตจากการที่ผู้ใช้กลับมาถึง return_url ยึด webhook deposit.settled และ GET /v1/pay/deposits/{id} เท่านั้น
ขั้นที่ 2 — พาผู้ใช้ไปที่ checkout_url
แบบ redirect (แนะนำ)
const { body: order } = await createDeposit(payload, idempotencyKey);
// ทำงานเหมือนกันทั้ง 201 (awaiting_payment) และ 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
<iframe
src="https://pay.example-wl.com/c/9f2c1ab6e0d74f"
title="ชำระเงิน"
width="100%"
height="760"
style="border:0"
allow="clipboard-write"
referrerpolicy="strict-origin"></iframe>ขั้นที่ 3 — รับ webhook (ยังต้องทำเหมือนเดิมทุกอย่าง)
hosted checkout ตัดงาน UI ไม่ได้ตัดงาน ledger
คุณยังต้องมี webhook handler ที่ verify Ed25519 บน raw body, dedupe ด้วย event.id, ทำ version guard, เครดิตผู้ใช้ด้วย amount_received_minor และ handle deposit.reversed / balance.debited ที่ปิดไม่ได้ — ครบเหมือนกับการทำหน้าเองทุกข้อ
หน้า checkout รู้แค่ว่าผู้ใช้ทำอะไรบนจอ · ความจริงเรื่องเงินอยู่ที่ ledger และมาถึงคุณทาง webhook เท่านั้น
เทียบกับการทำหน้าเอง
| Hosted checkout | ทำหน้าเอง | |
|---|---|---|
| เวลาที่ใช้ต่อครั้งแรก | ชั่วโมงเดียว | หลายวัน |
| หน้าจอเลือกบัญชีผู้โอน | 🔴 ยังต้องทำเอง | ต้องทำเอง |
| แสดง QR + เลขบัญชีปลายทาง | ได้เลย | ต้องทำเอง |
countdown จาก expires_at | ได้เลย · re-sync เมื่อกลับมาที่หน้า | ต้องทำเอง 🔴 คนทำมัก hardcode 15 นาที แล้วเพี้ยนกับ route ที่ TTL ~4 นาที |
รองรับ legs.length > 1 ("ขั้นที่ 1 จาก 2") | ได้เลย | ต้องทำเอง · จุดที่ลืมบ่อยที่สุด |
| กล่อง "โอนจากบัญชีนี้เท่านั้น" + "QR ใช้ครั้งเดียว" | ได้เลย | ต้องทำเอง · ถ้าไม่เด่นพอ อัตรา under_review จะพุ่ง |
ปุ่มอัปโหลดสลิป + สถานะ slip_processing | ได้เลย | ต้องทำเอง |
ข้อความของ under_review | ได้เลย จาก reasons[].display_th | ต้องทำเอง (ข้อความเราส่งมาให้อยู่แล้ว แต่ต้องเอาไปแสดง) |
ปุ่มอัปสลิปย้อนหลังตอน expired / canceled | ได้เลย | ต้องทำเอง 🔴 จุดที่ทำให้ผู้ใช้เสียเงินจริงถ้าลืม |
| ควบคุมหน้าตา/แบรนด์ได้แค่ไหน | ตามแบรนด์ของ White Label | เต็มที่ |
ต้องแตะ qr_payload ไหม | ไม่ต้องเลย | ต้อง — พร้อมภาระตามข้างล่าง |
| webhook handler | ต้องมีครบ | ต้องมีครบ |
ถ้าทำหน้าเอง คุณรับภาระเพิ่มสามอย่าง
1. qr_payload เป็นข้อมูลอ่อนไหวที่ห้ามเก็บ
ข้างใน qr_payload มี PromptPay ID ของ ผู้ถอน อยู่ใน TLV โดยหลีกเลี่ยงไม่ได้ ถ้าไม่มีค่านี้ผู้ใช้ก็โอนไม่ได้ เงื่อนไขที่ผูกมาด้วย:
- 🔴 ห้าม log ห้ามเก็บลงฐานข้อมูล ห้ามส่งต่อ — ใช้แสดงผลแล้วทิ้ง
legs[].receiver.account_numberไม่มีในรูปแบบเต็มอยู่แล้ว มีแต่account_number_masked(4 ตัวท้าย) กับaccount_name_maskedqr_payloadมีอายุเท่ากับexpires_atซึ่งในบาง route สั้นถึง ~4 นาที- APM/error tracker ที่ dump request body อัตโนมัติจะละเมิดข้อนี้โดยที่คุณไม่รู้ตัว — ต้องตั้ง scrubber
2. legs[] อาจไม่ถูกส่งมาใน webhook เลย
การส่ง legs[] ออกมาเป็น opt-in ระดับ White Label (webhook_include_legs) ค่าเริ่มต้นเปิดอยู่ แต่ WL ปิดได้ ถ้าปิดแล้ว deposit.matched จะส่งมาแค่ 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"
}
}
}⇒ หน้าที่ทำเองซึ่งพึ่ง legs[] จาก webhook จะจอขาวทันทีที่ WL เปลี่ยนนโยบาย · ทางที่ทนกว่าคือดึงจาก GET /v1/pay/deposits/{id} ตอนผู้ใช้เปิดหน้า ไม่ใช่เก็บจาก webhook ไว้ล่วงหน้า
3. รูป QR ต้องใช้ qr_image_url ที่เราให้ ห้าม re-host
qr_image_url อยู่บนโดเมนของ White Label เสมอ ผ่าน proxy ของเรา — ผู้ใช้จะไม่มีวันเห็นโดเมนของบุคคลที่สามบนหน้าจ่ายเงิน ซึ่งเป็นเรื่องความน่าเชื่อถือล้วน ๆ
เรื่อง origin และ CSP
สิ่งที่คุณต้องตั้งฝั่งคุณ
ถ้าใช้ redirect — ไม่ต้องตั้งอะไรเลย เบราว์เซอร์ออกจากโดเมนคุณไปทั้งหน้า
ถ้าใช้ iframe — CSP ของหน้าที่ฝังต้องอนุญาตโดเมน checkout
Content-Security-Policy: frame-src https://pay.example-wl.com; ...ถ้าคุณโหลดรูป QR มาแสดงเองบนหน้าตัวเอง (กรณีทำหน้าเอง ไม่ใช่ iframe) ต้องเพิ่มด้วย
Content-Security-Policy: img-src 'self' data: https://pay.example-wl.com; ...โดเมนในตัวอย่างเป็นของสมมติ
pay.example-wl.com เป็นตัวอย่าง · โดเมนจริงเป็นของผู้ให้บริการ White Label ของคุณ ให้อ่านค่าจาก checkout_url ที่ API คืนมา อย่า hardcode host ไว้ในโค้ด เพราะโดเมนเปลี่ยนได้ตามแบรนด์
สิ่งที่ต้องตกลงกับผู้ให้บริการ White Label ก่อน
หน้า checkout ถูกป้องกันไม่ให้ถูกฝังจากโดเมนที่ไม่รู้จัก ⇒ ก่อนใช้ iframe ต้องแจ้ง origin ของหน้าที่จะฝัง (เช่น https://shop.example.com) ให้ WL ลงทะเบียนก่อน และทดสอบบน sandbox ให้เห็นว่าฝังได้จริงก่อนขึ้น production
ทดสอบ iframe บน sandbox ก่อนเสมอ
ถ้า origin ยังไม่ถูกลงทะเบียน เบราว์เซอร์จะบล็อกการฝังแบบเงียบ ๆ — ผู้ใช้เห็นกรอบว่างเปล่า และ log ฝั่ง server ของคุณไม่มีอะไรเลย เพราะมันเป็นการบล็อกที่ฝั่งเบราว์เซอร์
ข้อจำกัดที่ระบบบังคับอยู่แล้ว
| เรื่อง | กติกา |
|---|---|
| endpoint รับ webhook ของคุณ | ต้องเป็น https:// · TLS ≥ 1.2 · cert ที่ valid จริง (self-signed ไม่ผ่าน) |
| redirect ในเส้นทาง webhook | 🔴 เราไม่ตาม 3xx — ถือเป็น failure (กัน SSRF และกันลายเซ็นไปโผล่ที่อื่น) |
| ข้อมูลใน query string | เราไม่เติม query param ใด ๆ ให้ endpoint คุณ และไม่ส่งข้อมูลผ่าน query string เลย |
| IP allowlist | 🔴 ใช้ไม่ได้ เราไม่มี IP คงที่ — ใช้การ verify ลายเซ็นเป็นด่านเดียว |
checkout_url ใช้ยังไงให้ถูก
| ✅ | ❌ |
|---|---|
| เปิดครั้งเดียวต่อหนึ่งรายการฝาก | ส่งลิงก์เดิมให้ผู้ใช้ครั้งหน้า — token ผูกกับรายการนั้นใบเดียว |
อ่าน host จาก checkout_url ที่ API คืน | hardcode https://pay.example-wl.com/c/ แล้วต่อ token เอง |
| ส่งลิงก์ผ่านช่องทางที่ผู้ใช้เข้าถึงคนเดียว | โพสต์ในกลุ่มแชท / ส่งต่อ |
ปล่อยให้หมดอายุตาม expires_at | เก็บลิงก์ไว้ใน DB เป็นข้อมูลถาวร |
QR ใช้ได้ครั้งเดียวสำหรับรายการนี้
ถ้าผู้ใช้ screenshot QR ไว้แล้วเอามาโอนซ้ำในภายหลังนอกรายการ เงินจะเข้าบัญชีผู้ถอนโดยไม่มีรายการรองรับ และไม่มีทางกู้คืนอัตโนมัติ ต้องไปเปิดเรื่องผ่าน POST /v1/bo/claims ด้วยมือ
หน้า hosted checkout ขึ้นข้อความเตือนข้อนี้ให้อยู่แล้ว · ถ้าคุณทำหน้าเอง ต้องเขียนข้อความนี้เอง ("QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้ ห้ามบันทึกภาพไว้ใช้ครั้งหน้า")
สถานะบนหน้าคุณกับบนหน้า checkout ต้องไม่ขัดกัน
ผู้ใช้เห็นสองหน้าจอในเวลาเดียวกันได้ — แท็บของคุณ กับหน้า checkout · แหล่งความจริงมีแหล่งเดียว
| status | หน้าคุณควรแสดง |
|---|---|
awaiting_liquidity | "กำลังจัดหาช่องทางโอน" · 🔴 ห้ามบอกว่าล้มเหลว ห้ามแสดง QR |
awaiting_payment | "รอการโอน" + ลิงก์กลับไปหน้า checkout |
slip_processing | "กำลังตรวจสอบสลิป" · ปิดปุ่มอัปสลิปซ้ำ |
under_review | review.reasons[0].display_th ตรง ๆ · สีเหลือง/ฟ้า ไม่ใช่แดง · ห้ามมีปุ่ม "โอนใหม่" |
settled | "เติมเงินสำเร็จ" · จุดเดียวที่เครดิตผู้ใช้ได้ ด้วย amount_received_minor |
expired / canceled ที่ is_terminal: false | "ถ้าคุณโอนไปแล้ว อัปโหลดสลิปได้ภายใน 24 ชั่วโมง" + ลิงก์กลับหน้า checkout |
rejected | "รายการไม่ผ่านการตรวจสอบ" + แสดง deposit_id ให้ copy ได้ |
reversed | "รายการถูกยกเลิกภายหลัง ยอดถูกปรับกลับ" + หักยอดผู้ใช้คืน |
เลือกอย่างไร
ใช้ hosted checkout ถ้า — ต่อครั้งแรก · ทีมเล็ก · ไม่อยากรับภาระ qr_payload · อยากได้ปุ่มอัปสลิปย้อนหลังตอน expired โดยไม่ต้องเขียนเอง
ทำหน้าเองถ้า — ต้องคุมแบรนด์ทุกพิกเซล · มีทีมที่พร้อมดูแล 9 สถานะและเคส multi-leg ตลอดอายุระบบ · มี process ป้องกันไม่ให้ qr_payload หลุดเข้า log
ย้ายจาก hosted ไปทำหน้าเองทีหลังได้ ไม่ต้องรื้อ backend
legs[] มีอยู่ใน response ของ POST /v1/pay/deposits และ GET /v1/pay/deposits/{id} อยู่แล้ว ⇒ เพิ่มหน้าเองแล้วเลิก redirect เมื่อไหร่ก็ได้ · webhook handler ไม่ต้องแก้อะไรเลยเพราะไม่เกี่ยวกัน
ขั้นต่อไป
- ยิงรายการแรกแล้วเปิด
checkout_urlจริง → Quickstart - ทดสอบเคส multi-leg (ยอด 104.00) และ late-slip (ยอด 102.00) → Sandbox
- หน้าจอเลือกบัญชีผู้โอนที่ยังต้องทำเอง → กฎบัญชีผู้โอน
- webhook handler ที่ยังต้องมีครบ → ตรวจลายเซ็น และ version guard
