แคตตาล็อก event ทั้งหมด
นี่คือรายการเดียวในระบบ · ไม่มี event ที่ไม่อยู่ในตารางนี้
type เป็น enum เปิด
วันหนึ่งจะมีชื่อ event ใหม่โผล่มาโดยไม่นับเป็น breaking change · switch (event.type) ทุกตัวต้องมี default: ที่ ตอบ 200 แล้วบันทึกไว้เฉย ๆ ไม่ใช่ throw
ตาราง event
ฝั่งรายการฝาก (deposit.*)
| Event | เกิดเมื่อไร | ปิดได้ | payload ที่ต่างจากใบอื่น |
|---|---|---|---|
deposit.created | ทุกครั้งที่สร้างรายการสำเร็จ — ทั้งเส้น 201 และเส้น 202 | ✅ | data.previous_attributes = null (event เดียวที่เป็น null เสมอ) · request.idempotency_key · data.object.status เป็นค่าจริง (awaiting_payment หรือ awaiting_liquidity) |
deposit.awaiting_liquidity | ยังไม่มีคู่จับคู่ · ยิงหลัง created เป็น attribute-change | ✅ | liquidity.wait_started_at · liquidity.estimated_wait_ms · liquidity.retry_interval_ms · liquidity.suggested_amounts_minor[] |
deposit.matched | ได้ปลายทางให้โอนแล้ว | ✅ | legs[] (QR + ปลายทาง + expires_at ต่อ leg) · liquidity_source · leg_count — event เดียวที่มี QR |
deposit.leg_updated | ปลายทางบาง leg เปลี่ยนสถานะ | ✅ | legs[{leg_ref, sequence, total, leg_status}] |
deposit.slip_received | รับสลิปแล้ว (ตอบ 202 ไปแล้ว) | ✅ | slip.received_at · slip.is_late · 🔴 ยังไม่มี verification — ห้ามปล่อยของ |
deposit.slip_rejected | รูปอ่านไม่ออก ขอรูปใหม่ | ✅ | retryable: true · rejection.code = 'SLIP_UNREADABLE' · attempts_left — 🔴 ไม่ใช่การปฏิเสธเงิน |
deposit.under_review | มิติ non-amount ไม่ผ่าน / ติด policy / ธนาคารปิดตรวจ / สลิปซ้ำ / กำกวม; amount mismatch อย่างเดียวไม่เข้าคิว | ✅ | review.reasons[] · review.opened_at · review.escalated · review.four_eyes_required · review.estimated_resolution_bucket · verification{} boolean 6 ตัว |
deposit.settled | เครดิตแล้ว รวม clean short-pay และ same-slip overpay | ✅ | snapshot การเงินครบ + overpayment เสมอ (null เมื่อไม่เกิน, object เมื่อเกิน) · clean short-pay ส่ง A=V<X, partial:true, settled_via:'auto'; same-slip overpay ส่งใบนี้เพียงใบเดียว |
deposit.overpaid | มี transfer เพิ่มหลังรายการ settle ไปแล้ว | ✅ | settled snapshot ครบ (ordered/transferred/received/fee/credited/partial) โดยยอดเดิมไม่เปลี่ยน + overpayment{claim_id, cause:'additional_transfer', amount_minor, slip_received_at, status, action_required} — 🔴 status ยัง settled; ไม่มี auto-refund |
deposit.expired | หมดเวลาโดยไม่มีสลิป | ✅ | late_slip_accepted_until · is_terminal · expired_reason ∈ no_payment |
deposit.canceled | ยกเลิกก่อนโอน — คุณกด / ผู้ใช้กด / ระบบหมดทางจัดหาสภาพคล่อง | ✅ | canceled_by ∈ merchant | end_user | system · cancel_reason_code · late_slip_accepted_until (อาจเป็น null) · is_terminal |
deposit.rejected | เจ้าหน้าที่ตัดสินไม่รับสลิปใบนี้ | ✅ | rejection.code + rejection.display_th / display_en |
deposit.reversed | เคย settle แล้วถูกกลับรายการ | 🔴 ไม่ได้ | reversal.reason_code · reversal.reverses_balance_transaction_id · reversal.balance_transaction_id |
ฝั่งรายการถอน (withdrawal.*)
| Event | เกิดเมื่อไร | ปิดได้ | payload ที่ต่างจากใบอื่น |
|---|---|---|---|
withdrawal.created | สร้างรายการถอนสำเร็จ | ✅ | requires_approval · reserved_balance_transaction_id (ยอด amount + fee ที่กันไว้) |
withdrawal.matched | ทุกครั้งที่มีคนจองยอดจะโอนให้ | ✅ | matched_legs · reserved_minor · 🔴 ไม่มี id ของรายการฝากฝั่งตรงข้าม |
withdrawal.filled | ได้รับเงินจริง 1 ก้อน | ✅ | fill{leg_ref, sequence, amount_minor, fee_recognized_minor, settled_at, balance_transaction_id, auto_confirm_at} · filled_minor · remaining_minor · settled_splits / max_splits — 🔴 event เดียวที่ยิงหลายครั้งต่อ 1 รายการ |
withdrawal.settled | ได้รับครบยอด | ✅ | fills[] สรุปครบทุกก้อน · fee_total_minor · dust_absorbed |
withdrawal.expired | หมดเวลาโดยยังเหลือยอด | ✅ | filled_minor / refunded_minor / fee_recognized_minor · released_balance_transaction_id |
withdrawal.canceled | ยกเลิกส่วนที่เหลือ | ✅ | canceled_by · refunded_minor |
withdrawal.rejected | ถูกปฏิเสธตอนรออนุมัติ | ✅ | rejection.code + display |
withdrawal.receipt_confirmed | ผู้ถอนยืนยันว่าได้รับเงินก้อนนั้น | ✅ | fill.leg_ref · confirmed_by ∈ withdrawer | auto |
withdrawal.disputed | ผู้ถอนแจ้งว่าไม่ได้รับเงิน | ✅ | dispute{id, reason_code, amount_minor, opened_at, respond_by, evidence_url} · funds_on_hold: true · 🔴 status ไม่เปลี่ยน |
withdrawal.dispute_resolved | ตัดสินข้อพิพาทแล้ว | ✅ | dispute{id, outcome ∈ confirmed | reversed} |
withdrawal.fill_reversed | เงินก้อนที่จ่ายไปแล้วถูกกลับรายการ | 🔴 ไม่ได้ | fill.leg_ref · reversal.reason_code · replacement_withdrawal_id (ถ้ามี) |
ฝั่งกระเป๋าเงิน (balance.*)
| Event | เกิดเมื่อไร | ปิดได้ | payload ที่ต่างจากใบอื่น |
|---|---|---|---|
balance.credited | ทุกครั้งที่มีเงินเข้ากระเป๋า | ✅ | data.object เป็น balance_transaction (immutable — ไม่มี version) · bucket_to · available_at |
balance.cleared | ครบ 72 ชม. ยอดย้ายจากรอเคลียร์เป็นถอนได้ | ✅ | cleared_from_balance_transaction_id · bucket_from: 'pending_clearing' · bucket_to: 'available' |
balance.debited | ทุกครั้งที่มีเงินออกจากกระเป๋า | 🔴 ไม่ได้ | type ∈ withdrawal_reserved | withdrawal_settled | reversal | fee | adjustment · balance_after{} |
balance.cleared ไม่ใช่เงินเข้าใหม่
มันคือการย้าย bucket จาก pending_clearing → available ของยอดเดิม
merchant ที่บวกยอดตาม event จะได้ยอด สองเท่า ถ้าเผลอนับใบนี้เป็นเงินเข้า · เหตุผลที่เราไม่ยิง balance.credited ซ้ำที่ T+72h ก็คือข้อนี้ตรง ๆ
ฝั่งระบบ (webhook.*) — ปิดไม่ได้ทั้งคู่
| Event | เกิดเมื่อไร | ปิดได้ | payload ที่ต่างจากใบอื่น |
|---|---|---|---|
webhook.key.rotating | ประกาศ kid ใหม่ล่วงหน้า 7 วัน | 🔴 ไม่ได้ | next_kid · next_public_key · next_raw_public_key · activates_at — ⚠️ เซ็นด้วยกุญแจปัจจุบัน ไม่ใช่กุญแจใหม่ |
webhook.endpoint.disabled | ระบบปิด endpoint อัตโนมัติหลังล่มติดกัน 24 ชม. | 🔴 ไม่ได้ | ส่งไป endpoint กลุ่มอื่นที่ยังดี + อีเมลถึง technical contact |
event ที่ยังไม่มีรายละเอียด payload ในสัญญา
สามตัวนี้อยู่ในรายการชื่อ event ที่คุณต้องรองรับ และมีที่มาชัดเจน แต่ยังไม่มีสเปก payload เต็ม:
| Event | เกิดเมื่อไร | ที่รู้แน่ |
|---|---|---|
beneficiary.verified | บัญชีผู้รับเงินถอนผ่านการตรวจชื่อกับธนาคาร (ปกติ < 30 วิ) | ต้องรอใบนี้ก่อนสร้างรายการถอนด้วย beneficiary_id นั้น |
beneficiary.rejected | ตรวจไม่ผ่าน | code ∈ NAME_MISMATCH | ACCOUNT_NOT_FOUND | BANK_UNAVAILABLE |
claim.updated | เรื่องที่เปิดไว้ (รวม overpayment) มีความคืบหน้า | Claim.version เพิ่มเสมอ; เส้นปกติ open/end_user → refund_pending/none → refunded/refund_issued; terminal payout failure กลับ under_review/support โดยไม่มี journal; original transfer return อาจเดิน return_pending_payout/none → returned_by_bank/transfer_returned และไม่ใช่ payout จากระบบ · ไม่มี auto-refund |
⇒ เขียน handler ให้ตกลง default: ได้อย่างปลอดภัย แล้วอ่านสถานะจริงจาก endpoint ที่เกี่ยวข้อง
event ที่ไม่เปลี่ยน status (attribute-change)
5 ตัวนี้ยิงโดย สถานะเดิมไม่ขยับ — ถ้า handler ของคุณตัดสินใจจาก event.type อย่างเดียวจะเข้าใจผิดทันที
| event | เปลี่ยนอะไร | ทำไมไม่มีสถานะใหม่ |
|---|---|---|
deposit.awaiting_liquidity | liquidity{} | สถานะยังเป็น awaiting_liquidity เหมือนเดิม |
deposit.under_review (ซ้ำใบที่สอง) | review.escalated = true | รอนานเกินเกณฑ์ต้องไม่แตะเงิน |
deposit.overpaid | แนบ latest overpayment{} จาก transfer เพิ่ม | รายการเดิม settle ถูกต้องแล้ว ห้ามย้อน · same-slip flagมากับ deposit.settled |
withdrawal.matched | reserved_minor | การจองยอดห้ามเลื่อนสถานะ |
withdrawal.disputed | แนบ dispute{} | ข้อพิพาทเกิดกับใบที่ settle แล้ว การเพิ่มสถานะ = ถอยออกจากสถานะปลายทาง |
ทุกใบยัง bump version เสมอ
attribute-change ก็ได้ version ใหม่ ⇒ version guard ของคุณจะไม่ทิ้งมัน · ถ้าไม่ bump คุณจะไม่มีทางรู้ว่ามี escalate เกิดขึ้น
ไม่มี event ชื่อ deposit.auto_approved และจะไม่มีวันมี
รายการที่ค้างคิวเจ้าหน้าที่นานเกินเกณฑ์ ไม่ถูกอนุมัติอัตโนมัติ — เราส่ง deposit.under_review ซ้ำ ด้วย review.escalated: true และ version ใหม่
ไม่มีเส้นทางไหนในระบบที่ timeout แล้วกลายเป็น settled
ตัวอย่างจริง 6 ใบ
ทั้ง 6 ใบเป็น JSON ที่ parse ได้จริง · เงินเป็น integer หน่วยสตางค์ · เวลาเป็น UTC ลงท้าย Z มิลลิวินาที 3 หลัก
1. deposit.matched — 2 legs ผ่านช่องทางสำรอง
ใบนี้คือใบเดียวที่มี QR · สังเกตว่า expires_at สั้นลง จากตอนสร้างรายการ
{
"id": "evt_01K3M7YB4C9QDR2XKF0T8VN3PA",
"object": "event",
"type": "deposit.matched",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:10:52.104Z",
"sequence": 88207,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"request": {
"id": "req_01K3M7YA9C2NVRT4HB0XKDF8QS",
"idempotency_key": "order-9981-topup"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 3,
"status": "awaiting_payment",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"expires_at": "2026-08-21T04:14:41.000Z",
"checkout_url": "https://pay.example-wl.com/c/9f2c1ab6e0d74f",
"liquidity_source": "external_gateway",
"leg_count": 2,
"legs": [
{
"leg_ref": "leg_7QF2H8KM",
"sequence": 1,
"amount_minor": 300000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560012345530376454063000.005802TH6304A1B2",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/1.png",
"receiver": {
"bank_code": "014",
"bank_name_th": "ธนาคารไทยพาณิชย์",
"account_number_masked": "xxx-x-x1234",
"account_name_masked": "ส*** ใ***"
}
},
{
"leg_ref": "leg_7QF2H8KN",
"sequence": 2,
"amount_minor": 200000,
"expires_at": "2026-08-21T04:14:41.000Z",
"leg_status": "pending",
"method": "promptpay_qr",
"qr_payload": "00020101021229370016A00000067701011201150105560099887530376454062000.005802TH6304C3D4",
"qr_image_url": "https://pay.example-wl.com/qr/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/2.png",
"receiver": {
"bank_code": "004",
"bank_name_th": "ธนาคารกสิกรไทย",
"account_number_masked": "xxx-x-x9887",
"account_name_masked": "ว*** ป***"
}
}
],
"sender": {
"bank_code": "002",
"account_number_masked": "xxx-x-x5567",
"account_name_th_masked": "ป*** ส***"
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": {
"version": 2,
"status": "awaiting_liquidity",
"legs": null,
"liquidity_source": null,
"leg_count": null,
"expires_at": "2026-08-21T04:30:47.902Z"
}
}
}สิ่งที่ต้องทำกับใบนี้
- แสดง ทุก leg —
legs.length > 1ได้ ⇒ UI ต้องเป็น "ขั้นที่ 1 จาก 2" ไม่ใช่หยิบlegs[0]มาแสดงใบเดียว - 🔴 นับถอยหลังจาก
expires_atที่ได้มาในใบนี้ ห้าม hardcode · ค่าเดิมคือ 20 นาที ค่าใหม่คือ ~4 นาที liquidity_source: "external_gateway"เป็นข้อมูลประกอบเท่านั้น 🔴 ห้ามใช้ตัดสินใจเครดิต- 🔴
qr_payloadห้าม log ห้ามเก็บลง DB ห้ามส่งต่อ — ใช้แสดงผลแล้วทิ้ง · ทางที่ปลอดภัยกว่าคือส่งผู้ใช้ไปที่checkout_url
2. deposit.under_review — ธนาคารปิดตรวจ + ชื่อผู้โอนไม่ตรง
{
"id": "evt_01K3M9F2XN8VQKR4T0BDHY7WZE",
"object": "event",
"type": "deposit.under_review",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:19:08.774Z",
"sequence": 88231,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"review": {
"opened_at": "2026-08-21T04:19:08.601Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "BANK_DISABLED",
"display_th": "ขณะนี้ระบบตรวจสอบสลิปของธนาคารนี้ปิดรับชั่วคราว เจ้าหน้าที่กำลังตรวจสอบรายการของคุณด้วยตนเอง เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "Automatic slip checking for this bank is temporarily unavailable. Our team is reviewing your transfer manually.",
"actionable_by": "support",
"detail": { "bank_code": "002" }
},
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับชื่อที่แจ้งไว้ตอนสร้างรายการ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "The sender name on the slip does not match the name registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "ป*** ส***", "matched": false }
}
]
},
"slip": { "received_at": "2026-08-21T04:19:02.180Z", "is_late": false },
"verification": {
"amount_exact": true,
"within_time_window": true,
"sender_bank_match": true,
"sender_account_match": true,
"sender_name_match": false,
"receiver_match": true
},
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": {
"version": 5,
"status": "slip_processing",
"review": null,
"verification": null
}
}
}สิ่งที่ต้องทำกับใบนี้
- แสดง
review.reasons[0].display_thตรง ๆ — เราเขียนข้อความมาให้แล้ว คุณไม่ต้องเขียนเอง และreasons[]เรียงตามความสำคัญ ตัวแรกคือตัวที่ควรแสดงถ้าจอมีที่ว่างบรรทัดเดียว - 🔴 ห้ามปล่อยของ · ห้ามมีปุ่ม "โอนใหม่" — เงินโอนเข้าไปแล้ว การให้โอนซ้ำคือทำให้ผู้ใช้เสียเงินสองรอบ
verification{}เป็น boolean ล้วน 6 ตัว — เราไม่ส่งชื่อ/เลขบัญชีที่อ่านได้จากสลิปออกมา เพราะคนโอนจริงอาจเป็นคนละคนกับที่คุณแจ้ง (โอนจากบัญชีภรรยา) = ข้อมูลส่วนบุคคลของบุคคลที่สามestimated_resolution_bucket ∈ under_15m | under_1h | under_4h | extendedเอาไปบอกผู้ใช้ได้เลยว่าประมาณเท่าไร
3. deposit.settled — ยอดโอนจริงกับยอดรับเข้าต้องแยกกัน
{
"id": "evt_01K3M9ZZ2P7QW2NHT0RXKD5V8C",
"object": "event",
"type": "deposit.settled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:21:11.402Z",
"sequence": 88240,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 9,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"ordered_amount_minor": 500000,
"transferred_amount_minor": 500000,
"amount_received_minor": 500000,
"fee_total_minor": 7500,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 5000, "rate_bps": 100 },
{ "component": "fixed_fee", "amount_minor": 2500 }
],
"credited_amount_minor": 492500,
"overpayment": null,
"currency": "THB",
"created_at": "2026-08-21T04:10:47.902Z",
"partial": false,
"settled_via": "review",
"liquidity_source": "external_gateway",
"balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"settled_at": "2026-08-21T04:21:11.208Z",
"clearing_available_at": "2026-08-24T04:21:11.208Z",
"metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
},
"previous_attributes": { "version": 6, "status": "under_review" }
}
}ตัวเลขเหล่านี้อย่าสลับกัน
transferred_amount_minor→ 500000 คือยอดธนาคารยืนยันของ settlement evidence; สูงกว่าAเฉพาะ same-slip overpay · transfer เพิ่มหลัง settle ไม่ทำให้ค่านี้กลายเป็นยอดสะสม- เครดิตผู้ใช้ปลายทาง =
amount_received_minor→ 500000 (5,000.00 บาท) — ยอดที่ระบบรับเข้า order - กระเป๋า merchant ขยับด้วย
credited_amount_minor→ 492500 (4,925.00 บาท) fee_total_minor→ 7500 · ต้องเป็นจริงเสมอว่าamount_received_minor − fee_total_minor = credited_amount_minor⇒ ใส่ assert ข้อนี้ใน reconcile job รายวัน
ใช้ credited_amount_minor เครดิตผู้ใช้ = ผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียมทุกออร์เดอร์
ถ้าเป็น same-slip overpay transferred_amount_minor จะมากกว่า amount_received_minor=ordered_amount_minor และ object เดียวกันมี overpayment{claim_id,cause:'same_transfer_excess',amount_minor,action_required:'contact_support'}. ยังต้องเครดิตครั้งเดียวด้วย amount_received_minor; แสดง claim ให้ผู้ใช้ติดต่อ support
สิ่งอื่นที่ต้องรู้จากใบนี้
settled_via: "review"แปลว่ารายการนี้ผ่านมือเจ้าหน้าที่ ไม่ใช่ auto — เก็บไว้ดูสถิติได้ แต่ไม่ต้องแสดงผู้ใช้clearing_available_atคือเวลาที่ยอดนี้จะ ถอนได้ (T+72h) · ก่อนหน้านั้นยอดอยู่ในpending_clearing⇒ สร้างรายการถอนแล้วจะได้402 insufficient_balanceทั้งที่ dashboard เห็นตัวเลข- 🔴
is_terminal: falseเพราะsettledยังออกไปreversedได้ · 🔴 อย่าใช้is_terminalตัดสินว่าเครดิตได้หรือไม่ — เงื่อนไขคือstatus === 'settled'เท่านั้น - หลังใบนี้จะมี
balance.creditedตามมา (bucket ปลายทางคือpending_clearing) และอีก 72 ชม. จะมีbalance.cleared
4. deposit.canceled — ระบบหมดทางจัดหาช่องทางให้โอน
เคสนี้เจ้าอื่นมักส่งเป็น expired หรือ HTTP 503 · ของเราสร้างรายการให้เสมอ แล้วปิดด้วย canceled
{
"id": "evt_01K3N2QK8M4TVR7B0HDZX2C5NP",
"object": "event",
"type": "deposit.canceled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T05:02:47.559Z",
"sequence": 88318,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "dep_01K3N2QG5W8VRT0BHKZQ7M4YXD",
"object": "deposit_order",
"version": 4,
"status": "canceled",
"is_terminal": true,
"merchant_reference": "ORD-10042",
"amount_minor": 1750000,
"currency": "THB",
"created_at": "2026-08-21T05:01:22.117Z",
"canceled_by": "system",
"cancel_reason_code": "no_liquidity_available",
"late_slip_accepted_until": null,
"metadata": { "user_id": "u_90117", "order_id": "ORD-10042" }
},
"previous_attributes": { "version": 3, "status": "awaiting_liquidity" }
}
}canceled ไม่ได้แปลว่าจบเสมอไป — ต้องดู 2 field
ใบนี้จบจริง เพราะ ไม่เคยมีปลายทางให้ผู้ใช้โอน ⇒ is_terminal: true และ late_slip_accepted_until: null
แต่ใบที่ผู้ใช้เห็น QR แล้วกดยกเลิก แล้วเผลอโอนตามไป (เกิดสัปดาห์ละหลายครั้ง) จะมาแบบ is_terminal: false พร้อม late_slip_accepted_until เป็นเวลาจริง ⇒ ต้องเหลือปุ่มอัปสลิปไว้ ไม่ใช่ปิดออร์เดอร์
โค้ดที่เขียน case 'deposit.canceled': failOrder() จะทำให้ผู้ใช้เสียเงินจริง — เช็ค is_terminal ไม่ใช่ชื่อสถานะ
กติกาเดียวกันนี้ใช้กับ deposit.expired ทุกประการ
5. withdrawal.filled — ก้อนที่ 1 จากหลายก้อน
รายการถอน 10,000 บาทใบนี้ได้รับเงินก้อนแรก 4,000 บาท · ยังเหลืออีก 6,000
{
"id": "evt_01K3NB4T7QW2VRH0DZXK5M8CNP",
"object": "event",
"type": "withdrawal.filled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T06:48:03.221Z",
"sequence": 88477,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "wdr_01K3NAX2R7T0QWVB5HDZKM9C4E",
"object": "withdrawal_order",
"version": 7,
"status": "partially_filled",
"merchant_reference": "WD-4471",
"amount_total_minor": 1000000,
"fee_total_minor": 3000,
"fee_recognized_minor": 1200,
"filled_minor": 400000,
"remaining_minor": 600000,
"reserved_minor": 0,
"refunded_minor": 0,
"settled_splits": 1,
"max_splits": 4,
"currency": "THB",
"expires_at": "2026-08-21T10:44:58.000Z",
"metadata": { "user_id": "u_77320", "payout_id": "WD-4471" }
},
"fill": {
"leg_ref": "leg_5TQ0WH2E",
"sequence": 1,
"amount_minor": 400000,
"fee_recognized_minor": 1200,
"settled_at": "2026-08-21T06:48:03.004Z",
"balance_transaction_id": "btxn_01K3NB4T5RW2VRH0DZXK5M8CNP",
"auto_confirm_at": "2026-08-24T06:48:03.004Z"
},
"previous_attributes": {
"version": 6,
"status": "open",
"filled_minor": 0,
"remaining_minor": 1000000,
"fee_recognized_minor": 0,
"settled_splits": 0
}
}
}1 รายการถอน = webhook หลายใบ
ถอน 10,000 อาจได้ withdrawal.matched ×3 + withdrawal.filled ×3 + withdrawal.settled ×1 = 7 ใบต่อ 1 รายการ
- 🔴
withdrawal.filledไม่ใช่จุดจบ — ปิดรายการเมื่อstatus = 'settled'และremaining_minor = 0เท่านั้น - 🔴 อ่านยอดจาก
data.object.filled_minor/remaining_minorที่ผ่าน version guard · ห้ามSUM()ตาราง fill ของตัวเอง (event อาจยังมาไม่ครบ ⇒ ได้ค่าต่ำกว่าจริง) และ ห้าม+=(delivery ซ้ำจะบวกเกิน) - ตาราง fill ฝั่งคุณมี
leg_refเป็น primary key ไว้ทำ timeline/audit เท่านั้น ห้ามเอาไปขับ business logic - 🔴 อย่าคำนวณค่าธรรมเนียมเองต่อก้อน — ค่าธรรมเนียมแบบคงที่ผูกกับ ใบ ไม่ใช่ต่อก้อน ⇒ ซอย 4 ครั้งไม่ได้จ่ายคงที่ 4 เท่า · ใช้
fee_recognized_minorที่เราส่งมา - UI ต้องเป็น timeline หลายบรรทัด ไม่ใช่แถบสถานะเดียว
auto_confirm_at คือเวลาที่ก้อนนี้จะถือว่าผู้รับยืนยันแล้วโดยอัตโนมัติถ้าไม่มีใครทักท้วง (72 ชม.) · ก่อนถึงเวลานั้นอาจมี withdrawal.disputed เข้ามาได้
6. balance.debited — reversal · ใบที่ปิดไม่ได้
{
"id": "evt_01K3PB6QW2ZR9YT5N4KDXCVH8M",
"object": "event",
"type": "balance.debited",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T09:41:20.006Z",
"sequence": 89402,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"data": {
"object": {
"id": "btxn_01K3PB6QW1M8HN0DZ2XKQ7RV4T",
"object": "balance_transaction",
"type": "reversal",
"direction": "debit",
"amount_minor": 492500,
"currency": "THB",
"bucket_from": "pending_clearing",
"bucket_to": null,
"balance_after": {
"available_minor": 1284300,
"pending_clearing_minor": 0,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"reverses_balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT",
"reason_display_th": "รายการถูกยกเลิกหลังจากตรวจพบว่าสลิปไม่ถูกต้อง",
"created_at": "2026-08-21T09:41:19.884Z"
}
}
}สิ่งที่ต่างจากใบอื่นทั้งหมด
- ไม่มี
versionไม่มีprevious_attributes—balance_transactionเป็น immutable ⇒ dedupe ด้วยidแล้ว สะสม ห้ามเขียนทับ reverses_balance_transaction_idชี้กลับไปที่balance_transaction_idของdeposit.settledใบเดิม (ตัวอย่างที่ 3) ⇒ คุณกลับรายการฝั่งตัวเองได้แม่นยำ ไม่ต้องเดา · ระบบบังคับว่า reversal เกิดได้ ครั้งเดียวต่อ 1 รายการ- ยอด 492500 คือ
credited_amount_minorเดิม (หลังหักค่าธรรมเนียม) · ค่าธรรมเนียมที่รับรู้ไปแล้วมีbalance_transactionคืนกลับแยกใบ (type: "fee") bucket_from: "pending_clearing"— การกลับรายการภายใน 72 ชม. ตัดจากยอดที่ยังรอเคลียร์ ไม่ใช่จากยอดที่ถอนได้ ⇒ กระเป๋าคุณไม่ติดลบ นี่คือเหตุผลทั้งหมดของการมี bucket รอเคลียร์- 🔴 ห้ามใช้
balance_afterเป็นยอดคงเหลือของระบบคุณ — เป็น snapshot ณ ตอนนั้น ถ้า event สลับลำดับคุณจะเขียนทับด้วยยอดเก่า · ใช้เพื่อ ตรวจทาน เท่านั้น ยอดจริงดึงจากGET /v1/pay/balance
พจนานุกรม reason code
ทุก entry ใน review.reasons[] หน้าตาแบบนี้:
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "ชื่อบัญชีผู้โอนในสลิปไม่ตรงกับที่แจ้งไว้ตอนสร้างรายการ",
"display_en": "The sender name on the slip does not match the one registered for this order.",
"actionable_by": "end_user",
"detail": { "registered_name_masked": "สม*** ใจ***", "matched": false }
}เราส่งข้อความมาให้ — คุณไม่ต้องเขียนเอง
display_th และ display_en มาในทุกใบ · แสดง reasons[0].display_th ได้เลย
การเขียนข้อความเองจาก code ทำให้ข้อความในระบบคุณล้าสมัยเมื่อเราปรับถ้อยคำ และ code เป็น enum เปิด ⇒ วันหนึ่งคุณจะเจอ code ที่ map ไม่ได้แล้วจอว่าง
| code | เกิดจาก | actionable_by | detail ที่จะได้ |
|---|---|---|---|
AMOUNT_MISMATCH | V<X หรือ V>X และมีเหตุ review อื่นร่วมด้วยเท่านั้น; amount-only mismatch settle อัตโนมัติ | end_user | expected_minor, observed_minor |
TIME_WINDOW | เวลาบนสลิปอยู่นอกหน้าต่างของรายการ | support | window_start, window_end, slip_time |
SENDER_BANK_MISMATCH | ธนาคารผู้โอนไม่ตรงกับที่แจ้ง | end_user | registered_bank_code |
SENDER_ACCOUNT_MISMATCH | เลขบัญชีผู้โอนไม่ตรงกับที่แจ้ง | end_user | registered_account_masked |
SENDER_NAME_MISMATCH | ชื่อผู้โอนไม่ตรงกับที่แจ้ง | end_user | registered_name_masked, matched |
RECEIVER_MISMATCH | โอนไปบัญชีปลายทางผิด | end_user | 🔴 ไม่มี detail เลย |
BANK_DISABLED | ระบบตรวจสลิปของธนาคารนั้นปิดชั่วคราว | support | bank_code (ของผู้โอนเอง) |
DUPLICATE_SLIP | สลิปใบนี้เคยถูกใช้ไปแล้ว | end_user | — |
SLIP_INVALID | ระบบตรวจสลิปตอบว่าสลิปใช้ไม่ได้ | support | — |
SLIP_UNREADABLE | รูปอ่านไม่ออกตั้งแต่ก่อนตรวจ 6 มิติ | end_user | — (มาใน deposit.slip_rejected ไม่ใช่ under_review) |
AMBIGUOUS_MATCH | จับคู่ได้มากกว่า 1 รายการ | support | — |
PROVIDER_UNAVAILABLE | ระบบตรวจสลิปใช้งานไม่ได้ชั่วคราว | support | — |
SENDER_ACCOUNT_UNAVAILABLE | ระบบตรวจสลิปไม่คืนข้อมูลบัญชีผู้โอน | support | — |
LATE_SLIP | สลิปมาถึงหลังรายการหมดอายุ | support | original_expires_at |
CANCELED_ORDER_SLIP | สลิปของรายการที่ถูกยกเลิกไปแล้ว | support | canceled_at |
ALLOCATION_CONFLICT | ยอดส่วนนั้นถูกจับคู่ให้รายการอื่นไปแล้ว | support | dispute_id |
SETTLE_CONFLICT | ขั้นตอนเครดิตค้างหรือล้มเหลวเกิน 15 นาที | support | — |
ROUTE_VERIFICATION_PENDING | ช่องทางสำรองต้องตรวจเพิ่ม | support | — |
LATE_ROUTE_CONFIRMATION | ช่องทางสำรองยืนยันหลังปิดรายการไปแล้ว | support | — |
RISK_HOLD | ระบบตรวจความเสี่ยงกันไว้ | support | — |
NO_SLIP_IMAGE | ผู้ใช้ส่งเลขอ้างอิงจากแอปธนาคารแทนรูปสลิป ⇒ บังคับเข้าคิวเจ้าหน้าที่เสมอ | — | — |
กฎที่ต้องรู้เกี่ยวกับ detail
- 🔴
RECEIVER_MISMATCHไม่มีdetailเด็ดขาด — บัญชีปลายทางเป็นของอีกฝ่าย การบอกว่า "ที่ถูกคือ xxx1234" คือการยืนยันบัญชีของคนอื่นให้คนที่โอนผิดรู้ detailที่มีข้อมูลส่วนบุคคลได้ เฉพาะค่าที่คุณส่งมาเอง ตอนสร้างรายการ (sender.*) และ echo กลับแบบ mask- 🔴 ค่าที่อ่านได้จากสลิปจริงไม่มีวันส่งออก — มีแค่
matched: false
actionable_by เอาไปทำอะไร
| ค่า | แปลว่า | UI ควรเป็น |
|---|---|---|
end_user | ผู้ใช้ทำอะไรบางอย่างได้ (แนบหลักฐานเพิ่ม เปลี่ยนบัญชีผู้โอนครั้งหน้า) | แสดงข้อความ + ทางเลือกที่ทำได้ · 🔴 ยังห้ามมีปุ่ม "โอนใหม่" |
support | รอเจ้าหน้าที่อย่างเดียว ผู้ใช้ทำอะไรไม่ได้ | แสดงข้อความ + เวลาโดยประมาณจาก estimated_resolution_bucket |
code ของการปฏิเสธและการกลับรายการ
rejection.code (ใน deposit.rejected / withdrawal.rejected) เป็น enum เปิดเช่นกัน · มาพร้อม display_th / display_en เสมอ
reversal.reason_code ชุดปัจจุบัน — เปิด เช่นกัน:
BANK_RECALL · ACCOUNT_FROZEN · SLIP_INVALIDATED_AFTER_SETTLEMENT · DUPLICATE_CREDIT · OPS_ERROR · MERCHANT_DISPUTE_LOST
withdrawal.fill_reversed ใช้ได้เฉพาะสามเหตุผล true transfer failure ชุดแรกเท่านั้น ส่วน DUPLICATE_CREDIT · OPS_ERROR · MERCHANT_DISPUTE_LOST เป็น deposit-only correction: ส่ง deposit.reversed + balance.debited แต่ ไม่ส่ง withdrawal.fill_reversed และไม่เปลี่ยนยอดรวม fill หรือ fee ฝั่งถอน
cancel_reason_code ชุดปัจจุบัน — เปิด:
no_liquidity_available · merchant_request · end_user_request · approval_ttl_expired
ขั้นต่อไป
| อ่านต่อ | เมื่อ |
|---|---|
| ลำดับไม่การันตี — version guard | 🔴 ก่อนเขียน switch ตัวแรก |
| ตรวจลายเซ็น Ed25519 | ก่อนเชื่อ payload ใบไหนก็ตาม |
| Retry และ endpoint ล่ม | อยากรู้ว่าตอบ non-2xx แล้วจะเกิดอะไรต่อ |
| สถานะรายการฝาก | ต้องการ map สถานะ → หน้าจอผู้ใช้ |
| รับ fill หลายก้อน | ทำฝั่งถอน |
| พจนานุกรม field | ไม่แน่ใจว่าชื่อ field ไหนคือตัวจริง |
