API Reference
Public REST API for integrators — verify slips, read quota, error formats + statuses for every endpoint
The public REST API for connecting your system (POS / ERP / bot) to SlipBolt.
Authenticate with an API key (sk_live_… / sk_test_…) — see Authentication
Base URL
https://api.slip-bolt.com/api/v1
Every request needs the header:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Check before a big batch — GET /api/v1/me (shop · plan · live+test quota · receiving accounts)
and GET /api/v1/quota (quota only) use no slips · they need the read scope.
There are no API keys during the Trial — check the quota tile on the Dashboard
Test mode — use an sk_test_… key to run the real pipeline end to end without consuming the shop's quota, ideal for
dev/CI · it has its own cap of 200 calls/shop/month (every call verifies against the real bank; it is not a mock).
Past the cap you get 402 TEST_QUOTA_EXCEEDED — details at Authentication
https://api.slip-bolt.com/api/v1/openapi.json
— import it into Postman / Insomnia / Bruno to try calls right away, or generate a client
in your language (e.g. with openapi-generator); it covers every endpoint on this page
Ready-made Postman collection: https://api.slip-bolt.com/api/v1/postman.json — import it, set the apiKey variable and send · POST /verify comes as 5 separate requests (raw file · multipart · base64 · url · qr)
Copy-paste POST /verify in Node.js · Python · PHP · Go · Java · C# is in Recipes
A successful request's result is always under data — { "success": true, "data": { … }, "meta": { … } }.
Errors are a flat object (see Error envelope) · read verified at data.verified, not at the root
POST /api/v1/verify
Verify one transfer slip — 5 ways to send it: raw bytes (recommended) · multipart/form-data · JSON image (base64) · JSON url · JSON qr
Request
| Method | POST |
| Body | The slip image file (JPEG/PNG) · whole request up to 5 MB |
| Required scope | verify |
| Send as | Content-Type | Body |
|---|---|---|
| raw bytes (recommended) | application/octet-stream (or image/jpeg · image/png) | The image file as raw binary |
| multipart | multipart/form-data | A file in the field named file (no such field → the first file found) |
| JSON base64 | application/json | { "image": "<base64>" } — a data:image/…;base64, prefix is fine |
| JSON URL | application/json | { "url": "https://…" } — we download the image · public https only · redirects not followed · up to 5 MB |
| JSON QR | application/json | { "qr": "<text inside the slip QR code>" } — no image needed |
qr is input only — it is the text scanned from the QR printed on the slip (the verification QR) · this API has no endpoint that generates a QR / PromptPay code for collecting payment
- Sending a payment QR (PromptPay / merchant QR — the text starts with
000201) instead →400, no quota charge - Any other text that isn't a slip QR → the bank decides; the result is
INVALIDorFRAUD(no such transaction at the bank) — no quota charge
A JSON body takes exactly one of image / url / qr · the examples below use raw bytes · every other way, in every language, is in Recipes
curl -X POST https://api.slip-bolt.com/api/v1/verify \
-H "Authorization: Bearer $SLIPBOLT_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary "@slip.jpg"
Response 200 OK
{
"success": true,
"data": {
"id": "8f3c…",
"status": "SUCCESS",
"verified": true,
"source": "API",
"transRef": "015170106978META…",
"amount": 250.0,
"bankCode": "002",
"bankName": "ธนาคารกรุงเทพ",
"senderName": "นาย ก. …",
"senderAccount": "xxx-x-x1234-x",
"receiverName": "ร้าน ข.",
"receiverAccount": "xxx-x-x5678-x",
"receiverBankCode": "014",
"receiverBankName": "ธนาคารไทยพาณิชย์",
"transDate": "2026-06-17T03:21:05.000Z",
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-06-17T03:21:06.412Z",
"receipt": { "code": "SB-7K2F-9QX4", "checkUrl": "https://slip-bolt.com/c/PyuMHlp9Tp-wwdLj9KW2xzQ1dGhVb2xr" },
"duplicate": false,
"cached": false,
"latencyMs": 480,
"environment": "live",
"quota": { "used": 132, "limit": 500, "remaining": 368 }
},
"meta": { "requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c", "timestamp": "2026-06-17T03:21:06.412Z" }
}
verified is true only when status === "SUCCESS" — see the status table below.
Bank and account names are returned as printed on the slip (usually Thai script); error messages are in Thai.
meta.requestId matches the response's X-Request-Id header — log it for when you contact support.
Examples of a slip that did not pass
Every case below is HTTP 200 — the request succeeded, the verdict on the slip is "not passed" (only the fields that differ from the example above are shown)
{
"id": "8f3c2a1e-…",
"status": "DUPLICATE",
"verified": false,
"transRef": "015170106978META…",
"amount": 250.0,
"errorCode": null,
"errorMessage": null,
"duplicate": true,
"cached": false
}
DUPLICATEreturns the original transaction's data (id·transRef·amountare from the first verification) — usetransRefto find which order already used this slipRECEIVER_MISMATCH·AMOUNT_MISMATCHwere read in full, sotransRef/amount/ receiver are there to show where the money went ·errorCodeisnull— branch onstatusINVALID·FRAUDcarry no slip data you can trust —transRefisnullandamountis0
Fields in data
The first group (through createdAt) is the same shape GET /transactions returns — one parser handles both
| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | Transaction id — use it with GET /transactions/:id |
status | string | The verdict — see Transaction status |
verified | boolean | true only when status is SUCCESS |
source | string | Channel the slip arrived through: API · LINE · LIFF · DASHBOARD |
transRef | string | null | Bank reference — use it as your unique key · null when it could not be read from the slip |
amount | number | Amount transferred (THB) — 0 when it could not be read |
bankCode · bankName | string | null | Sender's bank — bankCode is a 3-digit code, see Bank codes |
senderName · senderAccount | string | null | Sender — account digits are masked as the bank provides them |
receiverName · receiverAccount | string | null | Receiver — receiverAccount is the bank account the money landed in |
receiverProxyAccount | string | null | Receiver's PromptPay id (phone / national id / e-Wallet) — the number shown on the slip image for a PromptPay transfer · normally differs from receiverAccount · null for a direct account transfer |
receiverBankCode · receiverBankName | string | null | Receiver's bank (also a 3-digit code) |
transDate | string (ISO-8601 UTC) | null | Transfer time according to the bank |
errorCode · errorMessage | string | null | Reason when it did not pass — or a warning when it passed (STALE_SLIP) · see errorCode |
warnings | array | Warnings on a passing slip [{ code, message }] — [] when there are none or the slip did not pass · currently only STALE_SLIP (same value as errorCode; both are kept) |
createdAt | string (ISO-8601 UTC) | When we checked it |
receipt | object | null | Verification receipt — code (the confirmation code printed on it) · checkUrl (the link in its QR) · SUCCESS only · image/PDF: see receipt.png |
duplicate | boolean | This transRef was already checked (status = DUPLICATE) |
cached | boolean | Same image resent within 24 hours — the original result is returned, no quota charge |
quotaCharged | boolean | Whether this request used the shop's quota/credits — true only for a freshly checked SUCCESS with a live key · non-pass · DUPLICATE · cached · sk_test_ keys = false |
latencyMs | integer | null | Time spent checking with the bank |
environment | string | live or test, by key type |
quota | object | used · limit · remaining after this request |
Sender / receiver names are not full names — senderName / receiverName are the names the bank sends us (we do not read them off the slip image), and banks mask the surname down to its first letter, e.g. นาย สมชาย ใ · some banks send English (MR. SOMCHAI J) · long shop / account names may be cut off
A full name is not available on any channel (API · webhook · LINE) · do not match the receiver by name — we already match the account number against the shop's payee accounts, and the result is in status (RECEIVER_MISMATCH)
The same image in a different format is still the same image — raw bytes / multipart / base64 / url are all turned into the same bytes
before checking, so a resend returns cached: true either way · qr is tracked separately from images (the same text again = cached · an image of that same slip = DUPLICATE) — see Recipes
Quota — one pool with LINE slips
API calls use the same monthly quota as the shop's LINE slips (one pool, counted together) — not a separate quota; whichever channel is used draws from the same pool
Every response (including GET /api/v1/quota) carries headers with the balance:
| Header | Meaning |
|---|---|
X-Slipbolt-Quota-Limit | Slips/month ceiling of the current plan |
X-Slipbolt-Quota-Used | Used so far this month |
X-Slipbolt-Quota-Remaining | Remaining |
X-Slipbolt-Quota-Reset-Days | Days until the next reset |
Quota and credits used up → 402 Payment Required (live keys only · if credits remain they are used instead, no 402) — not a silent 200, so integrators can check the HTTP status directly
{ "success": false, "statusCode": 402, "error": "Payment Required",
"message": "โควตาสลิปรายเดือนและเครดิตของร้านถูกใช้หมดแล้ว — ซื้อเครดิต อัปเกรดแพ็ก หรือรอรอบบิลถัดไป",
"code": "QUOTA_EXCEEDED", "quota": { "used": 500, "limit": 500, "remaining": 0 } }
(message is in Thai: "the shop's monthly slip quota and credits are used up — buy credits, upgrade, or wait for the next billing cycle" · branch on code, not on the text.)
sk_test_ keys don't consume this (the shop's) quota so they never get this 402 — but they have their own cap of
200 calls/month; past it they get 402 TEST_QUOTA_EXCEEDED (see the Test mode note above)
GET /api/v1/quota
Read the remaining quota without spending a verify — good for polling before a big batch
| Method | GET |
| Required scope | read |
curl "https://api.slip-bolt.com/api/v1/quota" \
-H "Authorization: Bearer $SLIPBOLT_API_KEY"
{
"success": true,
"data": {
"used": 132,
"limit": 500,
"remaining": 368,
"percentUsed": 26,
"allowOverage": false,
"planCode": "STARTER",
"periodMonth": "2026-06",
"daysUntilReset": 13,
"creditsAvailable": 0
}
}
creditsAvailable = remaining slip credits (pay as you go) — used once the plan quota runs out, before any overage is charged.
A shop with no monthly plan but with credits can still verify slips · remaining is still the plan quota only
⇒ total slips you can still verify ≈ remaining + creditsAvailable
GET /api/v1/me
Shop · key · plan · remaining quota — uses no quota; good to call when your system boots
to check the key belongs to the right shop, has the scopes it needs, how many slips are left, and which receiving account the receiver check compares against
(the number-one cause of RECEIVER_MISMATCH is a shop that hasn't added the account customers pay into)
During the 7-day trial plan.code is "TRIAL" and quota.limit is always 50,
even if the shop picked the Pro/Business card at signup — paid-plan entitlements start only after payment
| Method | GET |
| Required scope | read |
curl "https://api.slip-bolt.com/api/v1/me" \
-H "Authorization: Bearer $SLIPBOLT_API_KEY"
{
"success": true,
"data": {
"shop": { "id": "8f3c…", "name": "ร้าน ข." },
"apiKey": {
"name": "POS หน้าร้าน",
"prefix": "sk_live_a1b2c3",
"environment": "live",
"scopes": ["verify", "read"],
"lastUsedAt": "2026-06-17T03:21:06.000Z",
"createdAt": "2026-05-01T00:00:00.000Z"
},
"plan": { "code": "STARTER", "name": "Starter" },
"quota": { "used": 132, "limit": 500, "remaining": 368, "percentUsed": 26,
"overage": 0, "allowOverage": false, "planCode": "STARTER",
"periodMonth": "2026-06", "daysUntilReset": 13 },
"credits": { "available": 0, "usedThisMonth": 0, "nextExpiry": null },
"testQuota": { "used": 12, "limit": 200, "remaining": 188 },
"rateLimit": { "perKeyPerMinute": 120, "verifyPerMinute": 240 },
"payeeAccounts": [
{ "bankCode": "kbank", "kind": "BANK", "holder": "ร้าน ข.", "accountLast4": "5678", "isPrimary": true, "status": "ACTIVE" }
]
}
}
payeeAccounts[].bankCode is a slug (kbank), not the 3-digit code used in slip results (004) — map them with the Bank codes table
payeeAccounts only returns the last 4 digits of the account number — an API key leaks more easily than a web password,
and the full number isn't needed to tell "which account" · testQuota.used: null = temporarily unmeasurable
(the sk_test_ key falls back to the shop's normal quota instead — not an error)
GET /api/v1/transactions
List the shop's slip results — newest first, cursor-paginated, across every channel (API / LINE / Dashboard).
Useful when a POST /verify dropped mid-flight (timeout / network cut) and you want the real result without resending the slip
| Method | GET |
| Required scope | read |
| Query | status · source · transRef · from/to (ISO-8601 — add +07:00 if you think in Thai time) · cursor · limit (1-100, default 50) |
curl "https://api.slip-bolt.com/api/v1/transactions?status=SUCCESS&limit=20" \
-H "Authorization: Bearer $SLIPBOLT_API_KEY"
{
"success": true,
"data": {
"items": [ { "id": "8f3c…", "status": "SUCCESS", "verified": true, "…": "…" } ],
"nextCursor": "7ab1…"
}
}
Send nextCursor back as cursor to fetch the next page · null = no more pages
GET /api/v1/transactions/:id
Fetch one slip result — pair it with webhooks (take data.transactionId from the event, then fetch the full details).
Another shop's transaction and a non-existent id both return 404 (we don't confirm whether the id exists)
curl https://api.slip-bolt.com/api/v1/transactions/8f3c2a1e-… \
-H "Authorization: Bearer $SLIPBOLT_API_KEY"
GET /api/v1/transactions/:id/receipt.png · receipt.pdf
The verification receipt for a passed slip — the same image customers get from the LINE bot / LIFF ("Verified · genuine slip" header · amount · sender → receiver · reference · QR + confirmation code) Hand it to your own customers as-is (chat · email · order page)
- The QR opens a check page at
slip-bolt.com/c/…— anyone holding the image can scan it to confirm SlipBolt really issued it (that page shows partly-masked details) · the same link and code are indata.receiptof the result - The receipt cannot be used in place of a transfer slip, and says so on its face — it proves "this slip passed verification", not the transfer itself
?maskSender=true— masks the sender's name ("สมชาย ใจ***") and leaves out the sender's account number · use it when the image will be posted where many people see it- Rendered on every request; we keep no file — store the image yourself if you need it again · PNG is 784px wide · the PDF is one page the size of the receipt
- A slip that didn't pass (
status≠SUCCESS) or whose details were deleted under the shop's retention period →409· another shop's / unknown id →404· limit 30 requests/minute per IP
| Required scope | read |
curl https://api.slip-bolt.com/api/v1/transactions/8f3c2a1e-…/receipt.png \
-H "Authorization: Bearer $SLIPBOLT_API_KEY" \
-o receipt.png
POST /api/v1/verify/async
Verify a slip without waiting — answers 202 within milliseconds and keeps checking in the background. Good for shops
that send slips in batches or don't want a request held open while the bank answers · body/headers/scope are
identical to POST /verify (including Idempotency-Key)
curl -i -X POST https://api.slip-bolt.com/api/v1/verify/async \
-H "Authorization: Bearer $SLIPBOLT_API_KEY" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/octet-stream" \
--data-binary @slip.jpg
HTTP/1.1 202 Accepted
Location: /api/v1/verifications/vrf_3f9a1c0b7e2d4a6f8b1c2d3e
{
"success": true,
"data": {
"id": "vrf_3f9a1c0b7e2d4a6f8b1c2d3e",
"status": "PENDING",
"createdAt": "2026-10-22T03:21:06.412Z",
"completedAt": null,
"statusUrl": "/api/v1/verifications/vrf_3f9a1c0b7e2d4a6f8b1c2d3e",
"result": null,
"error": null
},
"meta": { "requestId": "…", "timestamp": "…" }
}
Two ways to get the result (use both if you like):
- Webhook
verification.completed— tick this event on your endpoint in Settings → Webhooks (payload) - Poll
GET /api/v1/verifications/{id}every 1–2 seconds (does not spend a verify)
GET /api/v1/verifications/:id
status | Meaning |
|---|---|
PENDING · PROCESSING | Not finished yet |
COMPLETED | Done — result is exactly the data that POST /verify returns, every field (the slip's own status is result.status — a slip that did not pass is still COMPLETED) |
FAILED | Not verified — see error · no quota charged · safe to resend |
error.code on FAILED (error.httpStatus = the status POST /verify would have returned in the same case):
error.code | When |
|---|---|
QUOTA_EXCEEDED · TEST_QUOTA_EXCEEDED | Quota/credits used up (sync = 402) |
SPEND_CAP_EXCEEDED | Per-key cap the shop set was reached (sync = 429) |
VERIFY_TIMEOUT | Waited in the queue for more than 5 minutes — never checked |
API_KEY_REVOKED | The key was revoked before the check finished |
INTERNAL_ERROR | Our side failed even after retrying |
- Results are kept for 24 h, then
404— the permanent record isGET /transactions/{result.id} - An id from another shop / a key of the other environment (
sk_live_vssk_test_) →404 - Same
Idempotency-Key+ same slip = the same202(sameid·Idempotent-Replayed: true), no new job · async keys are separate fromPOST /verify(the same value on both routes does not collide) - Pending jobs per shop are capped (50 by default) — when full you get
429code: "ASYNC_QUEUE_FULL"+Retry-After - Our queue is temporarily unavailable →
503code: "ASYNC_UNAVAILABLE"— send toPOST /verifyinstead
Results, DUPLICATE, STALE_SLIP and quotaCharged in async mode are identical to normal mode in every case — same
verification path. The existing slip.* events still fire as usual (you get both slip.verified and verification.completed if both are ticked)
Scopes
A key can hold several scopes — an endpoint requires every scope it lists (configure them at Settings → API Keys)
| Scope | Used for |
|---|---|
verify | POST /api/v1/verify (default on every key) |
read | GET /api/v1/quota · GET /api/v1/me · GET /api/v1/transactions[/:id] · GET /api/v1/transactions/:id/receipt.{png,pdf} |
webhook | Reserved for the future — no public endpoint uses this scope yet (outgoing webhooks are configured only through Dashboard → Settings → Webhooks, not with an API key) |
Missing scope → 403 Forbidden · message: "API key missing scope(s): verify" (no code — full shape in Error envelope).
A revoked key gets 401 from the very next request — there is no cache window
Health check
No key needed · doesn't use your quota or the key's rate limit · Cache-Control: no-store · limited to 120 requests/minute per IP
| Endpoint | What it tells you | Response |
|---|---|---|
GET /api/v1/health | the process is accepting requests (touches no dependency) | 200 { "status": "ok" } |
GET /api/v1/ready | slips can actually be verified right now — checks the database · queue · verification service (cached 15 s) | 200 ok / degraded · 503 down |
{ "status": "degraded", "checks": { "database": "ok", "queue": "ok", "verification": "degraded" }, "timestamp": "2026-10-21T08:00:00.000Z" }
degraded= still verifying, but on the fallback path (may be slower) ·down= can't verify right now — pause sending and retry- Point your uptime monitor at
/api/v1/ready· overall status and incident history are on the status page
Rate limit
120 req/min per key (same on every plan) + a combined ceiling of 240 req/min on POST /api/v1/verify.
Over the limit → 429 Too Many Requests + a Retry-After header + code: "RATE_LIMITED" (tell it apart from SPEND_CAP_EXCEEDED / ASYNC_QUEUE_FULL by code) · see Authentication
Headers on every response:
| Header | Meaning | Unit |
|---|---|---|
X-RateLimit-Limit | This endpoint's ceiling per source IP — 120 everywhere · 240 on POST /verify | requests/minute |
X-RateLimit-Remaining | Left in the current window (per IP) | requests |
X-RateLimit-Reset | Time until the window resets | seconds (not an epoch) |
Retry-After | Only with 429 — how long to wait before calling again | seconds |
X-Request-Id | The request id — same value as meta.requestId / requestId in an error | UUID |
There are two counters — X-RateLimit-* is the per-IP layer (hence 240 on /verify and 120 elsewhere).
The 120 req/min per key ceiling is a separate counter with no remaining-count header — one key really gets 120 /verify calls a minute
even though X-RateLimit-Limit says 240 · whichever layer trips first answers 429 + Retry-After ⇒ throttle on 429, not on Remaining
Transaction status
status | verified | Quota/credit charged | Webhook | Meaning · what to do next |
|---|---|---|---|---|
SUCCESS | ✅ | 1 slip (live key) | slip.verified | Valid slip, paid into the shop's account → compare amount with your order total, then fulfil · check errorCode too — STALE_SLIP = stale slip |
DUPLICATE | ❌ | None | slip.duplicate | This reference (transRef) was already verified → do not fulfil again |
AMOUNT_MISMATCH | ❌ | None | slip.failed | The amount on the slip doesn't meet the condition the bank was asked to check — POST /verify does not take an expected amount ⇒ compare amount with your order total yourself |
RECEIVER_MISMATCH | ❌ | None | slip.failed | Paid into an account that isn't the shop's → check payeeAccounts in GET /me: has the account the customer paid into been added? |
INVALID | ❌ | None | slip.failed (except NO_PAYEE_CONFIGURED — no event) | Unreadable / not a slip → ask the customer for the slip image from their banking app · see errorCode |
FRAUD | ❌ | None | slip.fraud | Fake/edited slip — does not match bank records → do not fulfil |
QUOTA_EXCEEDED | ❌ | None | None | Quota used up — POST /verify answers 402, not 200 (see above) · this status only shows up in GET /transactions |
PROVIDER_ERROR | ❌ | None | None | Bank verification is temporarily unavailable (200 + this status) — not a verdict → resend the same slip (it is verified afresh, not served from cache) |
- Only
SUCCESScounts against quota — every non-pass outcome, duplicates,cached: trueandsk_test_keys cost the shop no quota or credits ·quotaChargedtells you for each request GET /transactions?status=accepts all 8 values in this table (anything else →400)- A request answered with
cached: truedoes not fire the webhook again — the event is sent once, on the first verification
errorCode in a slip result
There are two places a code appears, and they are different sets — data.errorCode (a successful 200 request · explains the verdict) and code in the error envelope (4xx)
data.errorCode — inside a slip result (POST /verify · GET /transactions)
errorCode | Comes with status | When | What your side should do |
|---|---|---|---|
null | SUCCESS · DUPLICATE · RECEIVER_MISMATCH · AMOUNT_MISMATCH | Nothing more to say | Branch on status |
STALE_SLIP | SUCCESS (or INVALID — see Stale slip) | A genuine slip whose transfer time is over 24 hours old, or future-dated | Decide whether you accept old slips — this is a warning, not a rejection |
NO_PAYEE_CONFIGURED | INVALID | The shop has not added any receiving account — the slip was never sent to the bank | Add one at Settings → Shop → Receiving accounts, then resend the slip |
INVALID | INVALID | Unreadable / not a slip / the bank has no such transaction | Ask the customer for a new slip image |
FRAUD | FRAUD | Fake or edited slip | Do not fulfil |
PROVIDER_ERROR | PROVIDER_ERROR | Verification temporarily unavailable | Resend the same slip with backoff |
QUOTA_EXCEEDED | QUOTA_EXCEEDED | Quota + credits used up (seen in GET /transactions) | Top up credits / upgrade the plan |
errorCode is not always null when status is SUCCESS — never write if (errorCode) reject() · decide pass/fail from verified (or status) only,
and treat an unknown errorCode as "not passed for another reason" — we may add values without it counting as a breaking change · errorMessage is human-readable text (usually Thai); never branch on it
code — inside the error envelope (Error envelope) · only these three cases have it; other errors carry no code field
code | HTTP | When | Quota charged? | What your side should do |
|---|---|---|---|---|
QUOTA_EXCEEDED | 402 | live key — the shop's monthly quota and credits are used up | No | Top up credits / upgrade / wait for the next cycle — retrying won't help |
TEST_QUOTA_EXCEEDED | 402 | test key — all 200 calls this month are used | No | Switch to an sk_live_ key or wait for next month |
PLAN_API_ACCESS_REQUIRED | 403 | The current plan doesn't include the REST API (Trial · expired plan · suspended shop) | No | Upgrade to Starter or above |
Stale slip
A genuine slip reused on a new order still passes the bank check (the bank confirms the transfer exists, not that it "just happened").
So we compare the transfer time (transDate) with the time it was submitted — older than 24 hours, or future-dated, gets the STALE_SLIP flag
· checked only on passing slips — a slip that fails for another reason (e.g. RECEIVER_MISMATCH) carries no STALE_SLIP even when old, since it did not pass anyway
| Mode | What you get | In use? |
|---|---|---|
alert | status: "SUCCESS" · verified: true · errorCode: "STALE_SLIP" — the shop owner is alerted · quota charged as usual | The current setting |
reject | status: "INVALID" · verified: false · errorCode: "STALE_SLIP" — no quota charge | Not enabled (a platform-level setting, not per shop) |
{
"status": "SUCCESS",
"verified": true,
"transDate": "2026-06-15T03:21:05.000Z",
"errorCode": "STALE_SLIP",
"errorMessage": "สลิปลงวันที่ย้อนหลังเกิน 24 ชม. (เก่า 48 ชม.)",
"warnings": [{ "code": "STALE_SLIP", "message": "สลิปลงวันที่ย้อนหลังเกิน 24 ชม. (เก่า 48 ชม.)" }]
}
If your business does not accept old slips, you must check for it — verified === true alone is not enough:
if (slip.verified && slip.warnings.some((w) => w.code === 'STALE_SLIP')) { /* treat as not passed, or send to manual review */ }
(existing code that checks slip.errorCode === 'STALE_SLIP' keeps working)
or compare transDate with your order's creation time under your own rules (e.g. the transfer must come after the order was opened)
Idempotency
Deduplication is automatic — three different cases:
- The "same image" sent again within 24 hours →
cached: true·status/verifiedare the real result of the original transaction (safe to retry after a network cut/timeout — not re-verified, no quota used) - The same
transRefalready verified (a different image file / re-shot photo) →duplicate: trueandstatusis always"DUPLICATE"in this response (verified: false) — whatever the first verification's result was - Resending the image of a slip that already answered
DUPLICATE→ stillDUPLICATE(duplicate: truetogether withcached: true) — thecachedreplay of the original result only applies to an image the API itself verified first · a slip that came in through LINE or the Dashboard earlier isDUPLICATEevery time it is sent to the API
Idempotency-Key — retry the same request with no side effects
cached only covers "the same image, byte for byte, already verified" · if your system retries on a timeout while the first
request is still running, or you want the exact same answer back (including the first call's quotaCharged), send an
Idempotency-Key header — a value unique per request, such as a UUID or your order id
curl -X POST https://api.slip-bolt.com/api/v1/verify \
-H "Authorization: Bearer sk_live_xxx" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/octet-stream" \
--data-binary @slip.jpg
| What happens | Result |
|---|---|
Same key + same slip, after the first call answered 200 | 200 with the original data, every field unchanged, plus the header Idempotent-Replayed: true · not re-verified, no second charge, never turns into DUPLICATE |
| Same key while the first call is still processing | 409 code: "IDEMPOTENCY_IN_PROGRESS" + Retry-After — wait, then resend with the same key |
| Same key but a different slip | 422 code: "IDEMPOTENCY_KEY_REUSED" — use a new key per request |
| Malformed key (empty · over 255 chars · not printable ASCII) | 400 code: "IDEMPOTENCY_KEY_INVALID" |
The first call ended in an error (4xx/5xx, e.g. 402 quota used up) | Not remembered — resend with the same key and it runs for real again |
- Kept for 24 hours per shop (
sk_live_andsk_test_keys are separate · other shops can use the same value without colliding) - "The same slip" is compared on the decoded image — a fresh multipart body (new boundary) still counts as the same request
- The envelope's
meta.requestId/timestampbelong to the current request · theX-Slipbolt-Quota-*headers show the current balance - No header = exactly the old behaviour (optional)
Never credit an order on verified === true alone — always check duplicate first if your system
has already processed that transRef, otherwise an accidental retry/resend credits the order twice
Error envelope
Every error has the same shape:
{
"success": false,
"error": "Forbidden",
"message": "API key missing scope(s): read",
"statusCode": 403,
"path": "/api/v1/quota",
"requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c",
"timestamp": "2026-06-17T03:30:45.120Z"
}
code is present only on some errors (both 402s · the plan 403 · the IDEMPOTENCY_* codes on 400/409/422 · METHOD_NOT_ALLOWED on 405 — see the code table) ·
other errors have no such field, so branch on statusCode · message is a string, except a validation 400 with several problems, where it is an array of strings
· requestId matches that response's X-Request-Id header — quote it when contacting support
| Status | When | Retry? |
|---|---|---|
400 | No image in the request / multipart without a file / image is not base64 / the url could not be downloaded / malformed qr or a payment QR · an unknown query parameter (e.g. ?page=2 → property page should not exist) or a wrong value (limit=500 · status=PASSED) | No — fix the request |
401 | No Authorization: Bearer … header (Missing API key — …) · key wrong or revoked (Invalid or revoked API key) — X-API-Key and Basic auth are not accepted | No — check the key |
402 | Quota + credits used up — QUOTA_EXCEEDED (live key) or TEST_QUOTA_EXCEEDED (test key past 200/month) | No — top up credits / upgrade, or use a live key |
403 | Missing scope (error: "Forbidden" · no code — the example above), or the current plan doesn't include the REST API (code: "PLAN_API_ACCESS_REQUIRED") | No — add the scope / upgrade the plan |
403 | The IP isn't on the key's allowlist — code: "IP_NOT_ALLOWED" (settings) | No — add the IP or call from an allowed machine |
404 | Transaction not found (GET /transactions/:id) — another shop's, or an id that isn't a UUID, gets the same 404 (not 400) · an unknown path is also 404 | No |
405 | Right path, wrong method (e.g. GET /api/v1/verify) — code: "METHOD_NOT_ALLOWED" · the Allow header lists the methods that work | No — fix the method |
409 | IDEMPOTENCY_IN_PROGRESS — a request with the same Idempotency-Key is still processing | Yes — wait for Retry-After, then resend with the same key |
422 | IDEMPOTENCY_KEY_REUSED — this Idempotency-Key was already used for another slip | No — use a new key |
429 | Rate limit exceeded — wait for the Retry-After header (seconds) before calling again | Yes — after waiting for Retry-After |
429 SPEND_CAP_EXCEEDED | The per-key cap set by the shop was reached (spendCap.window = hour/day) | Don't retry in a loop — wait for the next window per Retry-After |
5xx | A temporary fault on our side | Yes — with exponential backoff (resending the same image is safe, no second quota charge) |
Branch on statusCode and code — never match on the message text (wording may change without counting as a breaking change).
A client that already retries per this table is in Recipes
Bank codes
bankCode / receiverBankCode in a slip result (and in webhooks) are the 3-digit bank code as returned by the banking network.
payeeAccounts[].bankCode in GET /me is the slug the shop picked when adding its receiving account — the two formats don't compare directly; map them with this table
| 3-digit code | Slug in /me | Thai name | Bank |
|---|---|---|---|
002 | bbl | กรุงเทพ | Bangkok Bank |
004 | kbank | กสิกรไทย | Kasikornbank |
006 | ktb | กรุงไทย | Krungthai Bank |
011 | ttb | ทีเอ็มบีธนชาต | TMBThanachart (ttb) |
014 | scb | ไทยพาณิชย์ | Siam Commercial Bank |
017 | citi | ซิตี้แบงก์ | Citibank |
020 | sc | สแตนดาร์ดชาร์เตอร์ด (ไทย) | Standard Chartered (Thai) |
022 | cimb | ซีไอเอ็มบี ไทย | CIMB Thai |
024 | uob | ยูโอบี | UOB |
025 | bay | กรุงศรีอยุธยา | Krungsri (Bank of Ayudhya) |
030 | gsb | ออมสิน | Government Savings Bank |
033 | ghb | อาคารสงเคราะห์ (ธอส.) | Government Housing Bank |
034 | baac | ธ.ก.ส. | BAAC |
067 | tisco | ทิสโก้ | TISCO |
069 | kkp | เกียรตินาคินภัทร | Kiatnakin Phatra |
070 | icbc | ไอซีบีซี (ไทย) | ICBC (Thai) |
071 | tcrb | ไทยเครดิต | Thai Credit |
073 | lhbank | แลนด์ แอนด์ เฮ้าส์ | LH Bank |
088 | clicx | คลิกซ์ | CLICX |
Slugs with no 3-digit code (not banks): promptpay (kind: "PROMPTPAY") · truemoney (kind: "WALLET") · other (a bank the shop typed in)
- Treat codes as opaque strings — keep the leading zeros (
"002", not2) and tolerate codes missing from this table: we pass through what the bank returns, so a new bank can appear unannounced · for display, usebankName/receiverBankName - Transfers to PromptPay —
receiverBankCodeis the bank behind the destination account (ornullwhen the slip doesn't say), neverpromptpay - Merchant QR apps (K SHOP · Mae Manee · Thung Ngern · Mung Mee) use their bank's slug (
kbank·scb·ktb·bay) - Don't match the receiver yourself from bank codes — we already compare against the shop's receiving accounts; the outcome is in
status(RECEIVER_MISMATCH)
Next
- Webhooks — receive
slip.verified/slip.duplicate/slip.fraud/slip.failedevents, HMAC-signed, instead of polling - Authentication — key details · quota per plan · rate limit
