Skip to content

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

json
{
  "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ชนิดรับประกันอะไร
idstring evt_* (ULID)ไม่ซ้ำตลอดกาล · ค่าเดียวกันทุก attempt และทุกครั้งที่กดส่งซ้ำ ⇒ ใช้เป็น unique key ของตาราง dedupe ได้เลย
objectstringเป็น "event" เสมอ
typestringชื่อ event จาก แคตตาล็อก · 🔴 enum เปิด — ต้องมี default: ในทุก switch
api_versionYYYY-MM-DDrender ตาม version ที่คุณ pin ไว้ ณ ตอนสร้าง event · retry ของ event เก่าใช้ version เดิม
livemodebooleanfalse = sandbox · 🔴 event ของ sandbox ไม่มีวันถึง endpoint ของ production และกลับกัน
created_atRFC3339 UTCเวลาที่ สร้าง event — ไม่เปลี่ยนตอน retry · มิลลิวินาที 3 หลักเสมอ ลงท้าย Z
sequenceint64เพิ่มขึ้นเสมอต่อ merchant ตาม เวลาที่สร้าง ไม่ใช่เวลาที่ส่งถึง · ใช้ตรวจ gap และเป็น cursor ของ GET /v1/pay/events
account.merchant_idstringบัญชี merchant ที่ event นี้เป็นของ
account.white_label_idstringผู้ให้บริการที่ออก key ให้คุณ
request.idstringid ของ API call ที่ทำให้เกิด event นี้ · มีเฉพาะเมื่อ event เกิดจาก API call (ไม่มี key นี้เลยถ้าเกิดจากฝั่งเรา เช่น หมดอายุ)
request.idempotency_keystringkey ที่คุณส่งมาตอนสร้างรายการ ⇒ ผูก event กลับไปหา order ในระบบคุณได้แม้ยังไม่ได้บันทึก deposit_id
data.objectobjectbusiness object เต็มใบ ณ ขณะนั้น · มี version ทุกใบ ยกเว้น object ที่ immutable
data.previous_attributesobject | 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_urloptionalevent ฝั่งรายการถอน · ไม่ตั้ง → ไปที่ deposit_url
account_urloptionalbalance.* และ 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 — ห้ามสลับ:

  1. อ่าน raw body (bytes) — ห้ามให้ framework parse JSON ก่อน
  2. verify ลายเซ็น Ed25519 บน raw bytes → ไม่ผ่าน ตอบ 401
  3. เช็ค timestamp abs(now − t) ≤ 300 วินาที → เกิน ตอบ 400
  4. parse JSON (ได้ตอนนี้เท่านั้น) แล้วเช็ค event.id ตรงกับ header และ livemode ตรงกับ environment
  5. transactional inbox — ใน transaction เดียว ให้ INSERT event.id แบบ dedupe พร้อม raw event และสร้าง outbox row สำหรับงานถัดไป
  6. ตอบ 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.disabledendpoint ตัวใดตัวหนึ่งถูกปิดอัตโนมัติ

เหตุผลของสามตัวแรกคือข้อเดียวกัน: มันคือการเอาเงินออก ถ้าปิดได้ คุณจะเห็นแต่ขาเข้า กระทบยอดไม่ตรง และเปิดช่องให้เถียงกันว่า "ไม่เคยได้รับแจ้ง"

สิ่งที่จะไม่มีวันอยู่ใน 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

เราต่างจากตลาดตรงไหน (และคุณต้องทำอะไรเพิ่ม) ​

เรื่องเจ้าอื่นที่คุณเคยต่อของเราคุณต้องทำเพิ่ม
ลายเซ็น callbackHMAC ด้วย 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 คุณก็กระทบยอดย้อนหลังได้เสมอด้วย:

bash
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 ไปแล้วจะเกิดอะไร
ทดสอบก่อนขึ้น productionhandler เขียนเสร็จแล้ว
สถานะรายการฝากอยากรู้ว่าสถานะไหนแปลว่าเครดิตได้

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01