core

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)

EventWhendata.status on the event
slip.verifiedThe slip passed verificationSUCCESS
slip.duplicateThis slip (same reference number) was already verifiedDUPLICATE
slip.fraudFake / edited slip — does not match bank recordsFRAUD
slip.failedFailed for another reasonINVALID · AMOUNT_MISMATCH · RECEIVER_MISMATCH
verification.completedA 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

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 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
transactionIdidUse it with GET /api/v1/transactions/:id to fetch the full details
transRef · amount · status · bankCode · bankNamesame namebankCode is a 3-digit code — see Bank codes
sendersenderName
receiverreceiverName
verifiedAtcreatedAtWhen we checked it (ISO-8601 UTC) — not the transfer time · for slip.duplicate / DUPLICATE it is when the original slip was first checked
latencyMslatencyMsOf 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 bankNot in the webhook — fetch via GET /transactions/:id (e.g. to check errorCode: "STALE_SLIP")

Headers sent with every delivery:

HeaderValue
X-Slipbolt-EventEvent name, e.g. slip.verified
X-Slipbolt-DeliveryID 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-IdThe 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-AttemptWhich attempt this is for the event (1 = first · a "Resend" continues the count)
X-Slipbolt-Signaturet=<unix>,v1=<hex> — see the next section
User-AgentSlipbolt-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:

FieldMeaning
verificationIdThe vrf_… from the 202 — match it to your request
verificationStatusCOMPLETED (checked — see status) · FAILED (not checked — see error)
duplicate · cached · quotaChargedSame values as POST /verify
error{ httpStatus, code, message } when FAILED · otherwise null
JSON
{
  "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

HTTP
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:

HTTP
X-Slipbolt-Signature: t=1781107927,v1=<signed with the new secret>,v1=<signed with the old secret>
  1. Rotate → copy the new value
  2. Update your server (webhooks keep verifying in the meantime, whichever secret your server holds)
  3. 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):
AttemptSent after the previous one
1immediately
25 seconds
330 seconds
45 minutes
51 hour
66 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 id with a fresh signature (t is 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

  1. In your Discord server → Server Settings → Integrations → Webhooks → New Webhook
  2. Pick the channel to alert → Copy Webhook URL
  3. Paste it into Endpoint URL (format https://discord.com/api/webhooks/…)

Telegram

  1. Talk to @BotFather → /newbot → you get a token like 123456:ABC-…
  2. Invite the bot into the group to alert, then post any message in that group
  3. Open https://api.telegram.org/bot<TOKEN>/getUpdates in a browser → find "chat":{"id": …} (groups are negative numbers, e.g. -1001234567890)
  4. Paste this link into Endpoint URL:
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 3xx response 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