Skip to content

ภาพรวมระบบ ​

เลือกขอบเขตที่ตรงกับรุ่นที่ผู้ให้บริการเปิดใช้

Live P2P implementation 0092 ระบุหก operation ที่พัฒนาแล้วและยังรอตรวจ/deploy ส่วน Sandbox ที่รองรับ แยกขอบเขตทดสอบไว้ต่างหาก ภาพรวมและ flow ขยายด้านล่างเป็น Target contract ไม่ได้ยืนยันว่า endpoint ทุกตัวเปิดใน deployment ของคุณ

อ่านหน้านี้จบแล้วคุณจะรู้ว่าเงินวิ่งทางไหน เราทำอะไรบ้าง และโค้ดฝั่งคุณต้องรองรับอะไรที่ gateway เจ้าอื่นไม่เคยบังคับ

กลไกใน 3 ประโยค ​

  1. เงินไม่เคยผ่านบัญชีของเรา — ผู้ใช้ที่เติมเงิน (ผู้ฝาก) โอนเข้าบัญชีธนาคารของ ผู้ถอน ซึ่งเป็นลูกค้าของ merchant อีกรายโดยตรง
  2. เราเป็นคนจับคู่และเป็นคนตรวจ — จับรายการฝากเข้ากับรายการถอนที่ยอดพอดี เปิดเผยบัญชีปลายทางให้ ตรวจสลิปกับธนาคาร แล้วขยับ ledger ของทั้งสองฝั่ง
  3. exact ต้องผ่านครบ 6 มิติ; ยอดไม่ตรงมีทางอัตโนมัติเมื่อ non-amount อีก 5 มิติผ่าน — short-pay รับตามยอดจริง (A=V<X, partial:true, settled_via:'auto'); overpay เครดิตไม่เกินยอดที่สร้างและเปิด claim ส่วนเกิน · มีเหตุ non-amount/ambiguity/policy อื่นจริงจึงเข้าคิวเจ้าหน้าที่

เงื่อนไขเดียวที่แปลว่าเครดิตได้

data.object.status === 'settled' เท่านั้น · deposit.slip_received, legs[].leg_status === 'complete', HTTP 202 — ไม่มีตัวไหนแปลว่าเงินเข้า

เงินวิ่งทางไหน ​

mermaid
flowchart LR
    U["ผู้ใช้ปลายทางของคุณ<br/>เติมเงิน 5,000 บาท"]
    W["ผู้ถอน<br/>ลูกค้าของ merchant อีกราย<br/>ขอถอน 10,000 บาท"]
    B["ธนาคาร"]

    subgraph GW["Gateway (เรา)"]
        M["จับคู่ฝาก ถอน<br/>ออก legs[] + expires_at"]
        V["ตรวจสลิปกับธนาคาร 6 มิติ"]
        L["ledger + webhook"]
        M --> V --> L
    end

    U ==>|"เงินจริง โอนผ่านแอปธนาคาร"| B
    B ==>|"เข้าบัญชีส่วนตัวของผู้ถอน"| W
    U -.->|"ขอปลายทาง / อัปสลิป"| GW
    GW -.->|"legs[] + webhook"| U
    GW -.->|"withdrawal.filled"| W

เส้นทึบคือเงินจริง เส้นประคือข้อมูล — เส้นทึบไม่ผ่านกล่อง Gateway เลย นั่นคือที่มาของกฎเกือบทุกข้อในเอกสารชุดนี้

ลำดับเวลาของรายการฝากหนึ่งใบ ​

