Webhooks
รับ event แบบ HMAC-signed · ส่งซ้ำอัตโนมัติ 6 ครั้งใน ~7 ชม. · ส่งอีกครั้งจากแดชบอร์ด · แจ้งเตือนเมื่อ endpoint ล่ม
ตั้ง endpoint ของคุณที่ Settings → Webhooks — SlipBolt จะ POST event ทุกครั้งที่มีผลตรวจสลิปใหม่
Event types
มี 5 event · เลือก subscribe ได้ต่อ endpoint (ไม่เลือกเลย = ได้ slip.verified อย่างเดียว)
| Event | เมื่อไร | data.status ที่มากับ event |
|---|---|---|
slip.verified | สลิปผ่านการตรวจ | SUCCESS |
slip.duplicate | สลิปใบนี้ (เลขอ้างอิงเดิม) เคยตรวจไปแล้ว | DUPLICATE |
slip.fraud | สลิปปลอม / ถูกแก้ไข — ไม่ตรงข้อมูลธนาคาร | FRAUD |
slip.failed | ตรวจไม่ผ่านด้วยเหตุอื่น | INVALID · AMOUNT_MISMATCH · RECEIVER_MISMATCH |
verification.completed | งานของ POST /verify/async จบ (ทั้งตรวจเสร็จและล้ม) | ผลตรวจจริง · DUPLICATE เมื่อชนสลิปเดิม · ERROR เมื่อไม่ได้ตรวจ |
สถานะที่ ไม่มี event: PROVIDER_ERROR · QUOTA_EXCEEDED · และ INVALID ที่เกิดจากร้านยังไม่ได้ตั้งบัญชีรับเงิน (errorCode: NO_PAYEE_CONFIGURED)
— ตารางเต็มต่อสถานะอยู่ที่ Transaction status
ส่งรูปเดิมซ้ำ (cached: true) — ถ้ารูปไฟล์เดียวกันเคยตรวจผ่านช่องทางเดียวกันมาแล้ว (เช่น POST /verify ซ้ำภายใน 24 ชม.)
เราถือว่าเป็นการ retry ของคำขอเดิม ⇒ ตอบผลเดิม และ ไม่ส่ง event ซ้ำ (event ของครั้งแรกส่งไปแล้ว) ·
ถ้ารูปนั้นเคยใช้ผ่านช่องทางอื่น (LINE / แดชบอร์ด) มาก่อน = ใช้สลิปซ้ำจริง ⇒ ได้ slip.duplicate
สลิปปลอมมาเป็น slip.fraud แยกต่างหาก ไม่ได้รวมอยู่ใน slip.failed — ถ้าต้องการรู้ทันทีเมื่อมีคนส่งสลิปปลอม
ต้องติ๊ก slip.fraud ด้วย · ส่วน ระบบตรวจขัดข้องชั่วคราว (PROVIDER_ERROR) ไม่มี event —
เป็นปัญหาฝั่งเรา ไม่ใช่คำตัดสินเกี่ยวกับสลิป
ยังไม่มี event ฝั่งบัญชี/แพ็ก (เช่น subscription หรือโควตาใกล้เต็ม) — การแจ้งเตือนโควตา (80% และ 95%) ส่งทางอีเมล/LINE ไม่ใช่ webhook
Payload
Body เป็น JSON ตามนี้เสมอ — created เป็น unix seconds ไม่ใช่ ISO string
{
"event": "slip.verified",
"id": "evt_9f3c1a7b2d4e6f8a0b1c2d3e",
"created": 1781107927,
"data": {
"transactionId": "8f3c…",
"transRef": "015170106978META…",
"amount": 250.0,
"status": "SUCCESS",
"bankCode": "002",
"bankName": "ธนาคารกรุงเทพ",
"sender": "นาย ก. …",
"receiver": "ร้าน ข.",
"verifiedAt": "2026-06-17T03:21:06.412Z",
"latencyMs": 480
}
}
sender / receiver เป็น string ชื่อ ไม่ใช่ object · ค่าที่อ่านไม่ได้จากสลิปจะเป็น null · เป็นชื่อตามที่ธนาคารส่งมา นามสกุลเหลืออักษรตัวแรก (นาย สมชาย ใ) ไม่ใช่ชื่อเต็ม
ชื่อฟิลด์ใน webhook สั้นกว่า ผลจาก REST (POST /verify · GET /transactions) — เทียบกันตามนี้:
Webhook data.* | REST data.* | หมายเหตุ |
|---|---|---|
transactionId | id | ใช้กับ GET /api/v1/transactions/:id เพื่อดึงรายละเอียดเต็ม |
transRef · amount · status · bankCode · bankName | ชื่อเดียวกัน | bankCode เป็นรหัส 3 หลัก — ดู รหัสธนาคาร |
sender | senderName | |
receiver | receiverName | |
verifiedAt | createdAt | เวลาที่เราตรวจ (ISO-8601 UTC) — ไม่ใช่เวลาโอน · slip.duplicate / DUPLICATE = เวลาที่ตรวจใบแรก (รายการเดิม) |
latencyMs | latencyMs | ของคำขอที่ทำให้เกิด event · slip.duplicate = เวลาของคำขอซ้ำครั้งนั้น (ไม่ใช่ของใบแรก) · ชนแคชรูป = 0 |
| — | verified · source · transDate · errorCode · errorMessage · เลขบัญชี · ธนาคารผู้รับ | ไม่มีใน webhook — ดึงผ่าน GET /transactions/:id (เช่น เช็ค errorCode: "STALE_SLIP") |
Header ที่มากับทุก delivery:
| Header | ค่า |
|---|---|
X-Slipbolt-Event | ชื่อ event เช่น slip.verified |
X-Slipbolt-Delivery | id ของ endpoint ปลายทาง (UUID · ค่าเดียวกันทุก event ของ endpoint นั้น) — ไม่ใช่ id ของการส่งแต่ละครั้ง · กันประมวลผลซ้ำให้ใช้ id ใน body (evt_…) |
X-Slipbolt-Event-Id | id ของ event (evt_…) — ค่าเดียวกับ id ใน body · คงที่ทุกครั้งที่ส่งซ้ำ ใช้กันประมวลผลซ้ำได้โดยไม่ต้อง parse body |
X-Slipbolt-Delivery-Attempt | ครั้งที่ส่ง event นี้ (1 = ครั้งแรก · การกด "ส่งอีกครั้ง" นับต่อจากเดิม) |
X-Slipbolt-Signature | t=<unix>,v1=<hex> — ดูหัวข้อถัดไป |
User-Agent | Slipbolt-Webhooks/1.0 |
verification.completed
ส่งเมื่องาน POST /verify/async จบ — ต้อง ติ๊ก event นี้เอง (ไม่ได้เปิดให้อัตโนมัติ) · data มีฟิลด์ชุดเดียวกับ slip.* บวก:
| ฟิลด์ | ความหมาย |
|---|---|
verificationId | vrf_… ที่ได้จาก 202 — ใช้จับคู่กับคำขอของคุณ |
verificationStatus | COMPLETED (ตรวจจบ ดู status) · FAILED (ไม่ได้ตรวจ ดู error) |
duplicate · cached · quotaCharged | ค่าเดียวกับ POST /verify |
error | { httpStatus, code, message } เมื่อ FAILED · ไม่งั้น null |
{
"event": "verification.completed",
"id": "evt_9c1f0a…",
"created": 1792640466,
"data": {
"transactionId": "8f3c2a1e-…",
"transRef": "015170106978BTF01234",
"amount": 250,
"status": "SUCCESS",
"bankCode": "002",
"bankName": "ธนาคารกรุงเทพ",
"sender": "นาย ก. ตัวอย่าง",
"receiver": "ร้าน ข.",
"verifiedAt": "2026-10-22T03:21:06.412Z",
"latencyMs": 412,
"verificationId": "vrf_3f9a1c0b7e2d4a6f8b1c2d3e",
"verificationStatus": "COMPLETED",
"duplicate": false,
"cached": false,
"quotaCharged": true,
"error": null
}
}
FAILED ก่อนมีรายการสลิป (เช่น โควตาหมด) → transactionId/ฟิลด์สลิปเป็น null และ status: "ERROR" · ดึงผลเต็มได้ที่ GET /verifications/{verificationId}
Verify signature
X-Slipbolt-Signature: t=1781107927,v1=<hex>
v1 = HMAC-SHA256(secret, ts + "." + raw_body) โดย ts คือตัวเลขหลัง t= (เช่น 1781107927)
— ดู secret ของแต่ละ endpoint ที่ Settings → Webhooks
ตัวอย่าง: header t=1781107927,v1=… กับ body {"event":"slip.verified",…} ⇒ สตริงที่เอาไปเซ็นคือ
1781107927.{"event":"slip.verified",…} (ตัวเลข + จุด + body ดิบ) — ตรงกับโค้ดทุกภาษาด้านล่าง
สตริงที่เซ็นคือ ts + "." + raw_body — ไม่มี t= นำหน้า · และต้องใช้ body ดิบตามที่ได้รับ
ไม่ใช่ JSON ที่ parse แล้วแปลงกลับ (ลำดับคีย์/ช่องว่าง/อักษรไทยเปลี่ยน = signature ไม่ตรงทุกใบ)
— framework ส่วนใหญ่ parse body ให้อัตโนมัติ ต้องปิดเฉพาะ route นี้ตามตัวอย่าง
ตัวอย่าง handler ครบวงจร — ตรวจ signature · กัน replay · ตอบเร็ว · กันประมวลผลซ้ำ:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.SLIPBOLT_WEBHOOK_SECRET;
const app = express();
function verifySignature(rawBody, header, secret) {
const pairs = (header ?? '').split(',').map((p) => p.split('='));
const ts = pairs.find(([k]) => k === 't')?.[1] ?? '';
const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!/^\d+$/.test(ts) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // กัน replay เกิน 5 นาที
const expected = createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest();
// ช่วงหมุน secret มี v1 สองตัว — ผ่านถ้าตรงตัวใดตัวหนึ่ง
return sigs.some((v) => {
const received = Buffer.from(v, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// ⚠️ express.raw() เฉพาะ route นี้ — ถ้า express.json() ได้ body ไปก่อน signature จะไม่ตรง
app.post('/webhooks/slipbolt', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('X-Slipbolt-Signature'), SECRET)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body);
// ตอบ 2xx ก่อน แล้วค่อยทำงานหนัก — เกิน 10 วินาทีเรานับว่าล้มเหลวและจะส่งซ้ำ
res.sendStatus(200);
// event.id เดิมอาจมาซ้ำได้ตอน retry → เก็บ id ที่ทำแล้วไว้ แล้วข้ามถ้าเคยเห็น
if (event.event === 'slip.verified') {
const { transRef, amount, sender } = event.data;
console.log(`รับเงิน ${amount} บาท จาก ${sender} · ref ${transRef}`);
}
});
app.listen(3000);
ส่งแบบ at-least-once — การ retry และการกดส่งอีกครั้ง ทำให้ event เดิม (id เดิม · อยู่ใน header X-Slipbolt-Event-Id ด้วย) มาถึงได้มากกว่าหนึ่งครั้ง
เก็บ id ที่ประมวลผลแล้วไว้ (หรือใช้ data.transactionId เป็น unique key ในตารางออเดอร์) กันปล่อยของซ้ำ
หมุน secret
กด หมุน secret (ไอคอนลูกศรวนข้าง signing secret) ที่ Settings → Webhooks — ได้ secret ใหม่ทันที และ secret เดิมยังเซ็นคู่ไปอีก 24 ชั่วโมง ระหว่างนั้น header จะมีสองลายเซ็น:
X-Slipbolt-Signature: t=1781107927,v1=<ลายเซ็นด้วย secret ใหม่>,v1=<ลายเซ็นด้วย secret เดิม>
- หมุน secret → คัดลอกค่าใหม่
- เปลี่ยนค่าในเซิร์ฟเวอร์ของคุณ (ระหว่างนี้ webhook ยังตรวจผ่านไม่ว่าเซิร์ฟเวอร์จะใช้ secret ไหน)
- กด ยกเลิก secret เดิมทันที — หรือปล่อยให้หมดอายุเองใน 24 ชม.
โค้ดตรวจต้อง ยอมรับถ้าตรง v1 ตัวใดตัวหนึ่ง (ตัวอย่างข้างบนทำแล้ว) · ถ้าโค้ดของคุณอ่านแค่ v1 ตัวเดียว
ให้แก้ก่อนหมุน — หรือเปลี่ยน secret ในเซิร์ฟเวอร์แล้วกดยกเลิก secret เดิมทันทีหลังหมุน · ทุกการหมุน/ยกเลิกถูกบันทึกใน Audit log
Retry policy
- 2xx = สำเร็จ · อย่างอื่นทั้งหมดถือว่าล้มเหลว (รวม
3xxเพราะเราไม่ตาม redirect) - 4xx ก็ retry — ไม่ได้ยกเว้นเหมือนบางเจ้า · ถ้าไม่อยากโดนยิงซ้ำ ตอบ 2xx แล้วค่อยไปคัดทิ้งฝั่งคุณ
- timeout 10 วินาที ต่อครั้ง — ตอบช้ากว่านี้นับเป็นล้มเหลว · ตอบ 2xx ทันทีแล้วค่อยประมวลผลทีหลัง
- ลองทั้งหมด 6 ครั้ง นับรวมครั้งแรก · ช่วงรอก่อนครั้งถัดไป (±10% กันยิงพร้อมกัน):
| ครั้งที่ | ส่งหลังครั้งก่อนหน้า |
|---|---|
| 1 | ทันที |
| 2 | 5 วินาที |
| 3 | 30 วินาที |
| 4 | 5 นาที |
| 5 | 1 ชั่วโมง |
| 6 | 6 ชั่วโมง |
รวมราว 7 ชั่วโมง — ระบบหลังบ้านล่ม/deploy ช่วงสั้น ๆ ไม่ทำให้พลาด event · ส่งถึงครั้งไหนก็หยุดทันที
- ครบ 6 ครั้งแล้วยังล้ม = ส่งไม่สำเร็จ (dead-letter) — อยู่ในแท็บ ส่งไม่สำเร็จ ที่ Settings → Webhooks รอให้กด
ส่งอีกครั้ง · ข้อมูลสลิปยังดึงย้อนหลังได้ด้วย
GET /api/v1/transactionsเสมอ - ทุกครั้งที่ยิงถูกบันทึกในประวัติ (เก็บ 30 วัน) — ดูได้ที่ Settings → Webhooks
endpoint ที่ล้มต่อเนื่องนานกว่า 3 วัน (และล้มสะสมอย่างน้อย 25 ครั้ง) จะถูกปิดอัตโนมัติ (enabled = false)
และหยุดรับ event ใหม่จนกว่าจะเปิดเอง — เราแจ้งเจ้าของร้านทางอีเมล (และ LINE ถ้าผูกไว้) ทันทีที่ปิด ·
ส่งสำเร็จหนึ่งครั้ง = เริ่มนับใหม่
ส่งอีกครั้ง
แท็บ Events ที่ Settings → Webhooks แสดงสถานะของทุก event ต่อ endpoint — กำลังลองส่ง (พร้อมเวลาที่จะลองครั้งถัดไป) · ส่งถึงแล้ว · ส่งไม่สำเร็จ · กดดูได้ทั้งประวัติทุกครั้งที่ส่งและ body ที่ส่งออกไป
- กด ส่งอีกครั้ง ทีละรายการ หรือเลือกหลายรายการ (สูงสุด 50 ต่อครั้ง)
- ได้ event
idเดิม แต่ ลายเซ็นใหม่ (tเป็นเวลาปัจจุบัน — ผ่านเกณฑ์ 5 นาทีของฝั่งคุณ) - ยิง ครั้งเดียว ไม่ไล่ตาราง retry ใหม่ — เห็นผลในไม่กี่วินาที · event ที่ยังอยู่ในคิว retry กดส่งอีกครั้งไม่ได้
- ส่งไป endpoint ที่ถูกปิดอยู่ได้ (ไว้ลองหลังแก้ระบบ ก่อนเปิดใช้จริง) · ทุกครั้งที่กดถูกบันทึกใน Audit log
- เฉพาะเจ้าของร้านเท่านั้นที่กดได้
แจ้งเตือนเมื่อส่งไม่ถึง
เราแจ้งเจ้าของร้านทางอีเมล (และ LINE ถ้าผูกบัญชีไว้) เมื่อ:
- endpoint ล้มติดกันตั้งแต่ 5 ครั้งและนานกว่า 15 นาที
- มี event ที่ลองครบ 6 ครั้งแล้วยังไม่ถึง
- endpoint ถูกปิดอัตโนมัติ
ไม่เกิน 1 ครั้งต่อชั่วโมงต่อ endpoint (ยกเว้นตอนถูกปิด) · ข้อความแจ้งเตือนไม่มีข้อมูลบนสลิป และโชว์แค่โดเมนของ endpoint
แจ้งเตือนเข้า Discord / Telegram (ไม่ต้องเขียนโค้ด)
วางลิงก์ของ Discord หรือ Telegram ลงช่อง Endpoint URL ได้เลย — ระบบจำลิงก์ได้เอง แล้วส่งเป็น ข้อความพร้อมอ่าน (ยอด · ผู้โอน · ธนาคาร · เลขอ้างอิง · เวลา) แทน JSON ไม่ต้องมีเซิร์ฟเวอร์ของตัวเอง
Discord
- ในเซิร์ฟเวอร์ Discord → Server Settings → Integrations → Webhooks → New Webhook
- เลือกห้องที่จะให้แจ้งเตือน → Copy Webhook URL
- วางลงช่อง Endpoint URL (รูปแบบ
https://discord.com/api/webhooks/…)
Telegram
- คุยกับ @BotFather →
/newbot→ ได้ token หน้าตา123456:ABC-… - เชิญบอทเข้ากลุ่มที่จะให้แจ้งเตือน แล้วพิมพ์อะไรก็ได้ในกลุ่มหนึ่งข้อความ
- เปิด
https://api.telegram.org/bot<TOKEN>/getUpdatesในเบราว์เซอร์ → หา"chat":{"id": …}(กลุ่มเป็นเลขติดลบ เช่น-1001234567890) - วางลิงก์นี้ลงช่อง Endpoint URL:
https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>
ลิงก์ Discord / Telegram คือรหัสผ่าน — ใครได้ลิงก์ไปก็โพสต์เข้าห้อง/สั่งบอทได้ ในหน้าตั้งค่าเราโชว์แบบ ซ่อน token ไว้ให้ · ปลายทางชนิดนี้ ไม่มีลายเซ็น HMAC (Discord/Telegram ไม่ได้ตรวจ) และได้ข้อความ สำหรับคนอ่าน ไม่ใช่ JSON ตามหัวข้อ Payload — ถ้าต้องการให้ระบบของคุณประมวลผลต่อ ให้ใช้ endpoint ของคุณเอง
ปลายทางที่ใช้ได้
- ต้องเป็น
https://และเป็นที่อยู่สาธารณะ — localhost / IP ภายในองค์กร / ที่อยู่ของ cloud metadata ถูกปฏิเสธ ทั้งตอนบันทึกและตอนส่ง (ตรวจ IP จริงหลัง resolve DNS) - ไม่ตาม redirect — ปลายทางตอบ
3xxนับเป็นส่งไม่สำเร็จ · ใส่ URL สุดท้ายตรง ๆ
ทดสอบ endpoint
กด ส่ง event ตัวอย่าง ที่ Settings → Webhooks — ระบบยิง slip.verified ปลอมหนึ่งใบไปที่ endpoint นั้น
โดยไม่สนว่า subscribe อะไรไว้และไม่แตะโควตา · ใช้ตรวจว่า signature ฝั่งคุณ verify ผ่านจริงก่อนขึ้น production
ถัดไป
- API Reference — endpoint + error code ทั้งหมด
- Security & PDPA — การเก็บ secret + สิทธิ์เจ้าของข้อมูล
