Skip to content

ทดสอบ webhook ​

ลำดับที่เสียเวลาน้อยที่สุด: verify offline → ยิงจริงจาก sandbox → ทดสอบเคสสลับลำดับ → เช็กลิสต์

ขั้นที่ 0 — verify ให้ผ่าน offline ก่อน (ไม่ต้องมี endpoint) ​

คุณ ปลอม webhook ของตัวเองไม่ได้ — และนั่นคือเรื่องดี

เจ้าอื่นที่ใช้ HMAC คุณสร้าง payload ปลอมพร้อมลายเซ็นที่ถูกต้องเองได้ เพราะคุณถือ secret เดียวกับเขา

ของเราเซ็นด้วย Ed25519 — private key อยู่ฝั่งเราเท่านั้น ⇒ ไม่มีทางที่สคริปต์ในเครื่องคุณจะสร้าง event ที่ verify ผ่านได้

⇒ ทดสอบ ฟังก์ชัน verify ด้วย test vector · ทดสอบ ทั้งเส้น ด้วย sandbox

bash
curl -s https://api.example-wl.com/v1/webhooks/test-vectors -o vectors.json

ได้ 3 ชุด แต่ละชุดมี raw_body.bin, t, event_id, kid, signature, public_key

ชุดpayloadจับบั๊ก
1ภาษาไทยล้วนre-serialize (ไทยกลายเป็น \uXXXX)
2มี URL ที่มี /re-serialize (/ กลายเป็น \/)
3มี emoji + surrogate pairอ่าน body เป็น string แทน bytes

เทสต์ที่ต้องมีคู่กัน:

เทสต์ผลที่ต้องได้
verify ทั้ง 3 ชุดผ่านทั้งหมด
แก้ raw body 1 ไบต์401
แก้ t เป็นค่าอื่น401 (เพราะ t อยู่ใน canonical string)
ตั้ง t ให้เก่ากว่า 300 วินาที400
ส่ง event.id ใน body ไม่ตรงกับ X-PG-Event-Id400
ส่งซ้ำ payload เดิมที่ถูกต้อง 2 ครั้งครั้งที่ 2 ได้ 🔴 200 และ ไม่มี side effect

เทสต์แถวสุดท้ายคือเทสต์ที่สำคัญที่สุดในหน้านี้

ถ้าครั้งที่ 2 เกิด side effect แปลว่า dedupe ยังไม่ทำงาน · production จะเครดิตซ้ำสูงสุด 11 เท่าตามจำนวนรอบ retry

ขั้นที่ 1 — ยิง event จริงจาก sandbox ​

sandbox ใช้คีย์คนละใบกับ production และทุก event มี livemode: false

livemode ข้ามกันไม่ได้ — บังคับที่ฐานข้อมูล ไม่ใช่ตรรกะในโค้ด

event ของ sandbox ไม่มีวัน ถึง endpoint ของ production และกลับกัน

แต่คุณยังต้องเช็คเองในโค้ดอยู่ดี: if (event.livemode !== isProduction) return 400 — เผื่อกรณีที่ config ฝั่งคุณชี้ผิดสภาพแวดล้อม

วิธียิง: สร้างรายการฝากด้วย "ยอดที่กำหนด" ​

sandbox ผูกเส้นทางไว้กับยอดเงิน · สร้างรายการด้วยยอดไหน ก็ได้ชุด event ของเส้นทางนั้น