mermaid
sequenceDiagram
    autonumber
    actor U as ผู้ใช้ปลายทาง
    participant M as backend ของคุณ
    participant G as Gateway
    participant B as ธนาคาร
    actor P as ผู้ถอน

    U->>M: กดเติมเงิน 5,000
    M->>G: GET /v1/pay/senders?end_user_ref=u_88213
    G-->>M: บัญชีผู้โอนที่เคยยืนยันแล้ว (sender_id)
    M->>G: POST /v1/pay/deposits (sender_id + client{} + Idempotency-Key)

    alt จับคู่ได้ทันที
        G-->>M: 201 status=awaiting_payment + legs[] + expires_at
    else ยังไม่มีสภาพคล่อง
        G-->>M: 202 status=awaiting_liquidity + suggested_amounts_minor
        G-->>M: webhook deposit.matched (status=awaiting_payment)
    end

    M-->>U: QR / เลขบัญชี + countdown จาก expires_at
    U->>B: โอนจากบัญชีที่แจ้งไว้
    B-->>P: เงินเข้าบัญชีผู้ถอนโดยตรง
    U->>M: อัปโหลดสลิป
    M->>G: POST slip_upload.upload_url + slip_upload.token
    G-->>M: 202 status=accepted + receipt_id
    G->>G: async claim receipt → slip_processing
    G->>B: verify สลิปกับธนาคาร
    B-->>G: ผู้โอน / ผู้รับ / ยอด / เวลา

    alt ยอดตรงครบ 6 มิติ
        G-->>M: webhook deposit.settled
        M->>M: เครดิตผู้ใช้ = amount_received_minor
        G-->>M: webhook balance.credited (เข้า pending_clearing)
        Note over G,M: T+72h → webhook balance.cleared (ถอนได้ตอนนี้)
    else ยอดเกินและอีก 5 มิติผ่าน
        G-->>M: deposit.settled พร้อม overpayment claim/flag
        M->>M: เครดิตเฉพาะยอดที่สร้าง + ให้ผู้ใช้ติดต่อ supportเรื่องส่วนเกิน
    else short-pay และ non-amount อีก 5 มิติผ่าน
        G-->>M: deposit.settled (A=V<X, partial:true, settled_via:auto)
        M->>M: เครดิตยอดที่รับจริงทันที
    else มีเหตุ non-amount / ambiguity / policy อื่น
        G-->>M: webhook deposit.under_review + reasons[].display_th
        G-->>M: deposit.settled หรือ deposit.rejected ภายหลัง
    end

ยอดเงิน 4 ตัวที่ห้ามสับสน ​

deposit.settled แยกยอดสั่ง (X), ยอดที่ธนาคารยืนยัน (V), ยอดที่ระบบรับเข้า (A) และยอดเกิน (E) เป็นคนละความหมาย ทุกค่าเป็น integer หน่วย สตางค์; key overpayment มีเสมอใน settled snapshot โดยเป็น null เมื่อไม่มียอดเกิน และให้ถือ E = 0

fieldใช้ทำอะไรตัวอย่าง exact 5,000 บาท
ordered_amount_minor (X)ยอดที่สร้างรายการ500000
transferred_amount_minor (V)ยอดที่ธนาคารยืนยันว่าโอนจริง500000
amount_received_minor (A)🔴 ยอดที่ระบบรับเข้าและให้เครดิตผู้ใช้ปลายทาง500000
overpayment.amount_minor (E)ยอดส่วนเกินที่อยู่ใน claim; ไม่มีใน exact/underpay0

ค่าธรรมเนียมและยอดกระเป๋า merchant เป็นอีกสองค่าที่คำนวณจาก A: fee_total_minor = 7500 และ credited_amount_minor = 492500

ความสัมพันธ์ที่ต้อง assert ทุกใบใน reconcile job:

amount_received_minor − fee_total_minor = credited_amount_minor

สำหรับ same-slip overpay ต้องเป็น V = A + E และ A = X; รายการยัง settled แต่ผู้ใช้ต้องติดต่อ support ด้วย claim_id

ข้อผิดพลาดอันดับหนึ่งของ merchant ที่ย้ายมาจากเจ้าอื่น

เอา credited_amount_minor ไปเครดิตผู้ใช้ปลายทาง ⇒ ผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียม ทุกออร์เดอร์ เพราะฝั่งเราค่าธรรมเนียมเป็นภาระของ merchant ผู้ใช้จึงได้ยอดที่ระบบรับเข้า (A) เต็มจำนวน ไม่ใช่ยอดหลังหักค่าธรรมเนียม และกรณี overpay A จะถูกจำกัดไว้ที่ยอดที่สร้าง

ห้าเรื่องที่ต่างจาก gateway เจ้าอื่น ​

