ทดสอบ webhook
ลำดับที่เสียเวลาน้อยที่สุด: verify offline → ยิงจริงจาก sandbox → ทดสอบเคสสลับลำดับ → เช็กลิสต์
ขั้นที่ 0 — verify ให้ผ่าน offline ก่อน (ไม่ต้องมี endpoint)
คุณ ปลอม webhook ของตัวเองไม่ได้ — และนั่นคือเรื่องดี
เจ้าอื่นที่ใช้ HMAC คุณสร้าง payload ปลอมพร้อมลายเซ็นที่ถูกต้องเองได้ เพราะคุณถือ secret เดียวกับเขา
ของเราเซ็นด้วย Ed25519 — private key อยู่ฝั่งเราเท่านั้น ⇒ ไม่มีทางที่สคริปต์ในเครื่องคุณจะสร้าง event ที่ verify ผ่านได้
⇒ ทดสอบ ฟังก์ชัน verify ด้วย test vector · ทดสอบ ทั้งเส้น ด้วย sandbox
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-Id | 400 |
| ส่งซ้ำ 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.00 | 10000 | matched → ตรวจสลิปอัตโนมัติ → settled ใน ~5 วินาที |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติ/ปฏิเสธ) |
| 102.00 | 10200 | → expired → อัปสลิปย้อนหลังอัตโนมัติที่ +60 วินาที → under_review['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed ที่ +60 วินาที (ทดสอบ event ที่ปิดไม่ได้ + เคส B ของ ordering) |
| 104.00 | 10400 | ช่องทางสำรอง 2 legs (TTL ~4 นาที) |
| 105.00 | 10500 | awaiting_liquidity เต็ม 25 วินาที แล้วค่อยได้ปลายทาง |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | รายการถอนฝั่งตรงข้ามถูกซอยเป็น 3 ก้อน |
| 108.00 | 10800 | โอนซ้ำ — settled แล้วมี deposit.overpaid ที่ +45 วินาที |
| 109.00 | 10900 | กด cancel แล้วเผลอโอน — canceled(is_terminal:false) → อัปสลิปย้อนหลัง → under_review['CANCELED_ORDER_SLIP'] |
| 110.00 | 11000 | ALLOCATION_CONFLICT — สลิปย้อนหลังที่ยอดส่วนนั้นถูกจับคู่ให้คนอื่นไปแล้ว + withdrawal.disputed ฝั่งตรงข้าม |
| 111.00 | 11100 | clean short-pay อัตโนมัติ → settled ที่ A=V<X, partial:true, settled_via:'auto' · ไม่เข้า review/ไม่เรียก force-complete |
| 112.00 | 11200 | หมดหน้าต่างรับสลิปย้อนหลัง → 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing) |
| 114.00 | 11400 | หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ภายใน 60 วินาที |
| 115.00 | 11500 | idempotency ชนกัน — key เดิม body ต่าง |
| 116.00 | 11600 | same-slip overpay — X=11600, V=12100, อีก 5 มิติผ่าน → deposit.settled ใบเดียวที่ A=X, E=500, open/contact-support flag, ไม่มี auto-refund |
# ตัวอย่าง: ยิงเส้น 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 สลับลำดับ
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
# ตัวอย่างสคีมา: <public https url> → http://127.0.0.1:3000/webhooks/pgกับดักของการต่อ tunnel
| อาการ | สาเหตุที่พบบ่อยที่สุด |
|---|---|
| verify ผ่านบนเครื่อง แต่ fail หลัง tunnel | middleware ของ framework parse JSON ก่อนถึง handler ⇒ raw body หายไป · แก้ที่ route นั้นเป็น raw ก่อนทุก middleware อื่น |
ได้ 400 ตลอด | นาฬิกาเครื่องเดี้ยง — abs(now − t) > 300 วินาที · sync NTP |
| ไม่มีอะไรมาถึงเลย | tunnel ตอบ 3xx หรือหน้า interstitial ของผู้ให้บริการ tunnel · ต้องตอบ 2xx ตรง ๆ |
| มาถึงแต่ช้าและโดน retry | tunnel + เครื่อง dev ช้ากว่า 15 วินาที ⇒ ทำให้ handler ตอบ 200 ก่อนแล้วค่อยทำงาน |
อย่าใช้ tunnel เป็นเกณฑ์วัดความเร็ว
เวลาตอบผ่าน tunnel ไม่ได้สะท้อน production · แต่ถ้า ผ่าน tunnel แล้วยังเกิน 3 วินาที แปลว่า handler ทำงานหนักเกินไปแน่นอน ให้ย้ายงานเข้าคิวก่อน
endpoint ทดสอบขั้นต่ำ (Node)
รันคู่กับ tunnel ได้ทันที — ตัดส่วน business logic ออกให้เหลือแค่โครงที่ถูกต้อง
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) · subscribewebhook.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_transactiondedupe ด้วย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แยกกัน + assertA = 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/refundE - [ ] เก็บ
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 |
| ตรวจลายเซ็น Ed25519 | verify ไม่ผ่าน |
| Retry และ endpoint ล่ม | อยากรู้พฤติกรรมตอน endpoint ล่ม |
| ย้ายจากผู้ให้บริการเดิม | กำลังย้ายระบบเดิมมา ต้องรันคู่กันชั่วคราว |