ยอด (บาท)amount_minorเส้นทางที่ได้
100.0010000matched → ตรวจสลิปอัตโนมัติ → settled ใน ~5 วินาที
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติ/ปฏิเสธ)
102.0010200→ expired → อัปสลิปย้อนหลังอัตโนมัติที่ +60 วินาที → under_review['LATE_SLIP'] → settled
103.0010300settled → reversed ที่ +60 วินาที (ทดสอบ event ที่ปิดไม่ได้ + เคส B ของ ordering)
104.0010400ช่องทางสำรอง 2 legs (TTL ~4 นาที)
105.0010500awaiting_liquidity เต็ม 25 วินาที แล้วค่อยได้ปลายทาง
106.0010600→ under_review ['BANK_DISABLED']
107.0010700รายการถอนฝั่งตรงข้ามถูกซอยเป็น 3 ก้อน
108.0010800โอนซ้ำ — settled แล้วมี deposit.overpaid ที่ +45 วินาที
109.0010900กด cancel แล้วเผลอโอน — canceled(is_terminal:false) → อัปสลิปย้อนหลัง → under_review['CANCELED_ORDER_SLIP']
110.0011000ALLOCATION_CONFLICT — สลิปย้อนหลังที่ยอดส่วนนั้นถูกจับคู่ให้คนอื่นไปแล้ว + withdrawal.disputed ฝั่งตรงข้าม
111.0011100clean short-pay อัตโนมัติ → settled ที่ A=V<X, partial:true, settled_via:'auto' · ไม่เข้า review/ไม่เรียก force-complete
112.0011200หมดหน้าต่างรับสลิปย้อนหลัง → 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing)
114.0011400หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ภายใน 60 วินาที
115.0011500idempotency ชนกัน — key เดิม body ต่าง
116.0011600same-slip overpay — X=11600, V=12100, อีก 5 มิติผ่าน → deposit.settled ใบเดียวที่ A=X, E=500, open/contact-support flag, ไม่มี auto-refund
bash
# ตัวอย่าง: ยิงเส้น reversed (103.00 บาท)
curl -X POST https://api.example-wl.com/v1/pay/deposits \
  -H "Authorization: Bearer <sandbox api key>" \
  -H "Content-Type: application/json" \
  -H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." \
  -H "Idempotency-Key: sbx-reversed-001" \
  -H "PG-Version: 2026-08-01" \
  -d '{
    "amount_minor": 10300,
    "currency": "THB",
    "merchant_reference": "SBX-REVERSED-001",
    "end_user_ref": "u_88213",
    "sender": {
      "bank_code": "002",
      "account_number": "1234567890",
      "account_name_th": "สมชาย ใจดี"
    },
    "client": {
      "device_fingerprint": "df_9a1c7f20b4",
      "ip": "203.0.113.44",
      "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X)"
    },
    "metadata": { "user_id": "u_88213", "order_id": "SBX-REVERSED-001" }
  }'

5 เส้นที่ระบบตรวจอัตโนมัติก่อนออกคีย์ production

103.00 (reversed) · 108.00 (โอนซ้ำ) · 109.00 (cancel แล้วเผลอโอน) · 116.00 (same-slip overpay) และการทดสอบ event สลับลำดับ

ห้าข้อนี้ไม่ใช่ช่องให้ติ๊กเอง — ระบบตรวจว่า handler ของคุณตอบถูกจริงก่อนจะออกคีย์ให้

ขั้นที่ 2 — ทดสอบ event สลับลำดับ ​

bash
curl -X POST https://api.example-wl.com/v1/test/webhooks/replay-out-of-order \
  -H "Authorization: Bearer <sandbox api key>" \
  -H "PG-Signature: ..." -H "PG-Timestamp: ..." -H "PG-Nonce: ..." \
  -H "Idempotency-Key: sbx-ooo-001" \
  -H "PG-Version: 2026-08-01"

ระบบจะส่ง event ของรายการทดสอบมาแบบสลับลำดับ · ผลลัพธ์ที่ต้องได้

เคสผลที่ถูกต้องผลที่ผิด (แปลว่าไม่มี version guard)
A — expired (version เก่า) มาหลัง settledออร์เดอร์ยัง settled · ไม่มี side effect จากใบเก่า · ตอบ 200ออร์เดอร์ถูกยกเลิก
B — settled (version เก่า) มาหลัง reversedไม่เครดิต ไม่ปล่อยของ · ตอบ 200เครดิตซ้ำ + ปล่อยของทั้งที่ถูกกลับรายการ
C — withdrawal.filled สลับก้อนremaining_minor ตรงกับใบที่ version สูงสุดยอดค้างที่ค่าเก่า

ดูโค้ดที่ทำให้ผ่านทั้ง 3 เคส

อยู่ที่ ลำดับไม่การันตี — version guard · หัวใจคือ WHERE pg_version < EXCLUDED.pg_version แล้วผูก side effect กับ rowCount

ขั้นที่ 3 — ส่งซ้ำจากหน้าจัดการ ​

หน้าจัดการ webhook มีให้กด 3 อย่าง — ทั้งสามอย่างมี endpoint ให้เรียกจาก CI ได้:

ปุ่ม / endpointทำอะไร
ส่งซ้ำใบเดียว — POST /v1/bo/webhooks/{endpoint_id}/deliveries/{delivery_id}/resendส่ง event ใบเดิม ไบต์เดิม event.id เดิม อีกครั้ง
ส่งซ้ำเป็นชุด — POST /v1/bo/webhooks/{endpoint_id}/replayเลือกด้วย event_types, created_after, created_before, only_failed
Replay failed deliveries (72 ชม. ล่าสุด)ปุ่มลัดหลัง endpoint ถูกปิดอัตโนมัติแล้วเปิดคืน

รายละเอียดและกติกาทั้งหมดอยู่ที่ Retry · การส่งซ้ำ

ใช้ resend เป็นเทสต์ dedupe ที่สมจริงที่สุด

กด resend ใบ deposit.settled ที่คุณประมวลผลไปแล้ว 3 ครั้งติด · event.id เหมือนเดิมทุกครั้ง