1. ไม่มีบัญชีกลาง ⇒ ต้องแจ้งบัญชีผู้โอนตอนสร้างรายการ ​

ต่างอย่างไร — เจ้าอื่นแยกแยะสลิปด้วย เศษสตางค์ (1,000.37 / 1,000.42) ของเราแยกด้วย ตัวตนของผู้โอน ยอดจึงเป็นเลขกลมเสมอ ตรงกับราคาสินค้าเป๊ะ

ทำไม — บัญชีปลายทางเป็นบัญชีส่วนตัวของผู้ถอน มีเงินเข้าจากหลายคนในเวลาไล่เลี่ยกัน ระบบต้องรู้ว่าสลิปใบนี้เป็นของรายการไหน · วิธีเศษสตางค์ผู้ใช้พิมพ์ยอดผิดตลอดและชนกันเมื่อ volume สูง

คุณต้องทำเพิ่ม — เพิ่ม หนึ่งหน้าจอ ให้ผู้ใช้เลือกบัญชีที่จะใช้โอน ก่อนสร้างรายการ · ส่ง sender{} หรือ sender_id ทุกครั้ง ไม่ส่ง = 422 sender_required สร้างรายการไม่ได้เลย · ลบโค้ดที่บวก random_cents ทิ้ง

โอนจากบัญชีอื่นไม่ใช่การถูกปฏิเสธ

สลิปยังถูกรับเสมอ รายการจะไป under_review พร้อม reasons=['SENDER_ACCOUNT_MISMATCH'] แล้วเจ้าหน้าที่ตัดสิน ปกติจบที่ settled 🔴 ห้ามขึ้นข้อความ "โอนผิดบัญชี เงินจะไม่เข้า" (ไม่จริง) และ ห้ามมีปุ่ม "โอนใหม่อีกครั้ง" เพราะจะกลายเป็นเงินเข้าซ้ำที่ต้องตามคืน

2. อัปสลิปที่รับเข้าแบบ durable สำเร็จตอบ 202 ไม่มี 409 duplicate_slip แบบทันที ​

ต่างอย่างไร — POST /v1/pay/deposits/{id}/slip ที่ผ่าน auth/validation และเก็บ blob พร้อมยืนยัน queue สำเร็จตอบ 202 แม้เป็นสลิปใบเดิมที่เคยส่งมาแล้ว เจ้าอื่นมักตรวจซ้ำแบบ synchronous แล้วตอบ 409 กลับไปทันที ถ้า durable intake ยังยืนยันไม่ครบจะตอบ retryable 503; ให้ส่ง logical evidence เดิมซ้ำด้วย Idempotency-Key เดิมเพื่อ reuse receipt (boundary/key order/base64 formatting เปลี่ยนได้ถ้า evidence_fingerprint เดิม)

auth ต่างด้วย — เส้นนี้ไม่ใช้ sk_live_*/PG HMAC แต่ใช้ slip_upload ที่ผูกกับ deposit เดียวจาก response 201; ส่งไปที่ upload_url ที่คืนมาโดยตรง ห้ามประกอบ host เอง และ precheck max_bytes/content_types · เส้น 202 awaiting_liquidity ให้ backend ขอ capability ด้วย merchant-HMAC POST /v1/pay/deposits/{id}/slip-token หลัง deposit.matched และก่อนแสดง payment instructions

ทำไม — การอัปสลิปคือจุดที่เงินของผู้ใช้ออกจากบัญชีไปแล้ว ห้ามมีเส้นทางไหนที่ทำให้หลักฐานหาย เราจึงเขียนไฟล์ลง object storage ให้สำเร็จ ก่อน แตะฐานข้อมูล และรับสลิปไว้ก่อนเสมอ · การตรวจซ้ำ (fingerprint ของ sending_bank_code + trans_ref) เกิดขึ้นทีหลังใน pipeline

คุณต้องทำเพิ่ม — ผลตรวจซ้ำมาทาง webhook deposit.under_review พร้อม reasons[].code = 'DUPLICATE_SLIP' หรืออ่านจาก GET /v1/pay/deposits/{id} ไม่ใช่จาก HTTP status ของการอัปโหลด · UI หลังกดส่งต้องเป็น "กำลังตรวจสอบสลิป" ไม่ใช่ "สำเร็จ"

