core

API Reference

Public REST API for integrators — verify slips, read quota, error formats + statuses for every endpoint

⏱ REST

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

URL
https://api.slip-bolt.com/api/v1

Every request needs the header:

HTTP
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

An OpenAPI spec is available

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

MethodPOST
BodyThe slip image file (JPEG/PNG) · whole request up to 5 MB
Required scopeverify
Send asContent-TypeBody
raw bytes (recommended)application/octet-stream (or image/jpeg · image/png)The image file as raw binary
multipartmultipart/form-dataA file in the field named file (no such field → the first file found)
JSON base64application/json{ "image": "<base64>" } — a data:image/…;base64, prefix is fine
JSON URLapplication/json{ "url": "https://…" } — we download the image · public https only · redirects not followed · up to 5 MB
JSON QRapplication/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 INVALID or FRAUD (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

JSON
{
  "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
}
  • DUPLICATE returns the original transaction's data (id · transRef · amount are from the first verification) — use transRef to find which order already used this slip
  • RECEIVER_MISMATCH · AMOUNT_MISMATCH were read in full, so transRef / amount / receiver are there to show where the money went · errorCode is null — branch on status
  • INVALID · FRAUD carry no slip data you can trust — transRef is null and amount is 0

Fields in data

The first group (through createdAt) is the same shape GET /transactions returns — one parser handles both

FieldTypeMeaning
idstring (uuid)Transaction id — use it with GET /transactions/:id
statusstringThe verdict — see Transaction status
verifiedbooleantrue only when status is SUCCESS
sourcestringChannel the slip arrived through: API · LINE · LIFF · DASHBOARD
transRefstring | nullBank reference — use it as your unique key · null when it could not be read from the slip
amountnumberAmount transferred (THB) — 0 when it could not be read
bankCode · bankNamestring | nullSender's bank — bankCode is a 3-digit code, see Bank codes
senderName · senderAccountstring | nullSender — account digits are masked as the bank provides them
receiverName · receiverAccountstring | nullReceiver — receiverAccount is the bank account the money landed in
receiverProxyAccountstring | nullReceiver'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 · receiverBankNamestring | nullReceiver's bank (also a 3-digit code)
transDatestring (ISO-8601 UTC) | nullTransfer time according to the bank
errorCode · errorMessagestring | nullReason when it did not pass — or a warning when it passed (STALE_SLIP) · see errorCode
warningsarrayWarnings 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)
createdAtstring (ISO-8601 UTC)When we checked it
receiptobject | nullVerification receipt — code (the confirmation code printed on it) · checkUrl (the link in its QR) · SUCCESS only · image/PDF: see receipt.png
duplicatebooleanThis transRef was already checked (status = DUPLICATE)
cachedbooleanSame image resent within 24 hours — the original result is returned, no quota charge
quotaChargedbooleanWhether 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
latencyMsinteger | nullTime spent checking with the bank
environmentstringlive or test, by key type
quotaobjectused · 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:

HeaderMeaning
X-Slipbolt-Quota-LimitSlips/month ceiling of the current plan
X-Slipbolt-Quota-UsedUsed so far this month
X-Slipbolt-Quota-RemainingRemaining
X-Slipbolt-Quota-Reset-DaysDays 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