ยอดผู้ใช้ต้องไม่ขยับเลยแม้แต่บาทเดียว และคุณต้องตอบ 200 ทั้ง 3 ครั้ง

forward ลง localhost ​

เราส่งไปหา URL สาธารณะเท่านั้น · http://localhost เป็นไปไม่ได้ตั้งแต่ต้นเพราะ:

  • ต้องเป็น https:// · TLS ≥ 1.2 · certificate ต้อง valid — self-signed = fail
  • 🔴 เราไม่ตาม redirect ⇒ tunnel ที่ตอบ 301/302 ไปหน้าอื่นจะถูกนับเป็น fail
  • 🔴 เราไม่มี IP ต้นทางคงที่ ⇒ อย่าตั้ง allowlist ที่ tunnel หรือที่ WAF

⇒ ใช้ HTTPS tunnel ที่ออก certificate จริงให้ ชี้เข้าเครื่องคุณ แล้วเอา URL นั้นไปตั้งเป็น deposit_url ของ endpoint ฝั่ง sandbox

bash
# ตัวอย่างสคีมา: <public https url> → http://127.0.0.1:3000/webhooks/pg

กับดักของการต่อ tunnel ​

อาการสาเหตุที่พบบ่อยที่สุด
verify ผ่านบนเครื่อง แต่ fail หลัง tunnelmiddleware ของ framework parse JSON ก่อนถึง handler ⇒ raw body หายไป · แก้ที่ route นั้นเป็น raw ก่อนทุก middleware อื่น
ได้ 400 ตลอดนาฬิกาเครื่องเดี้ยง — abs(now − t) > 300 วินาที · sync NTP
ไม่มีอะไรมาถึงเลยtunnel ตอบ 3xx หรือหน้า interstitial ของผู้ให้บริการ tunnel · ต้องตอบ 2xx ตรง ๆ
มาถึงแต่ช้าและโดน retrytunnel + เครื่อง dev ช้ากว่า 15 วินาที ⇒ ทำให้ handler ตอบ 200 ก่อนแล้วค่อยทำงาน

อย่าใช้ tunnel เป็นเกณฑ์วัดความเร็ว

เวลาตอบผ่าน tunnel ไม่ได้สะท้อน production · แต่ถ้า ผ่าน tunnel แล้วยังเกิน 3 วินาที แปลว่า handler ทำงานหนักเกินไปแน่นอน ให้ย้ายงานเข้าคิวก่อน

endpoint ทดสอบขั้นต่ำ (Node) ​

รันคู่กับ tunnel ได้ทันที — ตัดส่วน business logic ออกให้เหลือแค่โครงที่ถูกต้อง

js
import express from 'express';
import crypto from 'node:crypto';

const app = express();

app.post('/webhooks/pg', express.raw({ type: '*/*', limit: '512kb' }), async (req, res) => {
  const raw = req.body;
  const header = req.get('X-PG-Signature') || '';
  const parts = Object.fromEntries(
    header.split(',').map(s => s.trim()).filter(s => s.includes('='))
          .map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)]; }));
  const { kid, t, s: sig } = parts;
  if (!header.startsWith('v1,') || !kid || !t || !sig) return res.status(400).end();
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return res.status(400).end();

  const eventId = req.get('X-PG-Event-Id') || '';
  if (!/^evt_[A-Za-z0-9_]{10,80}$/.test(eventId)) return res.status(400).end();

  const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
  const pub = await getPublicKey(kid);                       // ดู /webhooks/signature
  if (!pub) return res.status(400).end();
  if (!crypto.verify(null, signed, pub, Buffer.from(sig, 'base64url')))
    return res.status(401).end();

  const event = JSON.parse(raw.toString('utf8'));
  if (event.id !== eventId) return res.status(400).end();
  if (event.livemode !== (process.env.NODE_ENV === 'production')) return res.status(400).end();

  // log สามค่านี้ทุกใบ — เวลาเปิด ticket จะถูกถามหา
  console.log(JSON.stringify({
    event_id: event.id, event_type: event.type,
    delivery_id: req.get('X-PG-Delivery-Id'), attempt: Number(req.get('X-PG-Attempt')),
    object_id: event.data.object.id, version: event.data.object.version ?? null,
    status: event.data.object.status ?? null,
  }));

  // helper นี้ทำ INSERT/dedupe inbox + INSERT outbox ใน DB transaction เดียว
  // ใช้ schema และ implementation จาก /webhooks/signature; เก็บ inbox ≥ 31 วัน
  await persistWebhookAtomically({ event, raw });
  return res.status(200).end();                              // ตอบหลัง commit เท่านั้น
});

app.listen(3000);

เช็กลิสต์ก่อนขึ้น production ​

