Webhooks
HMAC-signed events · 6 automatic attempts over ~7 h · resend from the dashboard · alerts when an endpoint is down
Set your endpoint at Settings → Webhooks — SlipBolt POSTs an event every time there is a new slip result
Event types
There are 5 events · choose which ones each endpoint subscribes to (choose none = slip.verified only)
| Event | When | data.status on the event |
|---|---|---|
slip.verified | The slip passed verification | SUCCESS |
slip.duplicate | This slip (same reference number) was already verified | DUPLICATE |
slip.fraud | Fake / edited slip — does not match bank records | FRAUD |
slip.failed | Failed for another reason | INVALID · AMOUNT_MISMATCH · RECEIVER_MISMATCH |
verification.completed | A POST /verify/async job finished (checked or failed) | The real result · DUPLICATE on a reused slip · ERROR when not checked |
Statuses with no event: PROVIDER_ERROR · QUOTA_EXCEEDED · and an INVALID caused by the shop having no receiving account (errorCode: NO_PAYEE_CONFIGURED)
— the full per-status table is at Transaction status
Re-sending the same image (cached: true) — if the identical image file was already checked through the same channel
(e.g. POST /verify again within 24 h), we treat it as a retry of the original request ⇒ you get the original result and
no new event (the first request's event was already sent) · if that image was used through another channel first
(LINE / dashboard), it is a genuine reuse ⇒ you get slip.duplicate
Fake slips arrive as a separate slip.fraud event — they are not included in slip.failed. If you want to know
the moment someone sends a fake slip, tick slip.fraud as well · temporary verification outages (PROVIDER_ERROR) have no event —
that is a problem on our side, not a verdict about the slip
There are no account/plan events yet (e.g. subscription or quota nearly full) — quota warnings (80% and 95%) are sent by email/LINE, not webhook
Payload
The body is always this JSON — created is unix seconds, not an 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 are name strings, not objects · values that cannot be read from the slip are null · they are the names as the bank sends them, surname cut to its first letter (นาย สมชาย ใ) — not full names
Webhook field names are shorter than the REST result (POST /verify · GET /transactions) — they map like this:
Webhook data.* | REST data.* | Note |
|---|---|---|
transactionId | id | Use it with GET /api/v1/transactions/:id to fetch the full details |
transRef · amount · status · bankCode · bankName | same name | bankCode is a 3-digit code — see Bank codes |
sender | senderName | |
receiver | receiverName | |
verifiedAt | createdAt | When we checked it (ISO-8601 UTC) — not the transfer time · for slip.duplicate / DUPLICATE it is when the original slip was first checked |
latencyMs | latencyMs | Of the request that triggered the event · for slip.duplicate it is that repeat request's time (not the original check's) · image-cache hit = 0 |
| — | verified · source · transDate · errorCode · errorMessage · account numbers · receiver's bank | Not in the webhook — fetch via GET /transactions/:id (e.g. to check errorCode: "STALE_SLIP") |
Headers sent with every delivery:
| Header | Value |
|---|---|
X-Slipbolt-Event | Event name, e.g. slip.verified |
X-Slipbolt-Delivery | ID of the destination endpoint (UUID · the same on every event for that endpoint) — not an id for the individual delivery · to de-duplicate, use the id in the body (evt_…) |
X-Slipbolt-Event-Id | The event id (evt_…) — same value as id in the body · stays the same on every retry and resend, so you can de-duplicate without parsing the body |
X-Slipbolt-Delivery-Attempt | Which attempt this is for the event (1 = first · a "Resend" continues the count) |
X-Slipbolt-Signature | t=<unix>,v1=<hex> — see the next section |
User-Agent | Slipbolt-Webhooks/1.0 |
verification.completed
Sent when a POST /verify/async job finishes — you must tick this event yourself (it is not on by default) · data has the same fields as slip.* plus:
| Field | Meaning |
|---|---|
verificationId | The vrf_… from the 202 — match it to your request |
verificationStatus | COMPLETED (checked — see status) · FAILED (not checked — see error) |
duplicate · cached · quotaCharged | Same values as POST /verify |
error | { httpStatus, code, message } when FAILED · otherwise null |
{
"event": "verification.completed",
"id": "evt_9c1f0a…",
"created": 1792640466,
"data": {
"transactionId": "8f3c2a1e-…",
"transRef": "015170106978BTF01234",
"amount": 250,
"status": "SUCCESS",
"bankCode": "002",
"bankName": "Bangkok Bank",
"sender": "Mr. A Example",
"receiver": "Shop B",
"verifiedAt": "2026-10-22T03:21:06.412Z",
"latencyMs": 412,
"verificationId": "vrf_3f9a1c0b7e2d4a6f8b1c2d3e",
"verificationStatus": "COMPLETED",
"duplicate": false,
"cached": false,
"quotaCharged": true,
"error": null
}
}
FAILED before a slip record existed (e.g. quota used up) → transactionId/slip fields are null and status: "ERROR" · full result at GET /verifications/{verificationId}
Verify signature
X-Slipbolt-Signature: t=1781107927,v1=<hex>
v1 = HMAC-SHA256(secret, ts + "." + raw_body) where ts is the number after t= (e.g. 1781107927)
— find each endpoint's secret at Settings → Webhooks
Example: header t=1781107927,v1=… with body {"event":"slip.verified",…} ⇒ the string that gets signed is
1781107927.{"event":"slip.verified",…} (number + dot + raw body) — exactly what every code sample below does
The signed string is ts + "." + raw_body — no t= prefix · and it must be the raw body exactly as received,
not JSON that was parsed and re-serialised (a change in key order / whitespace / Thai characters = every signature fails)
— most frameworks parse the body automatically, so turn that off for this route only, as in the samples
A complete handler — verifies the signature · blocks replays · responds fast · avoids double-processing:
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; // reject replays older than 5 minutes
const expected = createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest();
// while a secret is being rotated there are two v1 values — accept if either matches
return sigs.some((v) => {
const received = Buffer.from(v, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// ⚠️ express.raw() on this route only — if express.json() gets the body first, the signature won't match
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);
// Respond 2xx first, do the heavy work after — over 10 seconds counts as a failure and is retried
res.sendStatus(200);
// The same event.id can arrive again on retry → keep the ids you've processed and skip repeats
if (event.event === 'slip.verified') {
const { transRef, amount, sender } = event.data;
console.log(`Received ${amount} THB from ${sender} · ref ${transRef}`);
}
});
app.listen(3000);
Delivery is at-least-once — retries and manual resends mean the same event (same id, also in the X-Slipbolt-Event-Id header) can arrive more than once.
Keep the ids you've processed (or use data.transactionId as a unique key in your orders table) so you never release an order twice
Rotating the secret
Press Rotate secret (the circular-arrow icon next to the signing secret) under Settings → Webhooks — you get a new secret right away and the old secret keeps signing alongside it for 24 hours. During that window the header carries two signatures:
X-Slipbolt-Signature: t=1781107927,v1=<signed with the new secret>,v1=<signed with the old secret>
- Rotate → copy the new value
- Update your server (webhooks keep verifying in the meantime, whichever secret your server holds)
- Press Revoke the previous secret now — or let it expire on its own after 24 h
Your check must accept a match on any v1 (the samples above do) · if your code reads a single v1 only,
fix it before rotating — or switch the secret on your server and revoke the old one right after rotating · every rotation/revocation is recorded in the Audit log
Retry policy
- 2xx = success · anything else counts as a failure (including
3xx— we don't follow redirects) - 4xx is retried too — no exception like some providers make · if you don't want repeat calls, respond 2xx and discard on your side
- 10-second timeout per attempt — slower counts as a failure · respond 2xx right away and do the work afterwards
- 6 attempts in total, counting the first · the wait before each next attempt (±10% so endpoints aren't hit all at once):
| Attempt | Sent after the previous one |
|---|---|
| 1 | immediately |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
About 7 hours in total — a short outage or deploy on your side doesn't lose events · retries stop as soon as one gets through
- Still failing after all 6 = failed (dead-letter) — it waits in the Failed tab under Settings → Webhooks for you to
resend · the slip data itself is always available via
GET /api/v1/transactions - Every attempt is recorded in the history (kept 30 days) — see Settings → Webhooks
An endpoint that keeps failing for more than 3 days (with at least 25 failures) is disabled automatically (enabled = false)
and stops receiving new events until you re-enable it — the shop owner is told by email (and LINE when linked) the moment it happens ·
one successful delivery resets the count
Resending events
The Events table under Settings → Webhooks shows the state of every event per endpoint — Retrying (with the time of the next try) · Delivered · Failed · open one to see every attempt and the body that was sent
- Press Resend on one event, or select several (up to 50 at a time)
- You get the same event
idwith a fresh signature (tis the current time — it passes your 5-minute check) - It is sent once, not on the full retry schedule — you see the result within seconds · events still in the retry queue can't be resent
- You can resend to a disabled endpoint (to test a fix before switching it back on) · every resend is recorded in the Audit log
- Only the shop owner can resend
Alerts when deliveries fail
The shop owner is notified by email (and LINE when the account is linked) when:
- an endpoint has failed 5+ times in a row for more than 15 minutes
- an event used up all 6 attempts without getting through
- an endpoint was disabled automatically
At most once per hour per endpoint (except when it's disabled) · alerts never include slip data and show only the endpoint's domain
Alerts to Discord / Telegram (no code)
Paste a Discord or Telegram link into the Endpoint URL field — the system recognises it and sends a ready-to-read message (amount · sender · bank · reference · time) instead of JSON, so you need no server of your own
Discord
- In your Discord server → Server Settings → Integrations → Webhooks → New Webhook
- Pick the channel to alert → Copy Webhook URL
- Paste it into Endpoint URL (format
https://discord.com/api/webhooks/…)
Telegram
- Talk to @BotFather →
/newbot→ you get a token like123456:ABC-… - Invite the bot into the group to alert, then post any message in that group
- Open
https://api.telegram.org/bot<TOKEN>/getUpdatesin a browser → find"chat":{"id": …}(groups are negative numbers, e.g.-1001234567890) - Paste this link into Endpoint URL:
https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>
A Discord / Telegram link is a password — anyone who has it can post into the channel / command the bot. The settings page shows it with the token hidden · these destinations get no HMAC signature (Discord/Telegram don't check one) and receive a message meant for people, not the JSON from the Payload section — if your system needs to process events, use your own endpoint
Allowed destinations
- Must be
https://and a public address — localhost / private network IPs / cloud-metadata addresses are rejected both when saving and when sending (the real IP is checked after DNS resolution) - Redirects are not followed — a
3xxresponse counts as a failed delivery · enter the final URL directly
Testing an endpoint
Press Send test event at Settings → Webhooks — the system sends one fake slip.verified to that endpoint,
regardless of its subscriptions and without touching your quota · use it to confirm your signature check really passes before going to production
Next
- API Reference — every endpoint + error code
- Security & PDPA — how secrets are stored + data-subject rights
