Webhook — ภาพรวมและโครงซอง
webhook คือช่องทางหลักที่คุณใช้รู้ว่ารายการฝาก/ถอนเดินไปถึงไหน · การ poll เป็นแค่ fallback
หน้านี้ตอบ 3 อย่าง: ซองหน้าตาเป็นยังไง · ต้องตั้ง endpoint กี่ตัว · endpoint ของคุณต้องมีพฤติกรรมแบบไหนถึงจะนับว่า "รับสำเร็จ"
หลักการ 3 ข้อ
1. payload ครบใบ — ไม่ต้องยิงกลับมาถาม
ทุก event ส่ง data.object เป็น object เต็มใบ ณ ขณะที่สร้าง event ไม่ใช่แค่ id กับสถานะ
แปลว่า handler ของคุณเขียนแบบนี้ได้: รับ event → เขียนลง DB ด้วย UPDATE เดียว → ปล่อยของ · ไม่ต้องมีขั้น GET /v1/pay/deposits/{id} คั่นกลาง
❌ แบบที่ไม่ต้องทำ: webhook มา → GET กลับมาถาม → ค่อยตัดสินใจ
✅ แบบที่ถูก: webhook มา → verify → UPDATE ... WHERE version < ใหม่ → ตัดสินใจจากผลของ UPDATEเหตุผลที่เราออกแบบแบบนี้: การ GET กลับมาถามทำให้เกิด race อีกชุด (คุณอาจได้สถานะที่ ใหม่กว่า event ที่กำลังประมวลผลอยู่ แล้วเขียนทับกันเอง) และทำให้คุณชน rate limit pay_read ตอน traffic พีค
2. event.type บอกว่า "มีอะไรเกิดขึ้น" — data.object.status คือความจริง
ตัดสินใจเรื่องเงินจาก data.object.status เท่านั้น
status = 'settled' เท่านั้นที่แปลว่าเครดิตได้ · slip_processing และ under_review แปลว่าเงินโอนแล้วแต่ยังตรวจไม่จบ — ห้ามปล่อยของ
การเขียน switch (event.type) แล้วปล่อยของใน case 'deposit.slip_received' คือบั๊กที่ทำให้เสียเงินจริง
3. ลำดับไม่การันตี — version คือสิ่งที่กันไม่ให้ของเก่าทับของใหม่
เรารับประกันแค่ว่า data.object.version เพิ่มขึ้นตามลำดับที่สถานะเปลี่ยนจริงที่ฐานข้อมูลของเรา และ ไม่มีสอง event ของ object เดียวกันที่ version เท่ากัน
เราไม่รับประกันว่ามันจะมาถึงคุณตามลำดับนั้น → อ่าน ลำดับไม่การันตี ก่อนเขียน handler บรรทัดแรก
โครงซอง (มีทุก event ไม่มีข้อยกเว้น)
ซองนี้เป็น business event ที่ immutable ระบบเก็บ raw JSON ไว้ตอนสร้าง event และส่ง body ไบต์เดิมทุก attempt รวมถึง manual resend ส่วนข้อมูลของการส่งแต่ละครั้งอยู่ใน X-PG-* headers เท่านั้น จึงไม่ทำให้ body เปลี่ยนระหว่าง retry
{
"id": "evt_01K3M8QW7Z4N2VYB6H0RSTA9CD",
"object": "event",
"type": "deposit.settled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:12:33.881Z",
"sequence": 88214,
"account": {
"merchant_id": "mch_01K2G4H9XQ8M3TVB7NDZP0RCEF",
"white_label_id": "wl_01K1QZ2M6JH4XR8TVN0BDC5APG"
},
"request": {
"id": "req_01K3M8QW7Y2VNRT4HB0XKDF9CS",
"idempotency_key": "order-9981-topup"
},
"data": {
"object": { "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B", "object": "deposit_order", "version": 9 },
"previous_attributes": { "version": 8, "status": "slip_processing" }
}
}ตัวอย่างข้างบนตัด
data.objectให้สั้นเพื่อให้เห็นโครงซอง · ของจริงdata.objectคือ object เต็มใบ — ดูใบเต็มที่ แคตตาล็อก event
ทุก field ในซอง
| field | ชนิด | รับประกันอะไร |
|---|---|---|
id | string evt_* (ULID) | ไม่ซ้ำตลอดกาล · ค่าเดียวกันทุก attempt และทุกครั้งที่กดส่งซ้ำ ⇒ ใช้เป็น unique key ของตาราง dedupe ได้เลย |
object | string | เป็น "event" เสมอ |
type | string | ชื่อ event จาก แคตตาล็อก · 🔴 enum เปิด — ต้องมี default: ในทุก switch |
api_version | YYYY-MM-DD | render ตาม version ที่คุณ pin ไว้ ณ ตอนสร้าง event · retry ของ event เก่าใช้ version เดิม |
livemode | boolean | false = sandbox · 🔴 event ของ sandbox ไม่มีวันถึง endpoint ของ production และกลับกัน |
created_at | RFC3339 UTC | เวลาที่ สร้าง event — ไม่เปลี่ยนตอน retry · มิลลิวินาที 3 หลักเสมอ ลงท้าย Z |
sequence | int64 | เพิ่มขึ้นเสมอต่อ merchant ตาม เวลาที่สร้าง ไม่ใช่เวลาที่ส่งถึง · ใช้ตรวจ gap และเป็น cursor ของ GET /v1/pay/events |
account.merchant_id | string | บัญชี merchant ที่ event นี้เป็นของ |
account.white_label_id | string | ผู้ให้บริการที่ออก key ให้คุณ |
request.id | string | id ของ API call ที่ทำให้เกิด event นี้ · มีเฉพาะเมื่อ event เกิดจาก API call (ไม่มี key นี้เลยถ้าเกิดจากฝั่งเรา เช่น หมดอายุ) |
request.idempotency_key | string | key ที่คุณส่งมาตอนสร้างรายการ ⇒ ผูก event กลับไปหา order ในระบบคุณได้แม้ยังไม่ได้บันทึก deposit_id |
data.object | object | business object เต็มใบ ณ ขณะนั้น · มี version ทุกใบ ยกเว้น object ที่ immutable |
data.previous_attributes | object | null | เฉพาะ key ที่เปลี่ยนจริง + version เดิม · เป็น null สำหรับ event *.created · ไม่มี key นี้เลย สำหรับ balance_transaction |
ข้อมูลการส่งไม่อยู่ใน JSON body: อ่าน X-PG-Delivery-Id, X-PG-Endpoint-Id, X-PG-Attempt และ X-PG-Resend จาก header หลัง verify ลายเซ็นแล้ว ดูรายการเต็มที่ ตรวจลายเซ็น
field optional = อาจไม่มี key นั้นเลย ไม่ใช่ null`
เอกสารชุดนี้ใช้กติกาเดียวกันทั้งระบบ: field ที่เขียนว่า "มีเสมอ" คือรับประกัน · field ที่เป็น optional คือ อาจหายไปทั้ง key ⇒ โค้ดที่เขียน if (event.request.idempotency_key) จะพังตอน request ไม่มี ให้ใช้ optional chaining
object ที่ไม่มี version
balance_transaction (btxn_*) เป็น append-only immutable ⇒ ไม่มี version ไม่มี previous_attributes
⇒ dedupe ด้วย id แล้ว สะสม ไม่ใช่เขียนทับ · วิธีเก็บอยู่ที่ ordering
api_version — อะไรนับว่า breaking
upgrade เป็น opt-in · breaking change ประกาศล่วงหน้า 90 วัน · รองรับ version เก่า 12 เดือน · มี header X-PG-Deprecation แจ้งเตือน
ไม่นับว่าเป็น breaking change (เกิดได้ตลอดโดยไม่แจ้งล่วงหน้า):
- เพิ่ม field ใหม่ใน payload
- เพิ่มค่าใหม่ใน enum ที่ประกาศว่าเปิด:
typeของ event ·review.reasons[].code·rejection.code·balance_transaction.type·fee_breakdown[].component
ทุก switch ต้องมี default:
enum ที่เปิดแปลว่าวันหนึ่งคุณจะได้ค่าที่โค้ดไม่รู้จัก · switch ที่ไม่มี default: จะเงียบ ไม่ throw และคุณจะรู้ตอนกระทบยอดสิ้นเดือน
ค่าที่ ปิด (เพิ่มค่าใหม่ = breaking change) มีแค่ deposit_orders.status 9 ค่า และ withdrawal_orders.status 7 ค่า
ตั้ง endpoint แยกกลุ่มได้
| field | บังคับ? | รับ event กลุ่มไหน |
|---|---|---|
deposit_url | ✅ บังคับ | event ฝั่งรายการฝาก · และเป็น fallback ของทุกกลุ่ม ที่ไม่ได้ตั้ง URL แยก |
withdrawal_url | optional | event ฝั่งรายการถอน · ไม่ตั้ง → ไปที่ deposit_url |
account_url | optional | balance.* และ webhook.* · ไม่ตั้ง → ไปที่ deposit_url |
ข้อกำหนดของ URL ทุกตัว:
- ต้องเป็น
https://· TLS ≥ 1.2 · certificate ต้อง valid (self-signed = fail) - เราไม่เติม query parameter ใด ๆ และ ไม่ส่งข้อมูลใน query string เลย — ข้อมูลอยู่ใน body เสมอ
- 🔴 เราไม่ตาม redirect — ตอบ
3xxนับเป็น fail (กัน SSRF และกันไม่ให้ลายเซ็นไปโผล่ที่โฮสต์อื่น) - 🔴 เราไม่มี IP ต้นทางคงที่ — อย่าออกแบบให้ผ่านด้วย IP allowlist · ใช้ลายเซ็น Ed25519 เป็นด่านเดียว
ตั้งกี่ตัวดี
ถ้าทีมเดียวดูแลทั้งหมด ตั้ง deposit_url ตัวเดียวพอ · แยกเมื่อคนละทีมดูแลคนละฝั่ง หรือเมื่ออยากให้ traffic ฝั่งถอน (ซึ่ง 1 รายการอาจได้ 7 ใบ) ไม่ไปเบียดคิวฝั่งฝาก
endpoint ของคุณต้องทำอะไรบ้าง
ลำดับที่ถูกต้องภายใน handler — ห้ามสลับ:
- อ่าน raw body (bytes) — ห้ามให้ framework parse JSON ก่อน
- verify ลายเซ็น Ed25519 บน raw bytes → ไม่ผ่าน ตอบ
401 - เช็ค timestamp
abs(now − t) ≤ 300วินาที → เกิน ตอบ400 - parse JSON (ได้ตอนนี้เท่านั้น) แล้วเช็ค
event.idตรงกับ header และlivemodeตรงกับ environment - transactional inbox — ใน transaction เดียว ให้
INSERT event.idแบบ dedupe พร้อม raw event และสร้าง outbox row สำหรับงานถัดไป - ตอบ
2xxหลัง transaction commit เท่านั้น — duplicate ตอบ200; outbox relay ส่งงานหนักไป worker ภายหลัง
ห้ามทำ markSeen() แล้วค่อย enqueue คนละ commit เพราะ process อาจตายตรงกลางและทำ event หายถาวร รายละเอียดพร้อมโค้ดอยู่ที่ ตรวจลายเซ็น
เกณฑ์ที่นับว่า "รับสำเร็จ"
| เกณฑ์ | ค่า |
|---|---|
| status code ที่นับว่าสำเร็จ | 200 201 202 204 เท่านั้น |
| เวลาตอบที่แนะนำ | 3 วินาที |
| timeout รวมทั้ง request (hard limit) | 15 วินาที |
| ขนาด body ที่เราส่ง | ≤ 256 KB (รับประกัน) |
| body ที่คุณตอบกลับ | เราอ่านสูงสุด 8 KB ไปเก็บ log · เนื้อหาไม่มีผลต่อความสำเร็จ — ตอบ body ว่างได้ |
งานหนักต้องอยู่นอก request
ส่งอีเมล · sync ERP · rebuild รายงาน — ทำในคิว ไม่ใช่ในบรรทัดที่รอ res.send()
ถ้า handler ใช้เวลาเกิน 15 วินาที เราถือว่า fail แล้ว retry · handler ที่ทำงานซ้ำจะยิ่งช้า · ช้าแล้ว timeout อีก · วนจนล้ม
event 5 ตัวที่ปิดการรับไม่ได้
ทุก endpoint ต้อง subscribe 5 ตัวนี้ — เป็นเงื่อนไขที่บังคับที่ฐานข้อมูล ไม่ใช่คำแนะนำ:
| event | ทำไมปิดไม่ได้ |
|---|---|
deposit.reversed | เงินถูกดึงออกจากกระเป๋าคุณ |
withdrawal.fill_reversed | เงินที่จ่ายไปแล้วถูกกลับรายการ |
balance.debited | ทุกการหักยอดกระเป๋า |
webhook.key.rotating | กุญแจกำลังจะเปลี่ยน — ไม่รับ = verify fail ทั้งระบบในวันสลับ |
webhook.endpoint.disabled | endpoint ตัวใดตัวหนึ่งถูกปิดอัตโนมัติ |
เหตุผลของสามตัวแรกคือข้อเดียวกัน: มันคือการเอาเงินออก ถ้าปิดได้ คุณจะเห็นแต่ขาเข้า กระทบยอดไม่ตรง และเปิดช่องให้เถียงกันว่า "ไม่เคยได้รับแจ้ง"
สิ่งที่จะไม่มีวันอยู่ใน payload
ออกแบบไว้ตั้งแต่ต้นว่า merchant ไม่ควรเห็น — อย่าเขียนโค้ดที่รอค่าเหล่านี้:
- ชื่อ/รหัส/โดเมน/reference ของผู้ให้บริการภายนอกทุกรูปแบบ — คุณเห็นแค่
liquidity_source ∈ internal | external_gatewayและ 🔴 ห้ามใช้ค่านี้ตัดสินใจเครดิต - ชื่อจริง/เลขบัญชีเต็มของอีกฝ่าย — มีแต่ค่าที่ mask แล้ว
- id ของรายการฝั่งตรงข้าม — คุณเห็นเป็น
leg_refซึ่งสองฝั่งได้ค่าคนละตัว correlate ข้ามกันไม่ได้ - ค่าที่อ่านได้จากสลิปจริง (ชื่อ/เลขบัญชีผู้โอนที่ระบบอ่านได้) — มีแต่
verification.*ที่เป็น boolean ล้วน 6 ตัว - ข้อมูลคิวเจ้าหน้าที่และนโยบายภายใน — มีแต่
review.escalated,review.four_eyes_required,review.estimated_resolution_bucket
เราต่างจากตลาดตรงไหน (และคุณต้องทำอะไรเพิ่ม)
| เรื่อง | เจ้าอื่นที่คุณเคยต่อ | ของเรา | คุณต้องทำเพิ่ม |
|---|---|---|---|
| ลายเซ็น callback | HMAC ด้วย shared secret หรือไม่มีเลย | Ed25519 — เราถือ private key คุณถือ public key | ดึง public key จาก GET /v1/webhooks/signing-keys (ไม่ต้อง auth) และ verify บน raw bytes |
| ความสมบูรณ์ของ payload | ส่ง id + สถานะ แล้วให้ยิงกลับมาถาม | object เต็มใบ + version | เลิกขั้น GET กลางทาง แล้วใช้ version guard แทน |
| ลำดับ | หลายเจ้าส่งเรียงจริง | ไม่การันตี หน้าต่าง retry ยาว ~92 ชม. | ต้องมี version guard — ไม่มี = settled โดน under_review ทับ |
| 1 รายการถอน | 1 callback ตอนจบ | withdrawal.filled หลายใบ ต่อ 1 รายการ | ปิดรายการเมื่อ status='settled' และ remaining_minor = 0 เท่านั้น |
| ผลตรวจสลิปซ้ำ | 409 duplicate_slip ทันทีตอนอัปโหลด | 🔴 durable intake สำเร็จตอบ 202 | ผลตรวจซ้ำมาทีหลังทาง webhook (deposit.under_review code DUPLICATE_SLIP) หรือ status endpoint — โค้ดที่รอ 409 จะไม่มีวันได้ |
| ยอดเข้ากระเป๋า | ตัวเลขเดียว | 4 ยอดหลัก X/V/A/E แยกกัน + fee/merchant credit | เครดิตผู้ใช้ปลายทาง = amount_received_minor (A) · กระเป๋า merchant = credited_amount_minor · overpay เปิด claim ของ E ไม่เครดิต/คืนอัตโนมัติ |
202 ตอนอัปสลิป — จุดที่โค้ดเดิมพังเงียบที่สุด
POST /v1/pay/deposits/{id}/slip ที่ผ่าน capability auth/validation และยืนยัน storage+queue สำเร็จตอบ 202 แม้สลิปใบนั้นจะซ้ำกับใบที่เคยใช้ไปแล้ว; intake ที่ยังยืนยันไม่ครบตอบ retryable 503
เรารับไฟล์ไว้ก่อน (แม้ฐานข้อมูลกำลังมีปัญหาก็ยังรับ) แล้วตรวจทีหลัง เพราะผู้ใช้โอนเงินไปแล้วจริง ๆ การปฏิเสธที่หน้าอัปโหลดทำให้เงินก้อนนั้นหาเจ้าของไม่เจอ
⇒ โค้ด if (res.status !== 200) throw พังทั้งเส้น · ⇒ ผลว่าสลิปซ้ำจะมาถึงคุณทาง webhook เท่านั้น
🔴 webhook ไม่ใช่ source of truth
ledger ของเราคือความจริง · webhook คือการแจ้งเตือน
ต่อให้ endpoint คุณล่มยาว 4 วันจนหมดรอบ retry คุณก็กระทบยอดย้อนหลังได้เสมอด้วย:
curl -s "https://api.example-wl.com/v1/pay/events?since_sequence=88214&limit=100" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." -H "PG-Version: 2026-08-01"GET /v1/pay/events เก็บย้อนหลัง 30 วัน และใช้ since_sequence เป็น cursor ตัวเดียว
reconcile job รายวันเป็นเงื่อนไขบังคับก่อน go-live ไม่ใช่ nice-to-have
ขั้นต่อไป
| อ่านต่อ | เมื่อ |
|---|---|
| แคตตาล็อก event ทั้งหมด | อยากรู้ว่ามี event อะไรบ้าง ใบไหนมี field อะไร |
| ตรวจลายเซ็น Ed25519 | กำลังจะเขียน handler บรรทัดแรก |
| ลำดับไม่การันตี — version guard | 🔴 อ่านก่อนปล่อยของทุกกรณี |
| Retry และ endpoint ล่ม | อยากรู้ว่าตอบ 500 ไปแล้วจะเกิดอะไร |
| ทดสอบก่อนขึ้น production | handler เขียนเสร็จแล้ว |
| สถานะรายการฝาก | อยากรู้ว่าสถานะไหนแปลว่าเครดิตได้ |
