ภาพรวมระบบ
เลือกขอบเขตที่ตรงกับรุ่นที่ผู้ให้บริการเปิดใช้
Live P2P implementation 0092 ระบุหก operation ที่พัฒนาแล้วและยังรอตรวจ/deploy ส่วน Sandbox ที่รองรับ แยกขอบเขตทดสอบไว้ต่างหาก ภาพรวมและ flow ขยายด้านล่างเป็น Target contract ไม่ได้ยืนยันว่า endpoint ทุกตัวเปิดใน deployment ของคุณ
อ่านหน้านี้จบแล้วคุณจะรู้ว่าเงินวิ่งทางไหน เราทำอะไรบ้าง และโค้ดฝั่งคุณต้องรองรับอะไรที่ gateway เจ้าอื่นไม่เคยบังคับ
กลไกใน 3 ประโยค
- เงินไม่เคยผ่านบัญชีของเรา — ผู้ใช้ที่เติมเงิน (ผู้ฝาก) โอนเข้าบัญชีธนาคารของ ผู้ถอน ซึ่งเป็นลูกค้าของ merchant อีกรายโดยตรง
- เราเป็นคนจับคู่และเป็นคนตรวจ — จับรายการฝากเข้ากับรายการถอนที่ยอดพอดี เปิดเผยบัญชีปลายทางให้ ตรวจสลิปกับธนาคาร แล้วขยับ ledger ของทั้งสองฝั่ง
- 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 — ไม่มีตัวไหนแปลว่าเงินเข้า
เงินวิ่งทางไหน
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 เลย นั่นคือที่มาของกฎเกือบทุกข้อในเอกสารชุดนี้
ลำดับเวลาของรายการฝากหนึ่งใบ
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/underpay | 0 |
ค่าธรรมเนียมและยอดกระเป๋า 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 ของทุก request | 401 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 · คอลัมน์ต้องเป็น text | dep_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) | อภิธานศัพท์ |