JSON
{ "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

MethodGET
Required scoperead
curl "https://api.slip-bolt.com/api/v1/quota" \
  -H "Authorization: Bearer $SLIPBOLT_API_KEY"
JSON
{
  "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

MethodGET
Required scoperead
curl "https://api.slip-bolt.com/api/v1/me" \
  -H "Authorization: Bearer $SLIPBOLT_API_KEY"
JSON
{
  "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

MethodGET
Required scoperead
Querystatus · 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"
JSON
{
  "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)

Terminal
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 in data.receipt of 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 scoperead
Terminal
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)

Terminal
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
HTTP/1.1 202 Accepted
Location: /api/v1/verifications/vrf_3f9a1c0b7e2d4a6f8b1c2d3e
JSON
{
  "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):

  1. Webhook verification.completed — tick this event on your endpoint in Settings → Webhooks (payload)
  2. Poll GET /api/v1/verifications/{id} every 1–2 seconds (does not spend a verify)

GET /api/v1/verifications/:id

statusMeaning
PENDING · PROCESSINGNot finished yet
COMPLETEDDone — 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)
FAILEDNot 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.codeWhen
QUOTA_EXCEEDED · TEST_QUOTA_EXCEEDEDQuota/credits used up (sync = 402)
SPEND_CAP_EXCEEDEDPer-key cap the shop set was reached (sync = 429)
VERIFY_TIMEOUTWaited in the queue for more than 5 minutes — never checked
API_KEY_REVOKEDThe key was revoked before the check finished
INTERNAL_ERROROur side failed even after retrying
  • Results are kept for 24 h, then 404 — the permanent record is GET /transactions/{result.id}
  • An id from another shop / a key of the other environment (sk_live_ vs sk_test_) → 404
  • Same Idempotency-Key + same slip = the same 202 (same id · Idempotent-Replayed: true), no new job · async keys are separate from POST /verify (the same value on both routes does not collide)
  • Pending jobs per shop are capped (50 by default) — when full you get 429 code: "ASYNC_QUEUE_FULL" + Retry-After
  • Our queue is temporarily unavailable → 503 code: "ASYNC_UNAVAILABLE" — send to POST /verify instead

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)

ScopeUsed for
verifyPOST /api/v1/verify (default on every key)
readGET /api/v1/quota · GET /api/v1/me · GET /api/v1/transactions[/:id] · GET /api/v1/transactions/:id/receipt.{png,pdf}
webhookReserved 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

EndpointWhat it tells youResponse
GET /api/v1/healththe process is accepting requests (touches no dependency)200 { "status": "ok" }
GET /api/v1/readyslips can actually be verified right now — checks the database · queue · verification service (cached 15 s)200 ok / degraded · 503 down
JSON
{ "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:

HeaderMeaningUnit
X-RateLimit-LimitThis endpoint's ceiling per source IP — 120 everywhere · 240 on POST /verifyrequests/minute
X-RateLimit-RemainingLeft in the current window (per IP)requests
X-RateLimit-ResetTime until the window resetsseconds (not an epoch)
Retry-AfterOnly with 429 — how long to wait before calling againseconds
X-Request-IdThe request id — same value as meta.requestId / requestId in an errorUUID

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

statusverifiedQuota/credit chargedWebhookMeaning · what to do next
SUCCESS✅1 slip (live key)slip.verifiedValid slip, paid into the shop's account → compare amount with your order total, then fulfil · check errorCode too — STALE_SLIP = stale slip
DUPLICATE❌Noneslip.duplicateThis reference (transRef) was already verified → do not fulfil again
AMOUNT_MISMATCH❌Noneslip.failedThe 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❌Noneslip.failedPaid into an account that isn't the shop's → check payeeAccounts in GET /me: has the account the customer paid into been added?
INVALID❌Noneslip.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❌Noneslip.fraudFake/edited slip — does not match bank records → do not fulfil
QUOTA_EXCEEDED❌NoneNoneQuota used up — POST /verify answers 402, not 200 (see above) · this status only shows up in GET /transactions
PROVIDER_ERROR❌NoneNoneBank verification is temporarily unavailable (200 + this status) — not a verdict → resend the same slip (it is verified afresh, not served from cache)
  • Only SUCCESS counts against quota — every non-pass outcome, duplicates, cached: true and sk_test_ keys cost the shop no quota or credits · quotaCharged tells you for each request
  • GET /transactions?status= accepts all 8 values in this table (anything else → 400)
  • A request answered with cached: true does 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)

errorCodeComes with statusWhenWhat your side should do
nullSUCCESS · DUPLICATE · RECEIVER_MISMATCH · AMOUNT_MISMATCHNothing more to sayBranch on status
STALE_SLIPSUCCESS (or INVALID — see Stale slip)A genuine slip whose transfer time is over 24 hours old, or future-datedDecide whether you accept old slips — this is a warning, not a rejection
NO_PAYEE_CONFIGUREDINVALIDThe shop has not added any receiving account — the slip was never sent to the bankAdd one at Settings → Shop → Receiving accounts, then resend the slip
INVALIDINVALIDUnreadable / not a slip / the bank has no such transactionAsk the customer for a new slip image
FRAUDFRAUDFake or edited slipDo not fulfil
PROVIDER_ERRORPROVIDER_ERRORVerification temporarily unavailableResend the same slip with backoff
QUOTA_EXCEEDEDQUOTA_EXCEEDEDQuota + 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

codeHTTPWhenQuota charged?What your side should do
QUOTA_EXCEEDED402live key — the shop's monthly quota and credits are used upNoTop up credits / upgrade / wait for the next cycle — retrying won't help
TEST_QUOTA_EXCEEDED402test key — all 200 calls this month are usedNoSwitch to an sk_live_ key or wait for next month
PLAN_API_ACCESS_REQUIRED403The current plan doesn't include the REST API (Trial · expired plan · suspended shop)NoUpgrade 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

ModeWhat you getIn use?
alertstatus: "SUCCESS" · verified: true · errorCode: "STALE_SLIP" — the shop owner is alerted · quota charged as usualThe current setting
rejectstatus: "INVALID" · verified: false · errorCode: "STALE_SLIP" — no quota chargeNot enabled (a platform-level setting, not per shop)
JSON
{
  "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/verified are the real result of the original transaction (safe to retry after a network cut/timeout — not re-verified, no quota used)
  • The same transRef already verified (a different image file / re-shot photo) → duplicate: true and status is always "DUPLICATE" in this response (verified: false) — whatever the first verification's result was
  • Resending the image of a slip that already answered DUPLICATE → still DUPLICATE (duplicate: true together with cached: true) — the cached replay 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 is DUPLICATE every 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

Terminal
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 happensResult
Same key + same slip, after the first call answered 200200 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 processing409 code: "IDEMPOTENCY_IN_PROGRESS" + Retry-After — wait, then resend with the same key
Same key but a different slip422 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_ and sk_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/timestamp belong to the current request · the X-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:

JSON
{
  "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

StatusWhenRetry?
400No 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
401No Authorization: Bearer … header (Missing API key — …) · key wrong or revoked (Invalid or revoked API key) — X-API-Key and Basic auth are not acceptedNo — check the key
402Quota + 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
403Missing 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
403The IP isn't on the key's allowlist — code: "IP_NOT_ALLOWED" (settings)No — add the IP or call from an allowed machine
404Transaction 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 404No
405Right path, wrong method (e.g. GET /api/v1/verify) — code: "METHOD_NOT_ALLOWED" · the Allow header lists the methods that workNo — fix the method
409IDEMPOTENCY_IN_PROGRESS — a request with the same Idempotency-Key is still processingYes — wait for Retry-After, then resend with the same key
422IDEMPOTENCY_KEY_REUSED — this Idempotency-Key was already used for another slipNo — use a new key
429Rate limit exceeded — wait for the Retry-After header (seconds) before calling againYes — after waiting for Retry-After
429 SPEND_CAP_EXCEEDEDThe 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
5xxA temporary fault on our sideYes — 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 codeSlug in /meThai nameBank
002bblกรุงเทพBangkok Bank
004kbankกสิกรไทยKasikornbank
006ktbกรุงไทยKrungthai Bank
011ttbทีเอ็มบีธนชาตTMBThanachart (ttb)
014scbไทยพาณิชย์Siam Commercial Bank
017citiซิตี้แบงก์Citibank
020scสแตนดาร์ดชาร์เตอร์ด (ไทย)Standard Chartered (Thai)
022cimbซีไอเอ็มบี ไทยCIMB Thai
024uobยูโอบีUOB
025bayกรุงศรีอยุธยาKrungsri (Bank of Ayudhya)
030gsbออมสินGovernment Savings Bank
033ghbอาคารสงเคราะห์ (ธอส.)Government Housing Bank
034baacธ.ก.ส.BAAC
067tiscoทิสโก้TISCO
069kkpเกียรตินาคินภัทรKiatnakin Phatra
070icbcไอซีบีซี (ไทย)ICBC (Thai)
071tcrbไทยเครดิตThai Credit
073lhbankแลนด์ แอนด์ เฮ้าส์LH Bank
088clicxคลิกซ์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", not 2) and tolerate codes missing from this table: we pass through what the bank returns, so a new bank can appear unannounced · for display, use bankName / receiverBankName
  • Transfers to PromptPay — receiverBankCode is the bank behind the destination account (or null when the slip doesn't say), never promptpay
  • 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.failed events, HMAC-signed, instead of polling
  • Authentication — key details · quota per plan · rate limit