core

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

JSON
{
  "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.*หมายเหตุ
transactionIdidใช้กับ GET /api/v1/transactions/:id เพื่อดึงรายละเอียดเต็ม
transRef · amount · status · bankCode · bankNameชื่อเดียวกันbankCode เป็นรหัส 3 หลัก — ดู รหัสธนาคาร
sendersenderName
receiverreceiverName
verifiedAtcreatedAtเวลาที่เราตรวจ (ISO-8601 UTC) — ไม่ใช่เวลาโอน · slip.duplicate / DUPLICATE = เวลาที่ตรวจใบแรก (รายการเดิม)
latencyMslatencyMsของคำขอที่ทำให้เกิด 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-Deliveryid ของ endpoint ปลายทาง (UUID · ค่าเดียวกันทุก event ของ endpoint นั้น) — ไม่ใช่ id ของการส่งแต่ละครั้ง · กันประมวลผลซ้ำให้ใช้ id ใน body (evt_…)
X-Slipbolt-Event-Idid ของ event (evt_…) — ค่าเดียวกับ id ใน body · คงที่ทุกครั้งที่ส่งซ้ำ ใช้กันประมวลผลซ้ำได้โดยไม่ต้อง parse body
X-Slipbolt-Delivery-Attemptครั้งที่ส่ง event นี้ (1 = ครั้งแรก · การกด "ส่งอีกครั้ง" นับต่อจากเดิม)
X-Slipbolt-Signaturet=<unix>,v1=<hex> — ดูหัวข้อถัดไป
User-AgentSlipbolt-Webhooks/1.0

verification.completed

ส่งเมื่องาน POST /verify/async จบ — ต้อง ติ๊ก event นี้เอง (ไม่ได้เปิดให้อัตโนมัติ) · data มีฟิลด์ชุดเดียวกับ slip.* บวก:

ฟิลด์ความหมาย
verificationIdvrf_… ที่ได้จาก 202 — ใช้จับคู่กับคำขอของคุณ
verificationStatusCOMPLETED (ตรวจจบ ดู status) · FAILED (ไม่ได้ตรวจ ดู error)
duplicate · cached · quotaChargedค่าเดียวกับ POST /verify
error{ httpStatus, code, message } เมื่อ FAILED · ไม่งั้น null
JSON
{
  "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

HTTP
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 จะมีสองลายเซ็น:

HTTP
X-Slipbolt-Signature: t=1781107927,v1=<ลายเซ็นด้วย secret ใหม่>,v1=<ลายเซ็นด้วย secret เดิม>
  1. หมุน secret → คัดลอกค่าใหม่
  2. เปลี่ยนค่าในเซิร์ฟเวอร์ของคุณ (ระหว่างนี้ webhook ยังตรวจผ่านไม่ว่าเซิร์ฟเวอร์จะใช้ secret ไหน)
  3. กด ยกเลิก secret เดิมทันที — หรือปล่อยให้หมดอายุเองใน 24 ชม.

โค้ดตรวจต้อง ยอมรับถ้าตรง v1 ตัวใดตัวหนึ่ง (ตัวอย่างข้างบนทำแล้ว) · ถ้าโค้ดของคุณอ่านแค่ v1 ตัวเดียว ให้แก้ก่อนหมุน — หรือเปลี่ยน secret ในเซิร์ฟเวอร์แล้วกดยกเลิก secret เดิมทันทีหลังหมุน · ทุกการหมุน/ยกเลิกถูกบันทึกใน Audit log

Retry policy

  • 2xx = สำเร็จ · อย่างอื่นทั้งหมดถือว่าล้มเหลว (รวม 3xx เพราะเราไม่ตาม redirect)
  • 4xx ก็ retry — ไม่ได้ยกเว้นเหมือนบางเจ้า · ถ้าไม่อยากโดนยิงซ้ำ ตอบ 2xx แล้วค่อยไปคัดทิ้งฝั่งคุณ
  • timeout 10 วินาที ต่อครั้ง — ตอบช้ากว่านี้นับเป็นล้มเหลว · ตอบ 2xx ทันทีแล้วค่อยประมวลผลทีหลัง
  • ลองทั้งหมด 6 ครั้ง นับรวมครั้งแรก · ช่วงรอก่อนครั้งถัดไป (±10% กันยิงพร้อมกัน):
ครั้งที่ส่งหลังครั้งก่อนหน้า
1ทันที
25 วินาที
330 วินาที
45 นาที
51 ชั่วโมง
66 ชั่วโมง

รวมราว 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

  1. ในเซิร์ฟเวอร์ Discord → Server Settings → Integrations → Webhooks → New Webhook
  2. เลือกห้องที่จะให้แจ้งเตือน → Copy Webhook URL
  3. วางลงช่อง Endpoint URL (รูปแบบ https://discord.com/api/webhooks/…)

Telegram

  1. คุยกับ @BotFather → /newbot → ได้ token หน้าตา 123456:ABC-…
  2. เชิญบอทเข้ากลุ่มที่จะให้แจ้งเตือน แล้วพิมพ์อะไรก็ได้ในกลุ่มหนึ่งข้อความ
  3. เปิด https://api.telegram.org/bot<TOKEN>/getUpdates ในเบราว์เซอร์ → หา "chat":{"id": …} (กลุ่มเป็นเลขติดลบ เช่น -1001234567890)
  4. วางลิงก์นี้ลงช่อง Endpoint URL:
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 + สิทธิ์เจ้าของข้อมูล