Skip to content

Hosted checkout ​

ทุก response ของ POST /v1/pay/deposits และทุก deposit.matched มี field checkout_url ติดมาด้วย — เปิด URL นั้นแล้วผู้ใช้จะได้หน้าจ่ายเงินที่ครบทุกอย่าง โดยที่คุณไม่ต้องเขียน 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 เป็น 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 เหมือนเดิม

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 ไม่ได้ทำหน้าจอเลือกบัญชีผู้โอนให้

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 (แนะนำ)

js
const { body: order } = await createDeposit(payload, idempotencyKey);
// ทำงานเหมือนกันทั้ง 201 (awaiting_payment) และ 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
<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_masked
  • qr_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

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"
    }
  }
}

⇒ หน้าที่ทำเองซึ่งพึ่ง 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

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

ถ้าคุณโหลดรูป QR มาแสดงเองบนหน้าตัวเอง (กรณีทำหน้าเอง ไม่ใช่ iframe) ต้องเพิ่มด้วย

http
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_reviewreview.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 ไม่ต้องแก้อะไรเลยเพราะไม่เกี่ยวกัน

ขั้นต่อไป ​

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