โค้ดที่จะพังทันที

if (res.status !== 200) throw พังทั้งเส้นสร้างรายการฝาก (202 awaiting_liquidity) และเส้นอัปสลิป (202 accepted + receipt) · 2xx ทั้งหมดคือสำเร็จ

3. หนึ่งรายการถอนรับเงินจากหลายรายการฝาก ​

ต่างอย่างไร — เจ้าอื่น 1 รายการถอน = 1 callback ตอนจบ · ของเราถอน 10,000 อาจถูกเติมเป็น 4,000 + 6,000 จากผู้ฝากคนละคน ⇒ withdrawal.filled มาหลายครั้ง รวมทั้งใบอาจได้ 7 webhook (withdrawal.matched ×3 + withdrawal.filled ×3 + withdrawal.settled ×1)

ทำไม — ผู้ฝากคนเดียวที่ยอดพอดี 10,000 หายากกว่าผู้ฝากสามคนที่รวมกันได้ 10,000 มาก การซอยทำให้จับคู่ได้เร็วขึ้นและผู้ถอนรอสั้นลง

คุณต้องทำเพิ่ม — ปิดรายการถอนได้เมื่อ status === 'settled' และ remaining_minor === 0 เท่านั้น · UI ต้องเป็น timeline หลายบรรทัด ไม่ใช่แถบสถานะเดียว · อ่านยอดจาก data.object.filled_minor / remaining_minor ผ่าน version guard 🔴 ห้าม SUM() ตารางฝั่งคุณ และห้าม +=

4. ลำดับ webhook ไม่การันตี และหน้าต่าง retry ยาว ~92 ชั่วโมง ​

ต่างอย่างไร — เจ้าอื่นมักส่ง callback ครั้งเดียวแล้วเลิก · ของเรา retry 11 ครั้งกินเวลา ~92 ชม. 36 นาที ⇒ deposit.expired ของ attempt ที่ 4 มาถึง หลัง deposit.settled ได้จริง

ทำไม — endpoint ล่มสองวันแล้วกลับมา ยังต้องได้ event ครบ ไม่ต้องมานั่งไล่ reconcile ด้วยมือ · ราคาที่จ่ายคือลำดับไม่การันตี

คุณต้องทำเพิ่ม — ทุก event มี data.object.version ⇒ อัปเดตด้วย atomic UPDATE ... WHERE version < $new แล้วเช็ค rowCount · rowCount = 0 แปลว่า event เก่า ตอบ 200 แล้วไม่ทำอะไร · ตัดสินใจจาก data.object.status ไม่ใช่จาก event.type

5. settled ไม่ได้แปลว่าถอนได้ทันที ​

ต่างอย่างไร — เจ้าอื่นเงินเข้ากระเป๋าแล้วถอนออกได้เลย · ของเรายอดที่เพิ่ง settled เข้าไปนอนใน pending_clearing 72 ชั่วโมง ก่อนย้ายไป available

ทำไม — ผู้ถอนมีสิทธิ์กด "ยังไม่ได้รับเงิน" ได้ภายใน 72 ชม. ถ้าเรื่องนั้นจริงต้องกลับรายการ · การกลับรายการภายใน 72 ชม. จะตัดจาก pending_clearing ทำให้ยอด available ของคุณ ไม่ติดลบ ถ้าไม่มีหน้าต่างนี้ก็ต้องไปตามเก็บทีหลัง

คุณต้องทำเพิ่ม — แยกสองช่องในบัญชีภายในของคุณ คำนวณเงินถอนได้จาก available_minor เท่านั้น · handle balance.cleared ที่ T+72h โดย ไม่บวกยอดซ้ำ (เป็นการย้ายช่อง ไม่ใช่เงินเข้าใหม่) · แจ้งฝ่ายการเงินเรื่อง cash-flow ก่อน go-live

expired และ canceled ไม่ใช่จุดจบเสมอไป

