guides

Recipes

Copy-paste code that actually runs — a client with retries, taking a slip from a customer and closing the order, sending the slip as multipart/base64/URL/QR, testing webhooks locally

⏱ Copy & paste

Every block on this page runs as-is. Paste it in and change only the database part. Pick your language on a tab once and every block follows.

You wantGo to
A verify function that handles retries and timeouts1. Ready-made client
Customer uploads a slip → verify → close the order2. Take a slip, close the order
Send a form upload / base64 / a URL / the QR text3. The image is in another format
Try webhooks before you have a real server4. Test webhooks locally

Every example reads the key from an environment variable — use sk_test_ while developing; it does not consume the shop's quota.

Terminal
export SLIPBOLT_API_KEY="sk_test_..."

1. Ready-made client

One file, no extra dependencies (Python uses requests). It covers the three things every real system needs:

  • 30-second timeout — a network problem does not hang your customer's whole request
  • Automatic retries on network failure · 429 (waits for Retry-After) · 5xx — safe, because resending the same image within 24 hours returns the original result (cached: true): no second check, no second quota charge
  • Errors with status + code — tell "quota used up" (402) from "bad key" (401) without parsing the message
// slipbolt.js — Node.js 18+ · no packages to install
const BASE = 'https://api.slip-bolt.com/api/v1';

export class SlipBoltError extends Error {
  constructor(status, body) {
    super(`${status} ${body.code ?? body.error ?? ''}: ${body.message ?? 'request failed'}`);
    this.status = status; // HTTP status, e.g. 402
    this.code = body.code; // e.g. QUOTA_EXCEEDED
    this.requestId = body.requestId; // include it when you contact support
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/** Verify one slip — returns the `data` block · throws SlipBoltError when the API returns an error */
export async function verifySlip(image, { apiKey = process.env.SLIPBOLT_API_KEY, retries = 3 } = {}) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(`${BASE}/verify`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/octet-stream' },
        body: image,
        signal: AbortSignal.timeout(30_000),
      });
    } catch (err) {
      if (attempt >= retries) throw err; // network failure / timeout, out of attempts
      await sleep(500 * 2 ** attempt);
      continue;
    }

    if ((res.status === 429 || res.status >= 500) && attempt < retries) {
      await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
      continue;
    }

    const body = await res.json().catch(() => ({}));
    if (!res.ok) throw new SlipBoltError(res.status, body);
    return body.data; // the result is always under data
  }
}

Using it:

import { readFile } from 'node:fs/promises';
import { verifySlip } from './slipbolt.js';

const slip = await verifySlip(await readFile('slip.jpg'));
console.log(slip.status, slip.amount, slip.transRef);

2. Take a slip, close the order

The most common job: a customer uploads a slip on your checkout page → your server verifies it → the order closes automatically.

Call SlipBolt from your server only — never straight from browser JavaScript or a mobile app, because the API key ships with client-side code. The browser sends the image to your server; your server forwards it.

Release the order only when all 4 checks pass

#CheckIf you skip it
1verified === trueYou accept fake slips, or slips paid into someone else's account
2duplicate === falseA customer resends an old slip that was already used
3amount matches the order totalA customer transfers 1 THB and sends that slip — it is still "genuine"
4transRef is not yet attached to another order in your systemOne slip closes two orders

Check 4 is not optional — resending the same image within 24 hours returns the original result (cached: true · verified: true · duplicate: false) so that retries are safe ⇒ if a customer uploads the same image against a second order, checks 1–3 all pass. The real guard is a UNIQUE transRef column in your orders table.

Web page (customer side)

Send the file as the request body, no FormData needed — it works with all three servers below.

HTML
<input type="file" id="slip" accept="image/*">
<p id="result"></p>

<script>
  document.getElementById('slip').addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    // for PHP use '/pay.php?order=1001' instead
    const res = await fetch('/orders/1001/slip', {
      method: 'POST',
      headers: { 'Content-Type': file.type },
      body: file,
    });
    const out = await res.json();
    document.getElementById('result').textContent = out.message;
  });
</script>

Server

