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 want
Go to
A verify function that handles retries and timeouts
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
}
}
# slipbolt.py — pip install requests
import os
import time
import requests
BASE = "https://api.slip-bolt.com/api/v1"
class SlipBoltError(Exception):
def __init__(self, status: int, body: dict):
super().__init__(
f"{status} {body.get('code') or body.get('error') or ''}: {body.get('message') or 'request failed'}"
)
self.status = status # HTTP status, e.g. 402
self.code = body.get("code") # e.g. QUOTA_EXCEEDED
self.request_id = body.get("requestId") # include it when you contact support
def verify_slip(image: bytes, api_key: str = "", retries: int = 3) -> dict:
"""Verify one slip — returns the data block · raises SlipBoltError when the API returns an error"""
api_key = api_key or os.environ["SLIPBOLT_API_KEY"]
for attempt in range(retries + 1):
last = attempt == retries
try:
res = requests.post(
f"{BASE}/verify",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/octet-stream"},
data=image,
timeout=30,
)
except requests.RequestException:
if last:
raise # network failure / timeout, out of attempts
time.sleep(0.5 * 2**attempt)
continue
if (res.status_code == 429 or res.status_code >= 500) and not last:
time.sleep(float(res.headers.get("Retry-After") or 2**attempt))
continue
try:
body = res.json()
except ValueError:
body = {}
if not res.ok:
raise SlipBoltError(res.status_code, body)
return body["data"] # the result is always under data
<?php
// slipbolt.php — PHP 8.1+ · uses ext-curl, no Composer needed
class SlipBoltError extends RuntimeException
{
public function __construct(public int $status, public array $body)
{
parent::__construct(
"$status " . ($body['code'] ?? $body['error'] ?? '') . ': ' . ($body['message'] ?? 'request failed')
);
}
}
/** Verify one slip — returns the data block · throws SlipBoltError when the API returns an error */
function slipbolt_verify(string $image, int $retries = 3): array
{
for ($attempt = 0; ; $attempt++) {
$retryAfter = 0;
$ch = curl_init('https://api.slip-bolt.com/api/v1/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SLIPBOLT_API_KEY'),
'Content-Type: application/octet-stream',
],
CURLOPT_POSTFIELDS => $image,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter) {
if (stripos($line, 'retry-after:') === 0) {
$retryAfter = (int) trim(substr($line, 12));
}
return strlen($line);
},
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// network failure / timeout / 429 / 5xx → wait, then try again
if (($raw === false || $status === 429 || $status >= 500) && $attempt < $retries) {
sleep($retryAfter ?: 2 ** $attempt);
continue;
}
if ($raw === false) {
throw new RuntimeException('Connection failed: ' . curl_error($ch));
}
$body = json_decode($raw, true) ?? [];
if ($status !== 200) {
throw new SlipBoltError($status, $body);
}
return $body['data']; // the result is always under data
}
}
from slipbolt import verify_slip
with open("slip.jpg", "rb") as f:
slip = verify_slip(f.read())
print(slip["status"], slip["amount"], slip["transRef"])
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.
You accept fake slips, or slips paid into someone else's account
2
duplicate === false
A customer resends an old slip that was already used
3
amount matches the order total
A customer transfers 1 THB and sends that slip — it is still "genuine"
4
transRef is not yet attached to another order in your system
One 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.
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);
# app.py — pip install flask requests
from flask import Flask, jsonify, request
from slipbolt import verify_slip
app = Flask(__name__)
# in production = a database table · used_refs = a UNIQUE transRef column
orders = {"1001": {"total": 250, "paid": False}}
used_refs: set[str] = set()
# what to tell the customer when a slip is rejected
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",
}
def reply(status: int, ok: bool, message: str, **extra):
return jsonify(ok=ok, message=message, **extra), status
@app.post("/orders/<order_id>/slip")
def pay_with_slip(order_id: str):
order = orders.get(order_id)
if order is None:
return reply(404, False, "Order not found")
if order["paid"]:
return reply(200, True, "This order is already paid")
image = request.get_data()
if not image:
return reply(400, False, "Please attach a slip image")
try:
slip = verify_slip(image)
except Exception as err: # 402 = shop quota used up · 401/403 = key or plan → alert your team
app.logger.error("SlipBolt: %s", err)
return reply(503, False, "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 not slip["verified"] or slip["duplicate"]:
return reply(422, False, REJECT.get(slip["status"], "The slip did not pass verification"))
# 3) the amount matches the order — the API does not know what this order costs
if abs(slip["amount"] - order["total"]) > 0.001:
return reply(422, False, f"Transferred {slip['amount']} THB, but the order total is {order['total']} THB")
# 4) one slip closes one order
if slip["transRef"] in used_refs:
return reply(422, False, "This slip was already used for another order")
used_refs.add(slip["transRef"])
order.update(paid=True, transRef=slip["transRef"])
return reply(200, True, "Payment received. Thank you!", transRef=slip["transRef"])
if __name__ == "__main__":
app.run(port=3000)
<?php
// pay.php?order=1001 — table: orders(id, total, trans_ref UNIQUE NULL)
require __DIR__ . '/slipbolt.php';
function reply(int $status, bool $ok, string $message): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'message' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
// 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',
];
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER') ?: null, getenv('DB_PASS') ?: null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$find = $pdo->prepare('SELECT id, total, trans_ref FROM orders WHERE id = ?');
$find->execute([$_GET['order'] ?? '']);
$order = $find->fetch(PDO::FETCH_ASSOC);
if (!$order) reply(404, false, 'Order not found');
if ($order['trans_ref'] !== null) reply(200, true, 'This order is already paid');
$image = file_get_contents('php://input'); // the slip image the browser sent as the body
if ($image === '' || $image === false) reply(400, false, 'Please attach a slip image');
try {
$slip = slipbolt_verify($image);
} catch (Throwable $err) { // 402 = shop quota used up · 401/403 = key or plan → alert your team
error_log('SlipBolt: ' . $err->getMessage());
reply(503, false, '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']) {
reply(422, false, 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 (abs($slip['amount'] - (float) $order['total']) > 0.001) {
reply(422, false, "Transferred {$slip['amount']} THB, but the order total is {$order['total']} THB");
}
// 4) one slip closes one order — let the database UNIQUE decide (it also covers two requests racing)
try {
$pay = $pdo->prepare('UPDATE orders SET trans_ref = ? WHERE id = ? AND trans_ref IS NULL');
$pay->execute([$slip['transRef'], $order['id']]);
} catch (PDOException $err) {
reply(422, false, 'This slip was already used for another order');
}
reply(200, true, 'Payment received. Thank you!');
Don't want the customer to wait? Let them send the slip in the shop's LINE OA and have the
Webhookslip.verified hit your server instead — the same 4 checks work from data.amount / data.transRef.
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 have
How to send it
Content-Type
An image file / Buffer / bytes
As the request body — smallest and fastest, recommended
Forward it as multipart, in a file field named file
multipart/form-data
base64 or a data URL
Put it in the image field of a JSON body
application/json
An image URL (S3, CDN)
Put it in the url field of a JSON body — we download it
application/json
The text inside the slip's QR code (no image)
Put it in the qr field of a JSON body
application/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"}'
import { readFile } from 'node:fs/promises';
const API = 'https://api.slip-bolt.com/api/v1/verify';
const auth = { Authorization: `Bearer ${process.env.SLIPBOLT_API_KEY}` };
const bytes = await readFile('slip.jpg');
// multipart/form-data — file field named file
const form = new FormData();
form.append('file', new Blob([bytes]), 'slip.jpg');
const viaForm = await fetch(API, { method: 'POST', headers: auth, body: form });
// JSON base64 — plain base64 or data:image/...;base64,... both work
const viaJson = await fetch(API, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ image: bytes.toString('base64') }),
});
for (const res of [viaForm, viaJson]) {
const body = await res.json();
console.log(res.status, body.data?.status ?? body.message);
}
import base64
import os
import requests
API = "https://api.slip-bolt.com/api/v1/verify"
auth = {"Authorization": f"Bearer {os.environ['SLIPBOLT_API_KEY']}"}
with open("slip.jpg", "rb") as f:
data = f.read()
# multipart/form-data — file field named file
via_form = requests.post(API, headers=auth, files={"file": ("slip.jpg", data)}, timeout=30)
# JSON base64 — plain base64 or data:image/...;base64,... both work
via_json = requests.post(API, headers=auth, json={"image": base64.b64encode(data).decode()}, timeout=30)
for res in (via_form, via_json):
body = res.json()
print(res.status_code, body["data"]["status"] if res.ok else body["message"])
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var api = "https://api.slip-bolt.com/api/v1/verify";
var key = Environment.GetEnvironmentVariable("SLIPBOLT_API_KEY")!;
var data = await File.ReadAllBytesAsync("slip.jpg");
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
// multipart/form-data — file field named file
using var form = new MultipartFormDataContent();
var file = new ByteArrayContent(data);
file.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg");
form.Add(file, "file", "slip.jpg");
using var viaForm = await http.SendAsync(authed(new HttpRequestMessage(HttpMethod.Post, api) { Content = form }));
// JSON base64 — plain base64 or data:image/...;base64,... both work
var json = new StringContent(
JsonSerializer.Serialize(new { image = Convert.ToBase64String(data) }),
Encoding.UTF8,
"application/json");
using var viaJson = await http.SendAsync(authed(new HttpRequestMessage(HttpMethod.Post, api) { Content = json }));
foreach (var res in new[] { viaForm, viaJson })
{
using var body = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
string? status = null;
if (body.RootElement.TryGetProperty("data", out var d) && d.TryGetProperty("status", out var s)) status = s.GetString();
else if (body.RootElement.TryGetProperty("message", out var m)) status = m.GetString();
Console.WriteLine($"{(int)res.StatusCode} {status}");
}
HttpRequestMessage authed(HttpRequestMessage req)
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
return req;
}
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
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var byUrl = await VerifyJson(new { url = Environment.GetEnvironmentVariable("SLIP_URL") });
var byQr = await VerifyJson(new { qr = Environment.GetEnvironmentVariable("SLIP_QR") });
Console.WriteLine($"{byUrl} {byQr}");
static async Task<string> VerifyJson(object payload)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.slip-bolt.com/api/v1/verify");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SLIPBOLT_API_KEY"));
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req);
var body = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!res.IsSuccessStatusCode)
{
string code = body.RootElement.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
if (code == "" && body.RootElement.TryGetProperty("error", out var e)) code = e.GetString() ?? "";
string message = body.RootElement.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
throw new Exception($"{(int)res.StatusCode} {code}: {message}");
}
return body.RootElement.GetProperty("data").GetProperty("status").GetString()!;
}
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).
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.
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.