Webhook ของ P2P
flow ทั้งสองฝั่ง payload จริงทุก event การตรวจลายเซ็น และกับดักที่พลาดแล้วเสียเงิน
Use Case
Matched exactly, one leg
A single depositor for the exact amount. Done in 3 minutes.
You receive 3 webhooks, in this order
- 1
PAYMENT_PAIDthe deposit side is paid here - 2
WITHDRAWAL_PARTIALLY_FUNDEDfunding_status: FULLY_SETTLED — do not post from this one - 3
WITHDRAWAL_COMPLETEDchannel: P2P_PURE — post here
Post to your ledger from the third webhook only. The second is a progress report even though it reads FULLY_SETTLED.
- 1MerchantCreate a withdrawal for 500
The record waits open with no legs yet
no webhook - 2PlatformA depositor arrives; leg 1/1 opens for 500
The withdrawer account and a QR are revealed; the 10-minute clock starts
no webhook - 3DepositorTransfers 500 straight to the customer, then attaches the slip
The money never touches the central account
no webhook - 4PlatformSlip clears; the deposit order is credited
PAYMENT_PAIDthe deposit side is paid here - 5PlatformAnnounces that this leg settled
WITHDRAWAL_PARTIALLY_FUNDEDfunding_status: FULLY_SETTLED — do not post from this one - 6PlatformCloses the withdrawal
WITHDRAWAL_COMPLETEDchannel: P2P_PURE — post here
P2P ทำงานยังไง
ผู้ฝากโอนตรงเข้าบัญชีผู้ถอน — เงินไม่ผ่านบัญชีกลาง โอนครั้งเดียวจบทั้งรายการฝากและรายการถอน
ฝาก
- 1
ลูกค้าขอฝาก → ระบบจับคู่กับรายการถอนที่ยังขาดยอด
- 2
เปิดเลขบัญชีผู้ถอนและ QR ให้ลูกค้า มีเวลาโอน 10 นาที
- 3
ลูกค้าโอนเข้าบัญชีนั้นแล้วแนบสลิป
- 4
ตรวจสลิปผ่าน →
PAYMENT_PAID
มี webhook ตัวเดียวคือ PAYMENT_PAID — ระหว่างทาง (จับคู่ / รอโอน / ตรวจสลิป / หมดอายุ / ข้อพิพาท) เงียบทั้งหมด ต้อง poll เอา ยกเว้นสลิปซ้ำที่ได้ PAYMENT_FAILED
ถอน
- 1
ลูกค้าขอถอน 1,000
ยังไม่ถูกตัดขาตอนนี้ — รายการเปิดรอไว้เฉย ๆ
- 2
ผู้ฝากทยอยเข้ามา ระบบเปิดขาใหม่ให้ทีละราย
ขาถูกเพิ่มตามที่มีคนมา ไม่ได้ตัดล่วงหน้า และไม่จำเป็นต้องเป็นรายการฝากที่ร้านสร้างคู่กัน
- 3
ผู้ฝากโอนเข้าบัญชีลูกค้าโดยตรง
- 4
ทุกขาที่สำเร็จได้
WITHDRAWAL_PARTIALLY_FUNDEDห้ามลงบัญชี — ไม่มีบล็อก summary โดยเจตนา ดูว่าครบยอดหรือยังจาก progress.funding_status
- 5
หมดเวลาแล้วยังขาด บัญชีกลางโอนส่วนที่เหลือ แล้วจบด้วย event ปลายทาง 1 ใบ
ลงบัญชีตรงนี้ — COMPLETED / COMPLETED_PARTIALLY / REJECT อย่างใดอย่างหนึ่ง
ยอดที่บัญชีกลางออกให้ถูกหักจากเครดิต THB_P2P ของร้าน แต่ payload ไม่ได้บอกว่าจ่ายไปเท่าไร ต้องดูจาก balance history
รายการถอนจบได้ 3 แบบ — ดูที่ outcome.result
| event | outcome.result | เกิดจากอะไร | ผลกับลูกค้า |
|---|---|---|---|
WITHDRAWAL_COMPLETED | FULLY_SETTLED | ผู้ฝากเติมครบทุกขา หรือ บัญชีกลางโอนส่วนที่ขาดให้ — สองทางนี้จบเหมือนกัน แยกจาก payload ไม่ได้ ต่างกันแค่ summary.channel เป็น P2P_PURE หรือ HYBRID | ได้ครบตามที่ขอ |
WITHDRAWAL_COMPLETED_PARTIALLY | PARTIALLY_SETTLED | บางขาสำเร็จ ส่วนที่เหลือหาคนเติมไม่ได้และบัญชีกลางไม่ได้โปะ (SPLIT_PARTIAL_UNCOVERED) หรือแพ้ข้อพิพาทบางขา (DISPUTE_LOST) | ได้บางส่วน ที่เหลือคืน — เงินถึงลูกค้าไปแล้ว อย่าอ่านเป็นถอนไม่สำเร็จ |
WITHDRAWAL_REJECT | NOT_SETTLED | ไม่มีขาไหนสำเร็จเลย — หมดเวลาจับคู่และไม่มีช่องทางสำรอง (MATCH_EXPIRED_NO_FALLBACK) ยกเลิกเอง (WITHDRAWER_CANCELLED) หรือบัญชีปลายทางใช้ไม่ได้ (INVALID_BANK_ACCOUNT) | ไม่ได้เลย คืนเต็มจำนวน |
ไม่มี event | — | ธนาคารตีกลับ รายการค้างเป็น FREEZED — ระบบถือว่ายังไม่จบ จึงไม่ประกาศอะไรเลย ต้อง poll GET /v1/withdrawal/detail/:id | เงินไม่ถึงลูกค้าและยังไม่คืนร้าน |
ยอดคืนลูกค้าคือ summary.withdrawer_pending_refund ส่วน agent_refunded กับ outcome.refunded_amount คือยอดที่คืนเข้าร้าน ซึ่งรวมค่าธรรมเนียมที่ได้คืนตามสัดส่วนไว้ด้วย จึงมากกว่าเสมอ — หยิบผิดตัวไปคืนลูกค้าคือคืนเกิน
อย่าแยกด้วยชื่อ event — PARTIALLY_SETTLED ที่อ่านเป็น "ถอนไม่สำเร็จ" แล้วคืนเงินเต็ม จะกลายเป็นจ่ายซ้ำ
ในใบ PARTIALLY_FUNDED อย่าอ่าน withdrawal.withdrawal_status — บางใบเป็น COMPLETED แล้วเพราะประกอบจากแถวหลัง settle ให้ดู progress.funding_status เท่านั้น
split_total เพิ่มได้ระหว่างทาง — ขาที่เคยเป็น 1/1 กลายเป็น 1/2 เมื่อมีขาใหม่เข้ามา อย่าเก็บค่าเก่าไปคำนวณ
ขาที่จบด้วยการตัดสินข้อพิพาท ไม่ส่ง PARTIALLY_FUNDED ต่างจากขาที่ settle ปกติ — ฝั่งถอนจะเงียบจนกว่ารายการจะจบ
ฝั่งฝาก — ได้กี่ใบ
| จับคู่ P2P สำเร็จ | 1 | PAYMENT_PAID (deposit_type: P2P) |
| ตกไปช่องธนาคาร | 1 | PAYMENT_PAID (deposit_type: CLASSIC) |
| สลิปซ้ำ | 1 | PAYMENT_FAILED (DUPLICATE_SIGNATURE) |
| หมดอายุ / ยกเลิก / ข้อพิพาท | 0 | — ต้อง poll |
payload ฝั่ง P2P ไม่มีบล็อก transaction — parser ต้องถือว่าเป็น optional
ฝั่งถอน — ไทม์ไลน์จริงจาก 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 | ปิดหน้าต่าง split เหลือ 300 ให้บัญชีกลาง | ไม่มี |
| 14:39 | บัญชีกลางโอน 300 สำเร็จ → ครบยอด | WITHDRAWAL_COMPLETED |
Event Types
{
"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_name": "SOMCHAI JAIDEE",
"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
},
"idempotency_key": "8683af40-9186-4871-a8b7-0d04fff4c4ae:PAYMENT_PAID"
},
"type": "FIAT",
"request_id": "req_1786251927914_reyfnjurpoc"
}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 ใช้แจ้งซัพพอร์ตเวลาตามรอยใบใดใบหนึ่ง |
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 ไม่ผ่านทุกใบ
worker ทำอะไรกับแต่ละ event
| event | สิ่งที่ควรทำ | คีย์ที่ใช้ |
|---|---|---|
PAYMENT_PAID | เครดิตออเดอร์ตาม data.payment.order_id — ทำครั้งเดียวต่อ payment เดียว ห้ามดูจาก deposit_type ว่ามาทางไหน | payment.id |
PAYMENT_FAILED | ปิดออเดอร์ฝากเป็นไม่สำเร็จ — เกิดกรณีเดียวคือสลิปซ้ำ | payment.id |
WITHDRAWAL_PARTIALLY_FUNDED | อัปเดต progress ที่แสดงผลเท่านั้น ห้ามลงบัญชี — ไม่มี summary ใน payload นี้ | match id |
WITHDRAWAL_COMPLETED | ปิดรายการถอนและลงบัญชีตาม summary.withdrawer_received — ไม่ใช่ withdrawal.amount | withdrawal.id |
WITHDRAWAL_COMPLETED_PARTIALLY | ลูกค้าได้เงินไปแล้วบางส่วน — ลงบัญชีตาม summary.withdrawer_received และคืนลูกค้าเฉพาะ summary.withdrawer_pending_refund ห้ามใช้ agent_refunded เพราะรวมค่าธรรมเนียมที่คืนเข้าร้าน ไม่ใช่ยอดของลูกค้า | withdrawal.id |
WITHDRAWAL_REJECT | ไม่มีเงินถึงลูกค้าเลย คืนเต็มจำนวน — payload มี summary และ outcome เหมือน event ปลายทางตัวอื่น ยอดคืนลูกค้าอ่านจาก summary.withdrawer_pending_refund (สะกดไม่มี -ED) | withdrawal.id |
PAYMENT_CANCELED | ยกเลิกออเดอร์ตาม data.payment.id — อาจเป็นรายการเก่าที่ถูกยกเลิกอัตโนมัติเมื่อผู้จ่ายคนเดิมสร้างรายการใหม่ อย่าเดาว่าเป็นใบล่าสุด | payment.id |
PAYMENT_CONVERTED | มาต่อจาก PAYMENT_PAID เมื่อเปิด auto-convert — อัปเดตสกุลที่ settle จริง คนละ system_id ต้อง join กลับด้วย data.payment.id | convert:<id> |
WITHDRAWAL_APPROVED / _EXPIRED | เกิดกับ CRYPTO เท่านั้น — ฝั่ง FIAT/P2P ไม่ต้องรอสองตัวนี้ | withdrawal.id |
WITHDRAWAL_VERIFY | synchronous — ยิงระหว่างที่ร้านเรียก API สร้างรายการถอน ต้องตอบ 2xx ใน 10 วินาที ห้ามเข้าคิวเดียวกับ event อื่น | — (ยังไม่มี id) |
default | event อื่น (classic, crypto, TRANSACTION_NEW) — บันทึกไว้แล้วตอบ 200 อย่าโยน error เพราะระบบจะถือว่าส่งไม่สำเร็จและไม่มีการส่งซ้ำ | — |
เขียน handler อย่างไร
endpoint เดียวรับทั้งฝากและถอน — ระบบมี URL เดียวต่อ 1 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 // ของเก่ามาช้า → ทิ้ง
switch (event) {
case 'PAYMENT_PAID':
await tx.credit(order, data.payment.amount); break
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.summary.withdrawer_received) // 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 ต้องถือว่าทุกบล็อกเป็น optional- endpoint นี้ยังรับ event ของช่องทางอื่นด้วย (classic, crypto) —
defaultของ switch ควรบันทึกไว้เฉย ๆ แล้วตอบ 200 ไม่ใช่โยน error - ข้อยกเว้นเดียว:
WITHDRAWAL_VERIFYเป็น synchronous ต้องตอบผลในคำขอนั้นเลย ห้ามเข้าคิวเดียวกัน — ดู Withdraw verify
- ส่งครั้งเดียว ไม่มี retry — ต้องมี job กระทบยอดที่ poll ตามตารางด้านบน
- ไม่รับประกันลำดับ —
COMPLETEDมาถึงก่อนขาสุดท้ายได้ ให้ event ปลายทางชนะเสมอ - ขอส่งซ้ำได้ที่
POST /v1/webhookRequest/resend/:id— ข้าม dedup และใช้คีย์เดิม จึงต้อง idempotent - ทดสอบด้วย
POST /v1/p2p/acceptForTesting/:idบน testnet (ใส่ match id หรือ payment id)
เหตุการณ์ที่ไม่มี webhook
เหตุการณ์เหล่านี้ไม่ส่ง webhook สามารถใช้ poll แทนได้
| สถานการณ์ | ต้องรู้ได้อย่างไร |
|---|---|
| สลิปอยู่ระหว่างตรวจ (PAYMENT_CHECKING) | ค้างได้หลายนาที — GET /v1/payment/info |
| สร้างรายการสำเร็จ | เก็บจาก response ตอนสร้าง (รวม order_tracking_id ที่ค้นย้อนหลังไม่ได้) |
| จับคู่ได้แล้ว / คิวหมดอายุ | GET /v1/p2p/public/flow/:flowId |
| รายการฝากหมดอายุ | GET /v1/payment/info |
| สลิปถูกปฏิเสธ | 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 — ต้อง poll
DISPUTEDผู้ฝากเปิดข้อพิพาท — ล็อกไว้จนแอดมินตัดสิน ไม่หมดอายุเอง
ไม่มี webhook — ต้อง poll
COMPLETEDสลิปผ่าน เครดิตเข้าออเดอร์
webhook: PAYMENT_PAID
DISPUTE_RESOLVEDแอดมินตัดสินแล้ว — ผลไปคนละฝั่งตามคำตัดสิน
webhook
EXPIREDหมดเวลาโอน ขาถูกปล่อยกลับเข้าคิว
ไม่มี webhook — ต้อง poll
CANCELLEDผู้ฝากยกเลิกก่อนเห็นบัญชีปลายทาง
ไม่มี webhook — ต้อง poll
CANCELLED_HELDยกเลิกขณะยังกันยอดไว้
ไม่มี webhook — ต้อง poll
RELEASEDปล่อยยอดที่กันไว้กลับคืน
ไม่มี webhook — ต้อง poll
มีแค่ COMPLETED และ DISPUTE_RESOLVED ที่ส่ง webhook — สถานะปลายทางที่เหลือเงียบทั้งหมด
webhook หลังการตัดสิน dispute
ตัดสินโดยแอดมินเท่านั้น ไม่มีระบบตัดสินอัตโนมัติ รายการค้างจนกว่าจะมีคนกด — ผลแต่ละทางส่ง webhook ไปคนละฝั่ง
P2P_INVALID_STATUS_TRANSITION (38023) - ยอดที่ร้านผู้ฝากได้คือยอด payment หักค่าธรรมเนียมฝากตามปกติ
- ทั้งสองทางย้อนกลับไม่ได้ — ตัดสินซ้ำจะได้
P2P_INVALID_STATUS_TRANSITION(38023)
เมื่อตัดสินให้ผู้ฝาก รายการถอนยังไม่จบ — ถูกส่งต่อไปรางธนาคารและคงสถานะ PENDING ฝั่งถอนจึงยังไม่ได้ webhook จนกว่าธนาคารจะโอนเสร็จ และการตัดสินย้อนกลับไม่ได้
ตัวอย่าง Payload ของฝากอัตโนมัติ
Payment และ Withdrawal ช่องทางปกติ