Uses the client file from step 1 · the Node.js / Python examples keep orders in memory so they run immediately — in production that is a table in your database (the PHP example uses PDO to show the shape).

// server.js — npm install express
import express from 'express';
import { verifySlip } from './slipbolt.js';

const app = express();

// in production = a database table · usedRefs = a UNIQUE transRef column
const orders = new Map([['1001', { total: 250, paid: false }]]);
const usedRefs = new Set();

// what to tell the customer when a slip is rejected
const REJECT = {
  DUPLICATE: 'This slip has already been used',
  FRAUD: 'This slip is not valid. Please contact the shop',
  RECEIVER_MISMATCH: 'This slip was not paid into the shop account',
  INVALID: 'We could not read the slip. Upload an image with the QR code clearly visible',
  PROVIDER_ERROR: 'Slip verification is temporarily unavailable. Please try again',
};

app.post('/orders/:id/slip', express.raw({ type: 'image/*', limit: '5mb' }), async (req, res) => {
  const order = orders.get(req.params.id);
  if (!order) return res.status(404).json({ ok: false, message: 'Order not found' });
  if (order.paid) return res.json({ ok: true, message: 'This order is already paid' });
  if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
    return res.status(400).json({ ok: false, message: 'Please attach a slip image' });
  }

  let slip;
  try {
    slip = await verifySlip(req.body);
  } catch (err) {
    console.error('SlipBolt:', err); // 402 = shop quota used up · 401/403 = key or plan → alert your team
    return res.status(503).json({ ok: false, message: 'Automatic verification is unavailable. Try again or contact the shop' });
  }

  // 1) genuine and paid into the shop account  2) not a slip that was already checked
  if (!slip.verified || slip.duplicate) {
    return res.status(422).json({ ok: false, message: REJECT[slip.status] ?? 'The slip did not pass verification' });
  }
  // 3) the amount matches the order — the API does not know what this order costs
  if (Math.abs(slip.amount - order.total) > 0.001) {
    return res.status(422).json({
      ok: false,
      message: `Transferred ${slip.amount} THB, but the order total is ${order.total} THB`,
    });
  }
  // 4) one slip closes one order
  if (usedRefs.has(slip.transRef)) {
    return res.status(422).json({ ok: false, message: 'This slip was already used for another order' });
  }

  usedRefs.add(slip.transRef);
  order.paid = true;
  order.transRef = slip.transRef;
  res.json({ ok: true, message: 'Payment received. Thank you!', transRef: slip.transRef });
});

app.listen(3000);

Don't want the customer to wait? Let them send the slip in the shop's LINE OA and have the Webhook slip.verified hit your server instead — the same 4 checks work from data.amount / data.transRef.

3. The image is in another format

POST /verify accepts the slip in 5 ways — the result, the quota and the 24-hour cache are identical for all of them (resending the same image in a different format still returns cached: true, with no second quota charge)

What you haveHow to send itContent-Type
An image file / Buffer / bytesAs the request body — smallest and fastest, recommendedapplication/octet-stream (or image/jpeg · image/png)
A file from an upload formForward it as multipart, in a file field named filemultipart/form-data
base64 or a data URLPut it in the image field of a JSON bodyapplication/json
An image URL (S3, CDN)Put it in the url field of a JSON body — we download itapplication/json
The text inside the slip's QR code (no image)Put it in the qr field of a JSON bodyapplication/json
API="https://api.slip-bolt.com/api/v1/verify"

# 1) raw bytes
curl -X POST "$API" -H "Authorization: Bearer $SLIPBOLT_API_KEY" \
  -H "Content-Type: application/octet-stream" --data-binary "@slip.jpg"

# 2) multipart/form-data — file field named file
curl -X POST "$API" -H "Authorization: Bearer $SLIPBOLT_API_KEY" \
  -F "[email protected]"

# 3) JSON base64
base64 < slip.jpg | tr -d '\n' | sed 's/.*/{"image":"&"}/' |
  curl -X POST "$API" -H "Authorization: Bearer $SLIPBOLT_API_KEY" \
    -H "Content-Type: application/json" --data-binary @-

