Webhook ของ P2P
เข้าใจภาพรวมก่อน แล้วเปิดเฉพาะรายละเอียดที่ต้องใช้
1. ภาพรวมที่ต้องรู้
ผู้ฝากโอนเงินเข้าบัญชีผู้ถอนโดยตรง ระบบส่ง webhook แจ้งร้านค้าของแต่ละฝั่ง
ฝั่งฝาก → บันทึกเมื่อชำระสำเร็จ
PAYMENT_PAIDฝั่งถอน → รอ event ปิดรายการ
แจ้งความคืบหน้า ≠ ถอนสำเร็จ
แต่ละร้านได้รับเฉพาะ event ของรายการตัวเอง · ผลถอนอาจสำเร็จเต็มจำนวน สำเร็จบางส่วน หรือถูกปฏิเสธ
ฝั่งถอนได้ webhook 3 ช่วง
ช่วงที่ 1 · ก่อนเริ่ม
ยืนยันคำสั่งถอน
ชื่อ event ที่ร้านได้รับ
WITHDRAWAL_VERIFYเฉพาะร้านที่เปิด verify · ต้องตอบ 2xx ภายใน 10 วินาที
ช่วงที่ 2 · หลายครั้ง
รายงานความคืบหน้า
ชื่อ event ที่ร้านได้รับ
WITHDRAWAL_PARTIALLY_FUNDEDทุกครั้งที่รายการย่อยเปลี่ยน ดู
reason· ยังไม่บันทึกยอดช่วงที่ 3 · 1 ครั้ง
ปิดรายการ
ชื่อ event ที่ร้านได้รับ
WITHDRAWAL_COMPLETEDWITHDRAWAL_COMPLETED_PARTIALLYWITHDRAWAL_REJECTได้อย่างใดอย่างหนึ่ง บันทึกยอดตรงนี้ ดู
outcome.result· มาหลังความคืบหน้าครั้งสุดท้ายเสมอ
ช่วงที่ 2 แยกด้วย reason
สัดส่วนจาก production 24 ชม. (13 ต.ค. 2026)LEG_SETTLED ได้รับเงินรายการย่อยได้รับชำระแล้ว (รวมกรณีผู้ฝากชนะข้อพิพาท)
PLEDGE_EXPIRED ยังไม่ได้เงินผู้ฝากไม่โอนภายในเวลา
SHORTFALL_TO_BANK ยังไม่ได้เงินส่วนที่ยังขาดส่งให้บัญชีบริษัทโอน
PLEDGE_REJECTED ยังไม่ได้เงินผู้ฝากแพ้ข้อพิพาท ยอดกลับเข้ารายการถอน
3
PARTIALLY_FUNDED เฉลี่ยต่อรายการ (สูงสุด 11)
1,348
ปิดด้วย WITHDRAWAL_COMPLETED
3
ปิดด้วย WITHDRAWAL_REJECT
ถอนไม่ได้ครบ ระบบคืนเงิน — ได้ webhook อะไร
ไม่มี event "refund" แยก · การคืนเงินมากับ webhook ปิดรายการจับคู่ได้บางส่วน ส่วนที่เหลือหาผู้ชำระไม่ได้ (ตัวอย่างจริง ถอน 1,000)
WITHDRAWAL_COMPLETED_PARTIALLYลูกค้าได้ 900 จากผู้ฝาก 4 รายการย่อย อีก 100 คืน · outcome.result = PARTIALLY_SETTLED
900
ลูกค้าได้
100
คืนลูกค้า
100.20
คืนเข้าร้าน
ไม่ได้รับชำระเลย (ตัวอย่างจริง ถอน 300)
WITHDRAWAL_REJECTไม่มีเงินถึงลูกค้า คืนเต็มจำนวน · outcome.result = NOT_SETTLED
0
ลูกค้าได้
300
คืนลูกค้า
300.60
คืนเข้าร้าน
- คืนลูกค้าเท่ากับ
summary.withdrawer_pending_refundเท่านั้น —outcome.refunded_amount/agent_refundedคือยอดคืนเข้าร้านซึ่งรวมค่าธรรมเนียม withdrawal.withdrawal_statusเป็นREJECTEDได้แม้ลูกค้าได้เงินไปบางส่วนแล้ว — แยกกรณีด้วยoutcome.result/customer_kept_moneyrefund_reasonใน production 72 ชม. ล่าสุดเป็นUNKNOWN36 จาก 38 ใบ (ที่เหลือSYSTEM_FAILED) — ใช้บันทึก log ได้ แต่อย่าใช้แยกกรณี
ตัวอย่างจริง: ถอน 700 บาท
webhook ที่ร้านได้รับจริง · เวลาไทย · ปิด id ไว้- 09:46
PARTIALLY_FUNDEDLEG_SETTLEDได้ 100 (รายการย่อย 1/3)
100 / 700 - 09:49
PARTIALLY_FUNDEDLEG_SETTLEDได้ 300 (รายการย่อย 3/3)
400 / 700 - 10:00
PARTIALLY_FUNDEDPLEDGE_EXPIREDรายการย่อย 300 ไม่ได้โอน
400 / 700 - 10:19
PARTIALLY_FUNDEDLEG_SETTLEDผู้ฝากรายใหม่ชำระ 100
500 / 700 - 10:45
PARTIALLY_FUNDEDSHORTFALL_TO_BANKปิดรอบ ส่วนที่ยังขาด 100 ส่งให้บัญชีบริษัทโอน
600 / 700 - 12:34
WITHDRAWAL_COMPLETEDFULLY_SETTLEDลูกค้าได้ครบ 700 (P2P 600 + บัญชีบริษัท 100)
700 / 700
ยอดสะสมให้อ่านจาก progress.settled_amount ไม่ใช่นับจำนวนครั้ง — ที่ 10:45 ยอดขยับจาก 500 เป็น 600 ใน webhook SHORTFALL_TO_BANK โดยไม่มี LEG_SETTLED แยก · บันทึกยอดเมื่อได้ WITHDRAWAL_COMPLETED เท่านั้น
2. ดูจากตัวอย่าง
Matched exactly, one leg
A single depositor for the exact amount. Done in 3 minutes.
Deposit merchant
Receives 1 events
Withdrawal merchant
Receives 2 events
Counts cover only this example, excluding retries. Each merchant receives events for its own orders. Delivery order may vary.
Events and recipients
- 1Deposit merchantRecord the deposit
PAYMENT_PAIDSlip clears; the deposit order is credited
the deposit side is paid here
- 2Withdrawal merchantUpdate progress only
WITHDRAWAL_PARTIALLY_FUNDEDAnnounces that this leg settled
funding_status: FULLY_SETTLED — do not post from this one
- 3Withdrawal merchantClose the withdrawal
WITHDRAWAL_COMPLETEDCloses the withdrawal
channel: P2P_PURE — post here
The deposit merchant records PAYMENT_PAID. The withdrawal merchant updates progress from PARTIALLY_FUNDED and closes the order only on WITHDRAWAL_COMPLETED, even if progress already reads FULLY_SETTLED.
Show all steps, including those without webhooks
The total below tracks confirmed settlement in this example.
- 1.Merchant
Create a withdrawal for 500
The record waits open with no legs yet
No webhook at this step
Confirmed 0 / 500 THB
- 2.Platform
A depositor arrives; leg 1/1 opens for 500
The withdrawer account and a QR are revealed; the 15-minute clock starts
No webhook at this step
Confirmed 0 / 500 THB
- 3.Depositor
Transfers 500 straight to the customer, then attaches the slip
The money never touches the central account
No webhook at this step
Confirmed 0 / 500 THB
- 4.Platform
Slip clears; the deposit order is credited
PAYMENT_PAIDConfirmed 500 / 500 THB
- 5.Platform
Announces that this leg settled
WITHDRAWAL_PARTIALLY_FUNDEDConfirmed 500 / 500 THB
- 6.Platform
Closes the withdrawal
WITHDRAWAL_COMPLETEDConfirmed 500 / 500 THB
3. คู่มือสำหรับนักพัฒนา
รู้ว่าเมื่อไรควรปิดรายการถอน
WITHDRAWAL_PARTIALLY_FUNDED ใช้แจ้งความคืบหน้าเท่านั้น แม้ยอดจะครบแล้ว ให้รอ event ปิดรายการด้านล่าง และอ่าน outcome.result ประกอบ
ผลถอน 3 แบบ และกรณีที่ยังปิดรายการไม่ได้
| event | outcome.result | สาเหตุ | ผลต่อลูกค้า |
|---|---|---|---|
WITHDRAWAL_COMPLETED | FULLY_SETTLED | ผู้ฝากชำระครบ หรือบัญชีบริษัทโอนส่วนที่ขาดให้ (ตรวจสอบได้จาก summary.channel: P2P_PURE / HYBRID) | ได้ครบตามที่ขอ |
WITHDRAWAL_COMPLETED_PARTIALLY | PARTIALLY_SETTLED | ได้รับชำระบางรายการย่อย ส่วนที่เหลือไม่มีผู้ฝากและบัญชีบริษัทไม่ได้โอนแทน หรือบางรายการย่อยแพ้ข้อพิพาท | ได้รับบางส่วน ส่วนที่เหลือคืนร้าน — ลูกค้าได้รับเงินไปแล้ว ไม่ใช่การถอนที่ไม่สำเร็จ |
WITHDRAWAL_REJECT | NOT_SETTLED | ไม่มีรายการย่อยใดได้รับชำระ — หมดเวลาและไม่มีช่องทางสำรอง ลูกค้ายกเลิกเอง หรือบัญชีปลายทางใช้งานไม่ได้ | ไม่ได้รับเงิน คืนเต็มจำนวน |
ไม่มี event | — | ธนาคารตีกลับ รายการค้างสถานะ FREEZED — ต้องตรวจสอบสถานะผ่าน API GET /v1/withdrawal/detail/:id | เงินไม่ถึงลูกค้า และยังไม่คืนร้าน |
คืนยอดให้ถูกฝั่ง: ยอดที่ต้องคืนลูกค้าใช้ summary.withdrawer_pending_refund ส่วน agent_refunded กับ outcome.refunded_amount คือยอดที่คืนเข้าร้าน ซึ่งรวมค่าธรรมเนียมที่คืนด้วย จึงมากกว่าหรือเท่ากับยอดของลูกค้าเสมอ (เท่ากันเมื่อค่าธรรมเนียมถอนเป็น 0 เช่นถอนเข้าพร้อมเพย์) — หากใช้ผิดช่องจะคืนเงินลูกค้าเกิน
สำเร็จบางส่วน ไม่ใช่ล้มเหลวทั้งรายการ: PARTIALLY_SETTLED หมายถึงลูกค้าได้รับเงินไปแล้วบางส่วน หากตีความว่าถอนไม่สำเร็จแล้วคืนเต็มจำนวน จะเป็นการจ่ายซ้ำ
ข้อควรระวัง: สถานะ รายการย่อย และ webhook ซ้ำ
- ใน
PARTIALLY_FUNDEDอย่าดูwithdrawal.withdrawal_status(บางครั้งแสดงCOMPLETEDไปแล้ว) ให้ดูprogress.funding_statusแทน - จำนวนรายการย่อย (
split_total) เพิ่มขึ้นได้ระหว่างดำเนินการ เช่นจาก 1/1 เป็น 1/2 เมื่อมีผู้ฝากเข้ามาเพิ่ม อย่าใช้ค่าเดิมในการคำนวณ - รายการย่อยเดียวอาจได้รับ
PARTIALLY_FUNDEDมากกว่า 1 ครั้ง (เช่นได้รับชำระแล้ว ภายหลังแพ้ข้อพิพาท) แยกด้วยreasonและกันซ้ำด้วยx-idempotency-keyซึ่งรวม reason ไว้แล้ว — ข้อพิพาทที่ตัดสินแล้วก็มี webhook (ชนะ =LEG_SETTLED, แพ้ =PLEDGE_REJECTED)
ทบทวนขั้นตอนฝากและถอนทั้งหมด
ฝาก
- 1
ลูกค้าทำรายการฝาก ระบบจับคู่กับรายการถอนที่ยังรอรับชำระ
- 2
ระบบแสดงเลขบัญชีผู้ถอนพร้อม QR ลูกค้ามีเวลาโอน 15 นาที
- 3
ลูกค้าโอนเข้าบัญชีนั้น แล้วแนบสลิป
- 4
สลิปผ่านการตรวจสอบ ร้านได้รับ
PAYMENT_PAID
ฝั่งฝากมี webhook เพียง PAYMENT_PAID ขั้นตอนอื่น (จับคู่ / รอโอน / ตรวจสลิป / หมดเวลา / ข้อพิพาท) ไม่มี webhook ต้องตรวจสอบสถานะผ่าน API ยกเว้นสลิปซ้ำจะได้รับ PAYMENT_FAILED
ถอน
- 1
ลูกค้าขอถอน 1,000
ขั้นนี้ระบบยังไม่แบ่งยอด เพียงเปิดรายการรอไว้
- 2
ผู้ฝากเข้ามาทีละราย ระบบสร้างรายการย่อยให้ทีละรายการ
จำนวนรายการย่อยขึ้นกับจำนวนผู้ฝากที่เข้ามา ไม่ได้แบ่งไว้ล่วงหน้า และผู้ฝากอาจเป็นลูกค้าของร้านอื่น
- 3
ผู้ฝากโอนเข้าบัญชีลูกค้าโดยตรง
- 4
ทุกครั้งที่มีความคืบหน้า ร้านได้รับ
WITHDRAWAL_PARTIALLY_FUNDEDโดยระบุเหตุการณ์ในช่องreasonLEG_SETTLED = รายการย่อยได้รับชำระแล้ว · PLEDGE_EXPIRED = ผู้ฝากไม่โอนภายในเวลา · PLEDGE_REJECTED = ผู้ฝากแพ้ข้อพิพาท · SHORTFALL_TO_BANK = ส่วนที่ยังขาดจะโอนจากบัญชีบริษัท — ใช้แสดงความคืบหน้าเท่านั้น ยังไม่ต้องบันทึกยอด
- 5
หากหมดเวลาแล้วยังได้รับชำระไม่ครบ บัญชีบริษัทจะโอนส่วนที่เหลือ แล้วร้านได้รับ webhook ปิดรายการ 1 ครั้ง
บันทึกยอดที่ขั้นนี้ — จะได้รับ COMPLETED, COMPLETED_PARTIALLY หรือ REJECT อย่างใดอย่างหนึ่ง
ยอดที่บัญชีบริษัทโอนแทนจะหักจากเครดิต THB_P2P ของร้าน แต่ payload ไม่ระบุยอดนี้ ต้องตรวจสอบจาก balance history
ได้รับ event แล้วต้องทำอะไร
| event | การดำเนินการ | คีย์ที่ใช้ |
|---|---|---|
PAYMENT_PAID | เพิ่มเครดิตให้รายการฝากตาม data.payment.order_id ด้วยยอด data.payment.payment_amount (ไม่ใช่ payment.amount ซึ่งเป็นยอดออเดอร์) — ถ้าแอดมินปิดตามยอดที่โอนจริงจะมีบล็อก data.amount_adjustment · ดำเนินการครั้งเดียวต่อ payment ห้ามใช้ deposit_type เป็นเงื่อนไข | payment.id |
PAYMENT_FAILED | ปิดรายการฝากเป็นไม่สำเร็จ — เกิดเพียงกรณีเดียวคือสลิปซ้ำ | payment.id |
WITHDRAWAL_PARTIALLY_FUNDED | ใช้อัปเดตความคืบหน้าบนหน้าจอเท่านั้น ยังไม่ต้องบันทึกยอด — ตรวจ reason ก่อน ไม่ใช่ทุกครั้งที่หมายถึงได้รับเงิน | match id |
WITHDRAWAL_COMPLETED | ปิดรายการถอนและบันทึกยอดตาม outcome.delivered_amount — ไม่ใช่ withdrawal.amount หรือ summary.withdrawer_received (ฟิลด์นี้อาจนับรายการย่อยที่จับคู่แล้วแต่ยังไม่ได้ชำระ) | withdrawal.id |
WITHDRAWAL_COMPLETED_PARTIALLY | ลูกค้าได้รับเงินไปแล้วบางส่วน — บันทึกยอดตาม outcome.delivered_amount และคืนลูกค้าเฉพาะ summary.withdrawer_pending_refund | withdrawal.id |
WITHDRAWAL_REJECT | ลูกค้าไม่ได้รับเงิน คืนเต็มจำนวนตาม summary.withdrawer_pending_refund (สะกดไม่มี -ED) | withdrawal.id |
PAYMENT_CANCELED | ยกเลิกรายการฝากตาม data.payment.id — ฝั่ง P2P ส่งเฉพาะยกเลิกจริง (ผู้ฝากยกเลิกหรือแอดมินปล่อยรายการ) ฝากที่หมดอายุไม่ส่ง webhook (ตั้งแต่ 13 ต.ค. 2026) ต้องตรวจสอบสถานะผ่าน API · การสร้างฝาก P2P จะยกเลิกรายการฝากธนาคารที่ค้างของผู้ชำระคนเดิม (ได้ PAYMENT_CANCELED ของรายการเก่า อย่าเดาว่าเป็นรายการล่าสุด) แต่ไม่ยกเลิกฝาก P2P ที่ค้าง — ฝาก P2P รายการที่สองถูกปฏิเสธด้วย 38078 | payment.id |
PAYMENT_CONVERTED | ส่งตามหลัง PAYMENT_PAID เมื่อเปิด auto-convert — อัปเดตสกุลเงินที่ settle จริง คนละ system_id ต้อง join กลับด้วย data.payment.id | convert:<id> |
WITHDRAWAL_APPROVED / _EXPIRED | เกิดกับ CRYPTO เท่านั้น — ฝั่ง FIAT/P2P ไม่ต้องรอ event ทั้งสองนี้ | withdrawal.id |
WITHDRAWAL_VERIFY | synchronous — ส่งมาระหว่างที่ร้านเรียก API สร้างรายการถอน ต้องตอบ 2xx ใน 10 วินาที ห้ามเข้าคิวเดียวกับ event อื่น | — (ยังไม่มี id) |
default | event อื่น (classic, crypto, TRANSACTION_NEW) — บันทึกไว้แล้วตอบ 200 อย่าตอบ error เพราะระบบจะถือว่าส่งไม่สำเร็จและไม่มีการส่งซ้ำ | — |
จำนวน webhook และลำดับเหตุการณ์จาก testnet
จำนวนด้านล่างนับเหตุการณ์ในตัวอย่าง ไม่รวมการส่งซ้ำ ส่วนบันทึกจาก testnet ใช้ประกอบการอ่านและไม่ได้แสดงทุก event ที่ระบบรองรับ
ฝั่งฝาก — ได้ webhook กี่ครั้ง
| ชำระผ่าน P2P สำเร็จ | 1 | PAYMENT_PAID (deposit_type: P2P) |
| ย้ายไปช่องทางธนาคาร | 1 | PAYMENT_PAID (deposit_type: CLASSIC) |
| สลิปซ้ำ | 1 | PAYMENT_FAILED (DUPLICATE_SIGNATURE) |
| ยกเลิกจริง (ผู้ฝากยกเลิก / แอดมินปล่อยรายการ) | 1 | PAYMENT_CANCELED |
| หมดอายุ / เปิดข้อพิพาท | 0 | — ตรวจสอบสถานะผ่าน API (หมดอายุไม่ส่ง webhook ตั้งแต่ 13 ต.ค. 2026) |
PAYMENT_PAID ของ P2P มีบล็อก transaction แล้ว — ยอดสุทธิหลังหักค่าธรรมเนียมอยู่ที่ transaction.realized_amount (parser ควรรองรับกรณีที่ไม่มีบล็อกนี้ไว้ด้วย)
ตัวอย่างลำดับเหตุการณ์ทั้งสองฝั่งจาก testnet
| 12:12 | สร้างรายการถอน 1,000 | ไม่มี |
| 12:13 | รายการย่อยที่ 1 จับคู่ผู้ฝาก 400 | ไม่มี |
| 12:13 | รายการย่อยที่ 1 ชำระครบ — ยังขาดอีก 600 | WITHDRAWAL_PARTIALLY_FUNDED |
| 12:16 | รายการย่อยที่ 2 จับคู่ผู้ฝาก 300 | ไม่มี |
| 12:16 | ผู้ฝากรายการย่อยที่ 2 เปิดข้อพิพาท | ไม่มี |
| 14:38 | แอดมินตัดสินให้ผู้ฝาก | PAYMENT_PAID |
| 14:38 | ปิดช่วงเวลาจับคู่ ส่งยอดคงเหลือ 300 ให้บัญชีบริษัท | ไม่มี |
| 14:39 | บัญชีบริษัทโอน 300 สำเร็จ → ครบยอด | WITHDRAWAL_COMPLETED |
ดูตัวอย่าง JSON ที่จะได้รับ
เลือกฝั่งฝากหรือถอน แล้วเลือก event ที่ต้องการ ชื่อฟิลด์ในตัวอย่างใช้ตรงกับ payload จริง
Choose an event
{
"event": "PAYMENT_PAID",
"data": {
"deposit_type": "P2P",
"payment": {
"id": "8683af40-9186-4871-a8b7-0d04fff4c4ae",
"seq_num": 4478,
"source_id": "wpayz",
"slip_ref": null,
"order_id": "744352fd-8271-49b3-bf5a-500e8e678ef0",
"order_user_reference": "USER134",
"order_auto_cancel_previous": true,
"order_display_mode": "FIAT",
"request_ip": null,
"request_platform": "API",
"payment_method_type": "P2P",
"invoice_type": "FIAT",
"from_currency": "THB",
"to_currency": "THB",
"address": "0641236101",
"from_address": "0000000000",
"amount": "500",
"payment_amount": 500,
"payer_paid_currency": "THB",
"payer_paid_amount": 500,
"payer_bank_provider": "TEST",
"payer_bank_account_number": "0000000000",
"payer_bank_account_name": "TEST SENDER",
"merchant_promptpay_id": null,
"merchant_amount": 500,
"merchant_number": "0641236101",
"merchant_provider": "PromptPay",
"ebank_account_id": null,
"is_multiple_order": true,
"lifetime": 600,
"expired_at": "2026-08-09T05:12:06.210Z",
"fee_subtract": 0,
"discount_percent": 0,
"discount_amount": 0,
"accuracy_percent": 0,
"additional_data": null,
"audit_trail": [],
"order_tracking_id": "AQMW3TH0",
"payment_url": null,
"payment_qr": null,
"payment_domain": null,
"url_return": "",
"url_success": "",
"url_failed": "",
"exchange_rate": 0.02992803,
"exchange_rate_source": "COINGECKO",
"tx_value": 0,
"fx_rate": null,
"payment_status": "PAYMENT_PAID",
"payment_match_type": "EXACTLY",
"is_completed": false,
"status": "COMPLETED",
"failed_reason": "UNKNOWN",
"audit_by_user_id": null,
"audit_time": "2026-08-09T05:05:27.781Z",
"sequence_time": "2026-08-09T05:02:06.226Z",
"created_at": "2026-08-09T05:02:06.226Z",
"updated_at": "2026-08-09T05:05:27.833Z",
"request_withdrawal_id": "22266595-2de3-4532-baa4-4069adfc682b",
"request_withdrawal_sequence_time": "2026-08-09T04:59:46.488Z",
"organization_id": "5f5a3ae8-6fa4-47fd-bb4f-231935a32eb8",
"agent_id": "b1fe1b91-5054-49cc-90f4-f280ab88c9f4",
"partner_agent_id": null
},
"transaction": {
"id": "3f1c9a52-7d0e-4b8a-9e61-2a5b8c4d7f10",
"type": "RECEIVE",
"status": "COMPLETED",
"currency": "THB",
"amount": 500,
"fee": 2,
"fee_amount": 10,
"realized_amount": 490,
"settlement_channel": "P2P",
"from_bank": "KBANK",
"from_name": "TEST SENDER",
"from_address": "xxxxx1234x",
"to_bank": "KBANK",
"reference": "016221120512BTF01234",
"payment_id": "8683af40-9186-4871-a8b7-0d04fff4c4ae",
"p2p_match_id": "6b0c2d4e-1f3a-4c5b-8d7e-9a0b1c2d3e4f",
"metadata": {
"source": "P2P_MATCH",
"match_id": "6b0c2d4e-1f3a-4c5b-8d7e-9a0b1c2d3e4f",
"withdrawal_id": "22266595-2de3-4532-baa4-4069adfc682b"
},
"order_id": "744352fd-8271-49b3-bf5a-500e8e678ef0",
"tx_date": "2026-08-09T05:05:27.781Z"
},
"idempotency_key": "8683af40-9186-4871-a8b7-0d04fff4c4ae:PAYMENT_PAID"
},
"type": "FIAT",
"request_id": "req_1786251927914_reyfnjurpoc"
}อ่าน HTTP header
ชื่อ HTTP header ไม่แยกตัวพิมพ์เล็กและใหญ่ ให้อ่านแบบ case-insensitive
| header | ตัวอย่างค่า | ใช้ทำอะไร |
|---|---|---|
trust-x-event | WITHDRAWAL_COMPLETED | route ได้ก่อน parse JSON — ตรงกับ body.event |
trust-x-request-id | req_1786251928335_l7t7uvlvuvr | ตรงกับ request_id ใน body ใช้แจ้งทีมซัพพอร์ตเมื่อต้องการตรวจสอบ webhook รายการใดรายการหนึ่ง |
x-idempotency-key | 22266595-…:WITHDRAWAL_COMPLETED | คีย์เดียวที่ใช้ dedup ได้ ตรงกับ data.idempotency_key |
x-signature | sha256=19c38d2a… | HMAC-SHA256 ของ {timestamp}.{JSON ของ data} มี prefix sha256= |
x-timestamp | 1786251928 | หน่วยวินาที ใช้ทั้งตรวจอายุของคำขอและเป็นส่วนหนึ่งของลายเซ็น |
user-agent | axios/1.13.2 | ไม่ใช้ในการตรวจสอบ บันทึกไว้เพื่ออ้างอิงเท่านั้น |
x-timestamp ของ webhook เป็น วินาที (10 หลัก) ส่วน x-timestamp ตอนที่เราเรียก API ของระบบเป็นมิลลิวินาที (13 หลัก) — หากใช้หน่วยผิด การตรวจ signature จะไม่ผ่านทุกครั้ง
เขียนโค้ดรับ webhook
endpoint เดียวรับทั้งฝากและถอน — ระบบกำหนด URL ได้เพียงหนึ่งรายการต่อ agent จึงแยก endpoint ตามประเภทไม่ได้ ให้ route ด้วย body.event · ขั้นตอนการรับเหมือนกันทุก event ต่างกันเฉพาะขั้นสุดท้ายใน worker
app.post('/webhooks/p2p',
express.raw({ type: 'application/json' }), // ต้องเก็บ raw body ไว้
async (req, res) => {
const raw = req.body.toString('utf8')
const body = JSON.parse(raw)
// 1) timestamp ก่อน ถูกกว่าคำนวณ HMAC
// x-timestamp ของ webhook เป็น "วินาที" (10 หลัก) ไม่ใช่มิลลิวินาที
// คนละหน่วยกับ x-timestamp ตอนเรียก API ของระบบ
const ts = req.header('x-timestamp') ?? ''
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(401)
}
// 2) ลายเซ็นครอบเฉพาะ object data
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${ts}.${JSON.stringify(body.data)}`)
.digest('hex')
const got = req.header('x-signature') ?? ''
if (got.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
logger.warn({ event: body.event }, 'bad signature')
return res.sendStatus(401)
}
// 3) dedup ที่ x-idempotency-key เท่านั้น (unique index บนคอลัมน์นี้)
const key = req.header('x-idempotency-key')
const { rowCount } = await db.query(
`INSERT INTO webhook_inbox (idempotency_key, event, payload)
VALUES ($1, $2, $3) ON CONFLICT (idempotency_key) DO NOTHING`,
[key, body.event, raw],
)
if (rowCount === 0) return res.sendStatus(200) // เคยรับแล้ว ไม่ทำซ้ำ
// 4) ตอบก่อน แล้วค่อยประมวลผล
await queue.add('p2p-webhook', { key })
return res.sendStatus(200)
})// ยึด event ปลายทางเสมอ — webhook ไม่รับประกันลำดับ
const RANK = {
WITHDRAWAL_PARTIALLY_FUNDED: 1,
PAYMENT_PAID: 2,
PAYMENT_FAILED: 2,
WITHDRAWAL_COMPLETED: 3,
WITHDRAWAL_COMPLETED_PARTIALLY: 3,
WITHDRAWAL_REJECT: 3,
}
async function handle({ key }) {
const { event, payload } = await inbox.get(key)
const data = JSON.parse(payload).data
await db.tx(async (tx) => {
const order = await tx.selectForUpdate(orderIdOf(data)) // row lock ต่อ 1 รายการ
if (RANK[event] < RANK[order.last_event ?? '']) return // event เก่าที่มาช้า → ข้าม
switch (event) {
case 'PAYMENT_PAID':
await tx.credit(order, data.payment.payment_amount); break // ยอดที่เครดิตจริง ไม่ใช่ payment.amount
case 'WITHDRAWAL_PARTIALLY_FUNDED':
await tx.updateProgress(order, data.progress); break // แสดงผลอย่างเดียว
// event ปลายทางทั้งสามตัวใช้ทางเดียวกัน แยกด้วย outcome.result ไม่ใช่ชื่อ event
case 'WITHDRAWAL_COMPLETED':
case 'WITHDRAWAL_COMPLETED_PARTIALLY':
case 'WITHDRAWAL_REJECT':
await tx.settle(order, data.outcome.delivered_amount) // 0 ได้
await tx.refundCustomer(order, data.summary.withdrawer_pending_refund ?? 0)
break // ห้ามใช้ agent_refunded ตรงนี้
}
await tx.setLastEvent(order, event)
})
}- ตาราง dedup ใช้ตารางเดียว — คีย์เป็น
<system_id>:<EVENT>จึงไม่ชนกันข้ามฝากถอนอยู่แล้ว - row lock ต้องล็อกคนละ entity — event ฝากล็อกรายการฝาก event ถอนล็อกรายการถอน อย่าใช้ล็อกร่วมกันเพราะจะบล็อกกันเองโดยไม่จำเป็น
dataมีโครงต่างกันต่อ event (payment/withdrawal/matches/progress) — parser ต้องรองรับกรณีที่ไม่มีบล็อกใดบล็อกหนึ่ง- endpoint นี้ยังรับ event ของช่องทางอื่นด้วย (classic, crypto) —
defaultของ switch ควรบันทึกไว้แล้วตอบ 200 ไม่ใช่ตอบ error - ข้อยกเว้นเดียว:
WITHDRAWAL_VERIFYเป็น synchronous ต้องตอบผลในคำขอนั้นเลย ห้ามเข้าคิวเดียวกัน — ดู Withdraw verify
- ส่งครั้งเดียว ไม่มีการส่งซ้ำอัตโนมัติ — ต้องมี job กระทบยอดที่ตรวจสอบสถานะผ่าน API ตามตารางด้านบน
- webhook ปิดรายการจะมาหลัง
PARTIALLY_FUNDEDของรายการย่อยสุดท้ายเสมอ แต่ event อื่น (เช่นPAYMENT_PAID) อาจมาสลับลำดับ — หากสลับ ให้ยึด webhook ปิดรายการ - ขอส่งซ้ำได้ที่
POST /v1/webhookRequest/resend/:id— ระบบข้าม dedup และใช้คีย์เดิม handler จึงต้อง idempotent - ทดสอบด้วย
POST /v1/p2p/acceptForTesting/:idบน testnet (ใส่ match id หรือ payment id)
ถ้าไม่ได้รับ webhook ให้ตรวจที่ไหน
เหตุการณ์เหล่านี้ไม่มี webhook ให้ตรวจสอบสถานะผ่าน API ตามตาราง
| สถานการณ์ | วิธีตรวจสอบ |
|---|---|
| สลิปอยู่ระหว่างตรวจ (PAYMENT_CHECKING) | อาจใช้เวลาหลายนาที — GET /v1/payment/info |
| สร้างรายการสำเร็จ | บันทึกจาก response ตอนสร้างรายการ (รวม order_tracking_id ซึ่งค้นย้อนหลังไม่ได้) |
| จับคู่ได้แล้ว / คิวหมดอายุ | GET /v1/p2p/public/flow/:flowId |
| รายการฝากหมดอายุ (P2P และฝากธนาคาร) | GET /v1/payment/info หลังเลย expired_at — payment_status เป็น PAYMENT_EXPIRED |
| สลิปถูกปฏิเสธ | HTTP error ที่ API แนบสลิปตอบกลับ |
| เปิดข้อพิพาท / รอแอดมินตัดสิน | GET /v1/p2p/match/:matchId → match_status — ผลการตัดสินมี webhook แต่การเปิดข้อพิพาทไม่มี |
| ถอนถูกธนาคารตีกลับ (FREEZED) | GET /v1/withdrawal/detail/:id |
| คืนเงินส่วนที่ขาด | balance history |
สถานะของการจับคู่
สถานะของการจับคู่ P2P
match_status ตรวจสถานะ: GET /v1/p2p/match/:matchIdWAITING_SLIPเปิดเผยบัญชีปลายทางแล้ว รอผู้ฝากโอนและแนบสลิป
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
DISPUTEDผู้ฝากเปิดข้อพิพาท — ล็อกไว้จนกว่าแอดมินจะตัดสิน ไม่หมดอายุเอง
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
COMPLETEDสลิปผ่าน เพิ่มเครดิตให้รายการฝาก
webhook: PAYMENT_PAID
DISPUTE_RESOLVEDแอดมินตัดสินแล้ว — ผลไปคนละฝั่งตามคำตัดสิน
webhook
EXPIREDหมดเวลาโอน รายการย่อยถูกปล่อยกลับเข้าคิว
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
CANCELLEDผู้ฝากยกเลิกก่อนเห็นบัญชีปลายทาง
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
CANCELLED_HELDยกเลิกขณะยังกันยอดไว้
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
RELEASEDปล่อยยอดที่กันไว้กลับคืน
ไม่มี webhook — ต้องตรวจสอบสถานะผ่าน API
มีเพียง COMPLETED และ DISPUTE_RESOLVED ที่ส่ง webhook — สถานะปลายทางอื่นไม่มี webhook แจ้ง
เมื่อมีข้อพิพาท (dispute)
แอดมินเป็นผู้ตัดสินเท่านั้น ไม่มีการตัดสินอัตโนมัติ รายการจะค้างอยู่จนกว่าแอดมินจะตัดสิน — ผลการตัดสินแต่ละแบบส่ง webhook ไปคนละฝั่ง
P2P_INVALID_STATUS_TRANSITION (38023) - ยอดที่ร้านผู้ฝากได้คือยอด payment หักค่าธรรมเนียมฝากตามปกติ
- ทั้งสองทางย้อนกลับไม่ได้ — ตัดสินซ้ำจะได้
P2P_INVALID_STATUS_TRANSITION(38023)
เมื่อตัดสินให้ผู้ฝาก ฝั่งถอนได้ WITHDRAWAL_PARTIALLY_FUNDED (reason LEG_SETTLED) เมื่อตัดสินให้ผู้ถอน ได้ reason PLEDGE_REJECTED และยอดนั้นกลับเข้ารายการถอน ถ้ายังขาดยอด บัญชีบริษัทโอนส่วนที่เหลือตามปกติ และการตัดสินย้อนกลับไม่ได้