ผู้ใช้ที่โอนแล้วปิดเบราว์เซอร์ กลับมาอัปสลิปย้อนหลังได้อีก 24 ชั่วโมง ⇒ คุณจะได้ deposit.settled ของรายการที่ทำเป็น failed ไปแล้ว 🔴 เช็ค is_terminal ไม่ใช่ชื่อ status · ถ้ามี late_slip_accepted_until ต้องเหลือปุ่มอัปสลิปไว้

สถานะที่โค้ดของคุณต้องรองรับครบ ​

รายการฝาก — 9 ค่า

statusเงินอยู่ไหนปล่อยของได้ไหม
awaiting_liquidityยังไม่โอน · ยังไม่มีปลายทาง ห้ามแสดง QR❌
awaiting_paymentยังไม่โอน · มี legs[] + expires_at แล้ว❌
slip_processingโอนแล้ว กำลังตรวจ❌
under_reviewโอนแล้ว รอเจ้าหน้าที่❌
settledเครดิตแล้ว✅ จุดเดียว
expiredปกติยังไม่โอน · อาจมี late-slip ตามมา❌
canceledปกติยังไม่โอน · อาจมี late-slip ตามมา❌
rejectedไม่เครดิต ไม่มี journal❌
reversedเคยเครดิตแล้วถูกดึงกลับ❌ ต้องหักคืน

รายการถอน — 7 ค่า: pending_approval · open · partially_filled · settled · expired · canceled · rejected

expired ของรายการถอนไม่ได้แปลว่าไม่ได้เงินเลย

ถอน 10,000 ได้ไป 4,000 แล้วหมดอายุ ก็ยังเป็น expired ที่ filled_minor เท่ากับ 400000 · ทุก payload มี amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor แยกกันเสมอ

สามกลไกความปลอดภัยที่คุณต้องเขียนโค้ดรองรับ ​

กลไกใช้ที่ไหนถ้าไม่ทำ
HMAC-SHA256 บน request ขาออกของคุณheader PG-Signature ของทุก request401 signature_invalid
Ed25519 บน webhook ขาเข้าheader X-PG-Signature เราถือ private key คุณถือ public keyใครก็ยิง settled ปลอมเข้ามาได้
Idempotency-Keyบังคับทุก POST · TTL 7 วัน400 idempotency_key_required · retry แล้วได้ออร์เดอร์ซ้ำ

🔴 สองกลไกแรกเป็นคนละเรื่องกันโดยสิ้นเชิง อย่าเอาโค้ดตัวหนึ่งไปใช้กับอีกตัว

หน่วยและรูปแบบที่ใช้ทั้งระบบ ​

เรื่องกติกาตัวอย่าง
จำนวนเงินinteger หน่วยสตางค์ · ชื่อ field ลงท้าย _minor ทุกตัว5,000.00 บาท = 500000
เวลาRFC3339 UTC ลงท้าย Z มิลลิวินาที 3 หลักเสมอ2026-08-21T04:12:33.881Z
id ของรายการฝากห้าม parse ห้ามเดา prefix · คอลัมน์ต้องเป็น textdep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B
ที่มาของสภาพคล่องliquidity_source ∈ internal | external_gateway เท่านั้นใช้ทำสถิติได้ 🔴 ห้ามใช้ตัดสินใจเครดิต

เวลาในทุก payload เป็น UTC ไม่ใช่เวลาไทย

2026-08-21T04:30:47.902Z คือ 11:30 น. ตามเวลาไทย · แปลงตอนแสดงผลเท่านั้น อย่าเก็บเป็น local time

ขั้นต่อไป ​

คุณกำลังจะทำอะไรไปที่
เห็นรายการฝากใบแรกวิ่งจนจบภายใน 5 นาทีQuickstart
เซ็น request ให้ผ่าน 401 (มี test vector ให้ตรวจงานตัวเอง)การยืนยันตัวตนของ request
ทำ retry ให้ไม่สร้างออร์เดอร์ซ้ำIdempotency
จำลอง under_review / ธนาคารปิด / หมดเวลา ก่อนต่อของจริงSandbox
ไม่อยากทำหน้า QR เองHosted checkout
เจอศัพท์ที่ไม่รู้จัก (fill, leg, clearing)อภิธานศัพท์

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