# 4) JSON url — an https link to the image, reachable from the internet
curl -X POST "$API" -H "Authorization: Bearer $SLIPBOLT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://cdn.example.com/slips/1001.jpg"}'

# 5) JSON qr — the text scanned from the QR code on the slip
curl -X POST "$API" -H "Authorization: Bearer $SLIPBOLT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"qr":"0046000600000101030140225202610075020019535816MIR5102TH9104A1B2"}'

Maximum 5 MB per request · JPEG or PNG · the slip's QR code must be fully visible (an image cropped through the QR returns INVALID). base64 grows the data by about 33% ⇒ the largest image you can send as JSON is roughly 3.7 MB — for anything bigger use raw bytes or multipart

Send a URL or the QR text

Both go in a JSON body, like base64 — put exactly one of image / url / qr (more than one returns 400)

url — we download the image and verify it exactly as if you had sent that file yourself

  • Must be https:// and reachable from the internet without signing in (e.g. an S3 pre-signed URL) · localhost / internal IPs are rejected
  • Redirects are not followed — give the final link · file up to 5 MB · we wait at most 10 seconds
  • If the download fails → 400 with the reason (no quota used)

qr — the text decoded from the QR code on the slip; ideal for apps that already scan the QR, no image upload needed

  • Send the scanned text as-is, in full — do not trim or convert it
  • The same text sent again within 24 hours returns cached: true, just like resending the same image
const API = 'https://api.slip-bolt.com/api/v1/verify';

async function verifyJson(payload) {
  const res = await fetch(API, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SLIPBOLT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.code ?? body.error}: ${body.message}`);
  return body.data;
}

const byUrl = await verifyJson({ url: process.env.SLIP_URL });
const byQr = await verifyJson({ qr: process.env.SLIP_QR });
console.log(byUrl.status, byQr.status);

4. Test webhooks locally

SlipBolt only delivers webhooks to public https:// addresses, so it cannot reach localhost directly. Two options:

Option 1 — sign an event yourself and post it to localhost

The fastest way, nothing exposed to the internet. This script signs exactly the way the real system does. Use the same secret your handler is configured with (example handler).

Terminal
SECRET="whsec_your_secret"
URL="http://localhost:3000/webhooks/slipbolt"

TS=$(date +%s)
BODY='{"event":"slip.verified","id":"evt_local_'$TS'","created":'$TS',"data":{"transactionId":"txn_local_1","transRef":"TEST'$TS'","amount":250,"status":"SUCCESS","bankCode":"004","bankName":"ธนาคารกสิกรไทย","sender":"นาย ทดสอบ ระบบ","receiver":"Your shop","verifiedAt":"2026-06-17T03:21:06.412Z","latencyMs":480}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

curl -i -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Slipbolt-Event: slip.verified" \
  -H "X-Slipbolt-Signature: t=$TS,v1=$SIG" \
  --data-binary "$BODY"

200 = your handler verified the signature. Now change SECRET to something wrong and run it again — you must get 400 (if you still get 200, the handler is not really checking the signature). The Thai text in the body is deliberate: real slips carry Thai names, and that is where a re-serialised body breaks the signature.

Option 2 — open a tunnel and let the real system call you

Use this when you want events from real slips — open a tunnel with the tool you prefer (cloudflared, ngrok, …), for example

Terminal
cloudflared tunnel --url http://localhost:3000

Take the https://….trycloudflare.com URL it prints, append your handler's path, save it under Settings → Webhooks, then press Send test event — it uses no quota.

Pre-production checklist

  • Switch to an sk_live_ key, kept in a server environment variable — not in code or in the repo
  • Run all 4 checks before releasing an order, with transRef UNIQUE in your database
  • Call GET /me at boot — confirm the key belongs to the right shop and payeeAccounts lists the account customers pay into
  • Handle 402 (quota used up) by alerting your team, not silently — otherwise customers pay and their orders never close
  • If you use webhooks: verify the signature from the raw body · answer 2xx within 10 seconds · skip an id you have already processed
  • Log the requestId (from meta or from an error) — send it to us and we can trace the call straight away