ลายเซ็นและการรับ ​

  • [ ] verify Ed25519 บน raw body ก่อน parse · negative test: แก้ 1 ไบต์ ต้องได้ 401
  • [ ] parse X-PG-Signature แบบ key=value ไม่ใช่ตำแหน่ง · decode ลายเซ็นเป็น base64url
  • [ ] timestamp เกิน 300 วินาที → ปฏิเสธ
  • [ ] event.id ใน body ตรงกับ X-PG-Event-Id
  • [ ] ตรวจ livemode ให้ตรงกับ environment
  • [ ] cache public key ≤ 24 ชม. · เจอ kid ใหม่ → refetch 1 ครั้ง (throttle) · subscribe webhook.key.rotating และมีขั้นตอนรับ kid ใหม่
  • [ ] transactional inbox มี UNIQUE(event_id) เก็บ ≥ 31 วัน · ซ้ำ → ตอบ 200
  • [ ] ตอบ 2xx ภายใน < 3 วินาที · ไม่ตอบ 3xx · TLS valid · ไม่พึ่ง IP allowlist
  • [ ] verify ผ่าน test vector ครบทั้ง 3 ชุด

ลำดับและความถูกต้องของสถานะ ​

  • [ ] version guard เป็น atomic UPDATE ... WHERE version < ใหม่ ไม่ใช่ SELECT-then-UPDATE
  • [ ] ผ่าน POST /v1/test/webhooks/replay-out-of-order โดยผลลัพธ์ยังถูกทั้งเคส A และ B
  • [ ] balance_transaction dedupe ด้วย btxn_id + สะสม · ไม่ใช้ balance_after เขียนทับ
  • [ ] มี default: ในทุก switch ของ status / reasons[].code / balance_transaction.type / fee_breakdown[].component
  • [ ] มี handler ของ event ที่ปิดไม่ได้ครบ 5 ตัว (deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled)
  • [ ] expired / canceled เช็ค is_terminal ไม่ใช่ชื่อสถานะ — ทดสอบด้วยยอด 109.00

เงินและการกระทบยอด ​

  • [ ] เครดิตผู้ใช้ = amount_received_minor และ ผูกกับ credited_user_at IS NULL
  • [ ] เก็บ X/V/A/E แยกกัน + assert A = min(V,X), 0 ≤ fee ≤ A และ amount_received_minor − fee_total_minor = credited_amount_minor
  • [ ] ยอด 116.00 ได้ deposit.settled ใบเดียว, เครดิต A=X ครั้งเดียว, แสดง open/contact-support claim และไม่ auto-credit/refund E
  • [ ] เก็บ fee_breakdown[] ดิบ — parse ด้วย component
  • [ ] แยก pending_clearing / available ในบัญชีภายใน · คำนวณเงินถอนได้จาก available เท่านั้น
  • [ ] handle balance.cleared โดย ไม่บวกยอดซ้ำ
  • [ ] ฝั่งถอนอ่านยอดจาก filled_minor / remaining_minor ผ่าน version guard (ไม่ SUM ไม่ +=)
  • [ ] ปิดรายการถอนเมื่อ status = 'settled' และ remaining_minor = 0 เท่านั้น
  • [ ] มี reconcile job รายวัน ผ่าน GET /v1/pay/events?since_sequence= (🔴 บังคับ)

ปฏิบัติการ ​

  • [ ] alert เมื่อ verify ลายเซ็นไม่ผ่าน หรือ handler ตอบ non-2xx เกิน 1% ใน 5 นาที
  • [ ] log event.id + X-PG-Delivery-Id + X-PG-Attempt ทุกใบ (delivery headers ใช้เพื่อ observability เท่านั้น)
  • [ ] ตั้ง technical contact ที่มีคนอ่านจริง (ใช้แจ้งตอน endpoint ถูกปิดอัตโนมัติ)
  • [ ] 🔴 ไม่ log qr_payload และไม่เก็บลง DB · ไม่ log ข้อมูลบัญชีปลายทางแบบเต็ม
  • [ ] ทีม support มี playbook: under_review = รอตรวจ ไม่ใช่ error · ห้ามแนะนำให้ผู้ใช้โอนซ้ำ

ขั้นต่อไป ​

อ่านต่อเมื่อ
เช็กลิสต์ก่อน go-live (ฉบับเต็ม)ครบทุกหมวด ไม่ใช่เฉพาะ webhook
ลำดับไม่การันตี — version guardตกเทสต์ replay-out-of-order
ตรวจลายเซ็น Ed25519verify ไม่ผ่าน
Retry และ endpoint ล่มอยากรู้พฤติกรรมตอน endpoint ล่ม
ย้ายจากผู้ให้บริการเดิมกำลังย้ายระบบเดิมมา ต้องรันคู่กันชั่วคราว

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