API integration guide
Guide to integrating the automated bank slip verification API, for merchants and developers
Base URL
The base for every endpoint in this guide
https://api.slip2try.comAPI version v1 (every endpoint lives under /api/v1) · last updated 2026-10-23
- IPs that send more than 30 bad keys a minute: invalid keys now get HTTP 429 with the new reason auth_locked (was rate_limited — code that backs off on rate_limited should handle auth_locked too) · a valid key in use from the same IP keeps working
- The per-minute limit is now counted separately for live keys and test keys — heavy testing never eats your live budget · every response that passes the key check carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds)
- /usage: today.calls only counts slips the bank actually decided (requests rejected before checking and checker outages are excluded) · adds environment and breakdown { denied, errors } · test keys count their simulated checks
- Webhooks: for 24 h after a secret rotation an X-VerifySlips-Signature-Previous header (V1 signature with the old secret) is sent too, so V1 verifiers keep working · the first attempt is sent right after the API response that triggered it (never before it)
- Webhooks: rotate the secret from the Webhook page — the old secret keeps signing in the new X-VerifySlips-Signature-V2-Previous header for 24 hours, so you can update your secret without a gap
- Webhooks: pause delivery — while paused no new events are queued (nothing is replayed when you resume) · the test button and deliveries already waiting to retry still send
- Portal: API request logs (every request with a valid key, kept 14 days, searchable by requestId) · an API playground for test keys · a developer overview with an integration checklist
- Every non-2xx response from /api/v1/* now has the same shape: success: false, status, code, reason, message and requestId (400 validation errors add param with the field name) · unknown paths return 404 not_found in the same shape · 401 keeps the legacy error: invalid api key field for one more release
- POST /api/v1/verify/slip now accepts multipart/form-data: the image in the file field (or qrData instead), plus an optional expectedAmount
- New reasons: invalid_api_key (401), payload_too_large (413), unsupported_media_type (415), too_many_failed (429) and internal_error (500)
- Every 429 and 503 response carries a Retry-After header (seconds), test keys included: rate_limited = 60 · temporarily_unavailable = 5 · too_many_failed and the test-key daily cap = seconds until the window resets
- Webhooks: the body has a top-level id (stable across retries — dedupe on it) and environment · new X-VerifySlips-Delivery header · verify.* payloads include channel and requestId
- /balance, /usage and /ping return requestId · /balance returns environment · /usage counts checks from every channel (API + LINE + web)
- The Bearer scheme in Authorization is case-insensitive · expectedAmount is validated before the image is read · WebP images get 415 · the callback URL must be https · lookup endpoints answer 503 temporarily_unavailable when an upstream source fails
- data.transfer is the canonical path for transfer details — data.slipVerification.transfer is still returned but deprecated
Create an API key → send a slip verification request → review the history
- Step 1
Create an API key
After signing up, open the API keys page and create a key (none is created automatically). The secret is shown only once — copy it and keep it on your server.
- Step 2
Send the slip QR for verification
Read the QR value from the slip (it starts with 00...) and send it to
POST /api/v1/verify/slipThe response includesdata.transferwith the actual amount, andduplicatetells you whether the slip is a duplicate - Step 3
Review every past result
Every request is logged. Click a row to see the slip details, sender, receiver, amount and source IP.
8 things to get right before you take real payments
- 1Call the API only from your server — never ship the key in web pages or apps · build against an s2t_test_ key, then switch to s2t_live_ for production
- 2Send expectedAmount = the order total with every slip
- 3Deliver only when success && !duplicate && warnings.length === 0 && environment === "live" — anything else is rejected or goes to manual review
- 4Read transfer details from data.transfer · store transactionRef as a unique key and keep verificationId with the order
- 5Branch on success + reason only — never on the message text
- 6Use a 60-second client timeout · on a timeout or dropped connection, look the result up with GET /api/v1/verifications?qrData= (or ?requestId=) before resending
- 7On 429 or 503, wait Retry-After seconds before retrying · queue bulk work (120 requests/min per shop by default)
- 8Webhooks: verify X-VerifySlips-Signature-V2 over the raw body and reject timestamps more than 5 minutes off · dedupe on the body id · answer 2xx within 10 seconds
On the left is the request code; on the right are the decoded slip fields and the verified bank logos.
Request code
POST /api/v1/verify/slip
# Deliver only when success: true, duplicate: false, warnings: [] and environment: "live" · expectedAmount = order total
curl -X POST https://api.slip2try.com/api/v1/verify/slip \
--max-time 60 \
-H "Authorization: Bearer s2t_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"qrData":"0038000600000101030060217A3828f7b4f3244ff35102TH910480CE","expectedAmount":100000}'
# Timeout or dropped connection: look the result up before resending (uses no quota)
curl -G https://api.slip2try.com/api/v1/verifications \
--max-time 60 \
-H "Authorization: Bearer s2t_live_xxxxxxxx" \
--data-urlencode "qrData=0038000600000101030060217A3828f7b4f3244ff35102TH910480CE"Keep your key on the server only
s2t_live_xxxxxxxx with your real key, and never embed the key in front-end or mobile app code — call the API only from your backend server.Decoded slip example

Krungthai Business · Krungthai Bank
Verified against the bank
ktbAmount transferred
100,000.00 THB

Sender account
น.ส.สมศรี ใจดี

Receiver account
EXAMPLE CO., LTD.
- transactionRef
- A3828f7b4f3244ff3
- transactionDateTime
- 2026-09-01T17:51:01+07:00
- amount.amount
- 100,000.00 THB
- duplicate
- false · new slip
Same structure for every provider
data.transfer once and it works with KTB, CIMB, SCB and Slip2Go right away.Check duplicate every time before crediting or shipping
success: true does not mean a new payment
success only says the bank confirmed the slip is real. If duplicate: true, your shop has already verified this slip successfully — the customer may be reusing an old slip.How to read the result
| Response | Result | What to do |
|---|---|---|
| success: true · duplicate: false | New slip | Top up / ship the order |
| success: true · duplicate: true | Duplicate slip | Reject — the customer may be reusing a slip |
| success: false | Not verified | Don't deliver; ask the customer to check the slip |
The first verification returns duplicate: false and duplicateInfo: null
Sample response for a duplicate slip
{
"success": true,
"cached": true,
"duplicate": true,
"duplicateInfo": {
"firstVerifiedAt": "2026-10-14T04:14:49.145Z",
"previousCount": 2
},
"data": { "...": "Same transfer details as the first verification" }
}Counted per shop
A slip verified by another shop doesn't count as a duplicate for yours — your first check still uses 1 slip as usual
Matched by QR or reference
The same QR, or a different QR with the same
transactionRef, counts as a duplicate — and duplicates use no slipsFailed checks don't count
A verification that failed isn't counted as a previous check
Concurrent requests, one winner
If the same slip is sent in several requests at once, only one gets
duplicate: falseWebhooks tell you too
verify.successincludesdata.duplicateanddata.firstVerifiedAtReview it in the portal
The Verification history page shows a “Duplicate” badge and has a filter for it
Add another layer in your own system
transactionRef as a unique key in your order database too, in case your code calls twice or receives a webhook twice.Try every case with a test key before going live — simulated results, no bank call, no quota used
- Test keys start with
s2t_test_— create one on the API keys page by choosing the “Test” type. They use the same base URL and endpoints as live keys - You get a simulated result right away: nothing is sent to the bank or checker, no quota is used, and it works even before the shop has a plan
- Test keys can verify up to 1,000 slips per day per shop (beyond that: HTTP 429 reason: rate_limited) · test results are kept for 7 days
- Webhooks still fire for the events you selected, but carry the
X-VerifySlips-Test: trueheader and"environment": "test"in the body, and failed deliveries aren't retried - Sending the same QR again gives duplicate: true, just like live · slip images work too (the QR is read from the image and the same rules apply) · a payment QR (000201…) still gets 400 payment_qr
- Test keys only see test results and live keys only see live results (GET /api/v1/verifications) · ping returns environment: test and ready: true
Test results aren't real payments
environment in both responses and webhooks and never deliver goods for test results — switch to an s2t_live_ key when you go liveChoose the result with the end of the QR text
| QR ends with | Result | In the response |
|---|---|---|
| …FAIL | The bank rejects the slip (HTTP 200, no reason) — verify.failed webhook | success: false |
| …ERROR | The checker is temporarily unavailable — safe to retry | HTTP 503 · reason: temporarily_unavailable |
| …DUP | Duplicate slip — duplicateInfo has the first verification time | duplicate: true · warnings: ["duplicate"] |
| …MISMATCH | Paid into another account (checks.receiver: mismatch) | warnings: ["receiver_mismatch"] |
| …AMOUNT | The amount differs from expectedAmount (checks.amount: mismatch) — send expectedAmount too | warnings: ["amount_mismatch"] |
| …OLD | The transfer is 48 hours old (checks.stale: true) | warnings: ["stale_slip"] |
| Anything else | Success — amount = expectedAmount (100 baht if not sent) · receiver = the shop's first receiving account (not_configured if none is set) | success: true · warnings: [] |
We look at the end of qrData (case-insensitive), e.g. ORDER-1001-FAIL — qrData must still be 10–1000 characters, as with live keys
Examples
Request · test key
curl -X POST https://api.slip2try.com/api/v1/verify/slip \
-H "Authorization: Bearer s2t_test_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"qrData":"TEST-ORDER-1001-AMOUNT","expectedAmount":250}'Click an item to expand or collapse it and see the required parameters, a sample request, and what the response looks like on success and failure.
Send the slip image or the QR text on the slip. The system verifies it with the bank and returns the actual transfer details, plus checks for old slips and the receiving account.
Scope: verify:slip
Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
qrData | string | The value read from the QR code on the slip, usually starting with 00 — send the whole string as read; don't change letter case or drop characters (leading/trailing whitespace is trimmed for you) · send either qrData or image | |
image | string (base64) | Slip image (JPEG/PNG) as base64 — a data:image/…;base64, prefix is fine; we read the QR for you. Keep the image under ~3 MB (4 MB total body) · or send the image file as the raw body with Content-Type: image/jpeg or image/png · or multipart/form-data with the file field — 4 MB for the whole request · WebP, GIF, HEIC, AVIF and PDF get 415 unsupported_media_type | |
expectedAmount | number | string | The order total (baht), compared with the slip for you — send it every time · a number or numeric string, greater than 0, at most 2 decimals · for a raw image body pass it as ?expectedAmount= in the URL; for multipart send it as an expectedAmount text field · it's checked before the image is read; an invalid value returns 400 invalid_request (param: expectedAmount) |
Sample request
{
"qrData": "0038000600000101030060217A3828f7b4f3244ff35102TH910480CE",
"expectedAmount": 100000
}Sample response
{
"status": "Success",
"code": "200",
"success": true,
"message": "Bank slip QR code verified successfully",
"qrData": "0038000600000101030060217A3828f7b4f3244ff35102TH910480CE",
"verificationId": "6f1c2a9e-4b7d-4c1e-9a52-0d3f8e7b1c24",
"environment": "live",
"source": "qr",
"requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c",
"provider": "ktb",
"cached": false,
"duplicate": false,
"duplicateInfo": null,
"warnings": [],
"checks": {
"stale": false,
"slipAgeHours": 0.1,
"receiver": "matched",
"receiverLabel": "บัญชีหลัก",
"amount": "matched",
"expectedAmount": 100000
},
"data": {
"type": "SLIP_VERIFICATION",
"payment": null,
"transfer": {
"transactionRef": "A3828f7b4f3244ff3",
"transactionDateTime": "2026-09-01T17:51:01+07:00",
"fromBankCode": "006",
"fromBankName": "กรุงไทย",
"fromAccountNo": "XXX-X-XX123-4",
"fromAccountName": "น.ส.สมศรี ใจดี",
"fromAccountNameTh": "น.ส.สมศรี ใจดี",
"fromAccountNameEn": "MS SOMSRI JAIDEE",
"toBankCode": "014",
"toBankName": "ไทยพาณิชย์",
"toAccountNo": "XXX-X-XX567-8",
"toAccountName": "EXAMPLE CO., LTD.",
"toAccountNameTh": "บริษัท ตัวอย่าง จำกัด",
"toAccountNameEn": "EXAMPLE CO., LTD.",
"amount": {
"amount": 100000,
"currency": { "code": "THB", "displayName": "บาท", "symbol": "฿" }
}
},
"slipVerification": {
"transfer": { "...": "deprecated, same data as data.transfer" }
}
}
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
success | boolean | Whether the bank confirmed the slip — check this value, not the HTTP status |
verificationId | string | Id of this verification — same as data.verificationId in the webhook; fetch it later with GET /api/v1/verifications/:id (absent when the request is denied with a reason) |
environment | "live" | "test" | live = live key, verified with the bank · test = test key, simulated result (never use it to decide on delivery) |
warnings | string[] | Things to check before delivering: duplicate · receiver_mismatch · amount_mismatch · stale_slip — an empty array means nothing to worry about |
checks.receiver | string | matched = paid into one of the shop's receiving accounts · mismatch = paid into another account · not_configured = the shop hasn't set receiving accounts (set them on the LINE Bot page) |
checks.amount | string | matched = the amount equals expectedAmount · mismatch = it doesn't (warnings include amount_mismatch) · not_checked = no expectedAmount was sent |
checks.expectedAmount | number | null | The amount you sent for comparison (null = not sent) |
checks.stale | boolean | true = the transfer time is more than 24 hours old or in the future — possibly a real slip reused for a new order |
requestId | string | Request id, same as the X-Request-Id header — include it when contacting support and we can find the request instantly |
source | string | qr = you sent the QR text · image = we read the QR from your image |
duplicate | boolean | true = your shop has already verified this slip successfully. Always check it before crediting or shipping |
duplicateInfo.firstVerifiedAt | string | null | When the slip was first verified successfully (ISO 8601) — the whole object is null when duplicate: false |
duplicateInfo.previousCount | number | How many times your shop has successfully verified this slip before |
cached | boolean | true = the result came from the shared slip cache (this slip was verified before, so the bank wasn't asked again). It's free only when it's your shop's duplicate (duplicate: true); a slip first verified by another shop still uses 1 slip |
provider | string | null | The checker that answered this request, e.g. ktb (may be null) |
data.transfer | object | All transfer details — read them from this path |
…transfer.transactionRef | string | Transaction reference, used to prevent duplicate slips and to search the history page |
…transfer.transactionDateTime | string | Transaction time as provided by the bank (ISO-8601 string) — format and time zone can vary by bank |
…transfer.amount.amount | number | null | Amount in baht — nested one more level under amount, e.g. 100000 means 100,000 baht (not satang). May be null |
…transfer.fromAccountNo | string | Sender's account number, partially masked by the bank |
data.slipVerification.transfer | object | Old name for data.transfer (same data) — deprecated; new code should read data.transfer |
Notes and tips
- The response structure is the same for every provider, so one set of code works with all of them — but field values are whatever the bank returns.
- transactionDateTime is passed through as returned by the bank (ISO-8601) — parse it with a date library, don't slice the string, and don't assume a fixed time zone.
- provider and amount.amount can be null — if you rely on the amount, always check it has a value first.
- toBankCode may be empty when the destination is PromptPay or an e-wallet — don't assume it always has a value.
- Account numbers are masked by each bank, so formats can differ, e.g. xxxx-xx328-0 vs xxxxxx3280 — use transactionRef as the reference instead.
- Account names come in both Th and En, but some banks don't send both — be prepared for null.
- cached: true means the answer came from the shared cache (faster). Your shop's duplicates use no extra slips and come back with duplicate: true; a slip another shop verified first still uses 1 slip. See 'Duplicate slip protection'.
- When the bank rejects a slip there is no reason field — reason only appears on non-2xx responses (denied or errored, see 'Rejections and errors'). success: false with no reason means the bank couldn't verify that slip, e.g. the slip is too old or the transaction wasn't found.
- The message text is in Thai and may change — base your logic only on success, duplicate, warnings and reason.
- success: true means the bank confirmed the slip is real — still check warnings: receiver_mismatch = the money didn't go to your account · amount_mismatch = the amount doesn't match expectedAmount · stale_slip = an old slip (we don't reject these; apply your own rules)
- Send expectedAmount and we compare the amount for you — a different amount is still success: true but comes with warnings: amount_mismatch and checks.amount: mismatch (if the bank doesn't return an amount, it counts as a mismatch)
- Raw image upload: curl -X POST "…/api/v1/verify/slip?expectedAmount=250" -H "Authorization: Bearer $API_KEY" -H "Content-Type: image/jpeg" --data-binary @slip.jpg (leave out ?expectedAmount= if you don't need the amount check)
- Use a 60-second client timeout. If a request times out or the connection drops, don't resend the slip straight away — call GET /api/v1/verifications?qrData=… (or ?requestId=… if you already got the X-Request-Id header) to get the result first; it uses no quota. If a resend comes back duplicate: true, check whether the first verification was your own timed-out request
- multipart/form-data upload: curl -X POST "…/api/v1/verify/slip" -H "Authorization: Bearer $API_KEY" -F "file=@slip.jpg" -F "expectedAmount=250" — a JPEG/PNG in the file field (or a qrData text field instead of the image) · 4 MB for the whole request · the response has source: image
- Test keys (s2t_test_…) return simulated results with environment: test — see 'Test mode'
- A payment QR (PromptPay / merchant QR, starting with 000201) isn't a slip QR — you get reason: payment_qr right away and no slip is used
Fetch a past result by verificationId (from the verify response or a webhook) — nothing is re-verified and no quota is used
Scope: verify:slip
Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | string (path) | Required | verificationId from the POST /api/v1/verify/slip response or data.verificationId in a webhook — in the path |
Sample request
// GET /api/v1/verifications/6f1c2a9e-4b7d-4c1e-9a52-0d3f8e7b1c24
// Authorization: Bearer s2t_live_xxxxxxxxSample response
{
"success": true,
"data": {
"verificationId": "6f1c2a9e-4b7d-4c1e-9a52-0d3f8e7b1c24",
"environment": "live",
"status": "success",
"channel": "api",
"createdAt": "2026-10-14T04:20:11.512Z",
"requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c",
"qrData": "0038000600000101030060217A3828f7b4f3244ff35102TH910480CE",
"transactionRef": "A3828f7b4f3244ff3",
"amount": 100000,
"message": null,
"reason": null,
"duplicate": false,
"cached": false,
"warnings": [],
"checks": {
"stale": false,
"slipAgeHours": 0.1,
"receiver": "matched",
"receiverLabel": "บัญชีหลัก",
"amount": "matched",
"expectedAmount": 100000
},
"transfer": { "...": "Same data as data.transfer" }
},
"requestId": "9d4e2b17-5c6a-4f3e-8b1d-2a7c0e9f6b35"
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
data | object | One verification result (fields in the rows below) |
…verificationId | string | Verification id, same as verificationId in the verify response and in webhooks |
…environment | "live" | "test" | live = real result · test = simulated result from a test key |
…status | "success" | "failed" | success = the bank confirmed the slip · failed = rejected or denied |
…channel | "api" | "line" | "web" | Where the slip came from: api · line (the shop's LINE bot) · web (the slip checker in the portal) |
…createdAt | string | When it was verified (ISO 8601) |
…amount | number | null | Amount in baht — null when the slip failed |
…reason | string | null | Why the request was denied before reaching the bank, or a checker outage — null = the bank decided |
…warnings | string[] | Same as in the verify response: duplicate · receiver_mismatch · amount_mismatch · stale_slip |
…checks | object | null | The same checks as at verification time (stale · receiver · amount) — may be null |
…transfer | object | null | Transfer details, same shape as data.transfer in the verify response — null when the slip failed |
Notes and tips
- Returns the result as stored at verification time — the bank isn't asked again and no quota is used
- Not found or not your shop's result → HTTP 404 with reason: not_found
- Test keys only see test results (kept for 7 days) and live keys only see live results — using the other key type returns 404
- Requires the verify:slip permission and passes the same checks as slip verification (IP whitelist · rate limit)
Look up your shop's results by qrData, transactionRef or requestId — e.g. after a timeout, ask here instead of sending the slip again
Scope: verify:slip
Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
qrData | string (query) | The QR text you sent for verification (exact match · URL-encode it in the query string) | |
transactionRef | string (query) | Transaction reference from the result | |
requestId | string (query) | requestId (X-Request-Id header) of that verify request | |
limit | number (query) | Maximum number of results, 1–50 (default 20) |
Sample request
// GET /api/v1/verifications?transactionRef=A3828f7b4f3244ff3&limit=5
// Authorization: Bearer s2t_live_xxxxxxxxSample response
{
"success": true,
"data": [
{
"verificationId": "6f1c2a9e-4b7d-4c1e-9a52-0d3f8e7b1c24",
"environment": "live",
"status": "success",
"channel": "api",
"createdAt": "2026-10-14T04:20:11.512Z",
"requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c",
"qrData": "0038000600000101030060217A3828f7b4f3244ff35102TH910480CE",
"transactionRef": "A3828f7b4f3244ff3",
"amount": 100000,
"message": null,
"reason": null,
"duplicate": false,
"cached": false,
"warnings": [],
"checks": {
"stale": false,
"slipAgeHours": 0.1,
"receiver": "matched",
"receiverLabel": "บัญชีหลัก",
"amount": "matched",
"expectedAmount": 100000
},
"transfer": { "...": "Same data as data.transfer" }
}
],
"requestId": "1f7a3c9e-2d4b-4e8a-b6c5-9e0d1a2b3c4d"
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
data | object[] | Matching results, newest first — an empty array if nothing matches |
…verificationId | string | Verification id, same as verificationId in the verify response and in webhooks |
…environment | "live" | "test" | live = real result · test = simulated result from a test key |
…status | "success" | "failed" | success = the bank confirmed the slip · failed = rejected or denied |
…channel | "api" | "line" | "web" | Where the slip came from: api · line (the shop's LINE bot) · web (the slip checker in the portal) |
…createdAt | string | When it was verified (ISO 8601) |
…amount | number | null | Amount in baht — null when the slip failed |
…reason | string | null | Why the request was denied before reaching the bank, or a checker outage — null = the bank decided |
…warnings | string[] | Same as in the verify response: duplicate · receiver_mismatch · amount_mismatch · stale_slip |
…checks | object | null | The same checks as at verification time (stale · receiver · amount) — may be null |
…transfer | object | null | Transfer details, same shape as data.transfer in the verify response — null when the slip failed |
Notes and tips
- Filters can be combined (all must match) · no filter = your shop's latest results
- Newest first · nothing found = HTTP 200 with an empty data array
- Invalid filters or a limit outside 1–50 → HTTP 400 with reason: invalid_request
- Uses no quota · test keys only see test results, live keys only see live results
Not available yet — every call is rejected by the key permission check with HTTP 403 reason: service_disabled
Scope: verify:name
Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
accountNumber | string | Required | Account number, digits only, without dashes |
bankCode | string | Required | 3-digit bank code, e.g. 014 = SCB, 004 = Kasikornbank, 006 = Krungthai |
Sample request
{
"accountNumber": "1234567890",
"bankCode": "014"
}Sample response
{
"success": true,
"message": "Account name verified",
"data": {
"accountNumber": "1234567890",
"accountName": "นาย สมชาย ใจดี",
"accountNameTh": "นาย สมชาย ใจดี",
"accountNameEn": "MR. SOMCHAI JAIDEE",
"bankCode": "014",
"bankName": "ไทยพาณิชย์",
"iconUrl": "https://raw.githubusercontent.com/casperstack/thai-banks-logo/master/icons/SCB.png"
}
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
data.accountName | string | Thai name, same as accountNameTh, kept for backward compatibility |
data.accountNameEn | string | English name; may be empty if the bank doesn't provide it |
data.bankName | string | Bank name matching the bankCode you sent |
data.iconUrl | string | URL of the official bank logo, ready to display on your site |
Notes and tips
- This service is not available yet — the sample above shows the format it will use once launched
- For now every request fails the key permission check and returns HTTP 403 with reason: service_disabled (the same error shape as everywhere else)
- bankCode uses the same codes as the 'Bank codes in results' section
Checks that your key, IP whitelist and plan are ready without verifying a real slip or using quota — ideal for first-time setup
Scope: verify:slip
Request parameters
No request body needed — just attach the Authorization header: Authorization: Bearer s2t_live_xxxxxxxx
Sample request
// GET Request Header:
// Authorization: Bearer s2t_live_xxxxxxxxSample response
{
"success": true,
"message": "เชื่อมต่อสำเร็จ พร้อมตรวจสลิป",
"ready": true,
"environment": "live",
"merchant": { "code": "M0001", "name": "Your shop" },
"apiKey": { "name": "Main server", "keyPrefix": "s2t_live_ab12" },
"yourIp": "203.0.113.10",
"plan": { "name": "BASIC", "remainingSlips": 1850, "expiresAt": "2026-11-13T17:00:00.000Z", "expired": false },
"requestId": "e3a7c1f9-5b2d-4e8a-9c6f-1d4b7e0a3c52"
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
ready | boolean | true = you can send slips right away (always true for test keys) · false = the plan has expired or slips have run out (credit mode: balance below the cost of one check) |
environment | "live" | "test" | live = live key · test = test key (simulated results, no plan needed) |
yourIp | string | The IP we see from your server — add it to the IP whitelist as is |
plan.remainingSlips | number | null | Remaining slips (null = unlimited) |
requestId | string | Request id, same as the X-Request-Id header — include it when contacting support and we can find the request instantly |
Notes and tips
- Runs every check that slip verification does (key · shop · IP · rate), so you can use it to find out why requests are rejected
- Doesn't call the bank and doesn't use quota
- Test keys always get environment: test and ready: true, even before the shop has a plan
Your merchant's remaining credit — check it before sending a large batch (no body needed — just the key header)
Scope: verify:slip
Request parameters
No request body needed — just attach the Authorization header: Authorization: Bearer s2t_live_xxxxxxxx
Sample request
// GET Request Header:
// Authorization: Bearer s2t_live_xxxxxxxxSample response
{
"success": true,
"environment": "live",
"balance": 1850,
"currency": "credit",
"plan": {
"name": "BASIC",
"billingMode": "package",
"remainingSlips": 1850,
"expiresAt": "2026-11-13T17:00:00.000Z"
},
"requestId": "6d0b3f8e-2a5c-4e9b-a7d1-8c4f1e6b0a27"
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
environment | "live" | "test" | live = live key · test = test key |
balance | number | Remaining balance — on a package this is the number of slips you can still verify (1 credit = 1 slip) |
currency | string | Unit (credit) |
plan | object | null | Current plan — null = no plan yet |
plan.remainingSlips | number | null | Remaining slips (null = unlimited) |
plan.expiresAt | string | null | Plan expiry date (ISO 8601) |
requestId | string | Request id, same as the X-Request-Id header — include it when contacting support and we can find the request instantly |
Notes and tips
- No request body needed — attach only the Authorization header
- Requires a key with the verify:slip permission and passes the same checks as slip verification (IP whitelist · rate limit)
- A wrong or revoked key returns HTTP 401 with reason: invalid_api_key (the legacy error: invalid api key field stays for one more release)
Checks and slips used today across every channel (API + LINE + web), counted in Thai time (no body needed — just the key header)
Scope: verify:slip
Request parameters
No request body needed — just attach the Authorization header: Authorization: Bearer s2t_live_xxxxxxxx
Sample request
// GET Request Header:
// Authorization: Bearer s2t_live_xxxxxxxxSample response
{
"success": true,
"environment": "live",
"today": {
"calls": 312,
"credits": 298
},
"breakdown": {
"denied": 4,
"errors": 1
},
"requestId": "0f6c9a2e-8d4b-4b1f-a3e7-5c2d9f6a1b84"
}Key response fields
| Field name | Type | Meaning |
|---|---|---|
environment | "live" | "test" | live = live key (your shop's real history) · test = test key (only the test key's simulated checks) |
today.calls | number | Slips the bank decided today (verified + not verified) from every channel (API + LINE bot + web) — requests rejected before reaching the bank (no plan / expired / used up, etc.) and checker outages aren't counted |
today.credits | number | Slips actually deducted today (verified and not your shop's duplicates) · always 0 for test keys |
breakdown.denied | number | Requests rejected before reaching the bank today (e.g. no_plan, plan_expired, quota_exceeded, insufficient_credit, feature_disabled) — no slips used |
breakdown.errors | number | Requests that hit a checker outage today (answered 503 temporarily_unavailable) — no slips used, safe to resend |
requestId | string | Request id, same as the X-Request-Id header — include it when contacting support and we can find the request instantly |
Notes and tips
- Statistics reset automatically every day at midnight Thai time (UTC+7)
- Requests that fail the key check (401, 403, 429, etc.) are never recorded as checks, so they aren't in any of these numbers — see the API request logs page
- Includes slips checked through the LINE bot and the web slip checker, not just the API · failed or rejected slips aren't charged credits
- Requires a key with the verify:slip permission — a wrong key returns HTTP 401 reason: invalid_api_key like the other endpoints
The reason field lets your system tell cases apart and notify users correctly
| reason field | HTTP | Meaning | What to do |
|---|---|---|---|
| invalid_request | 400 | Neither qrData nor image was sent (or both were), qrData isn't 10–1000 characters, expectedAmount is invalid, the JSON is malformed, or the GET /api/v1/verifications filters are invalid — param names the offending field when there is one | Send either { "qrData": "…" } or { "image": "<base64>" } · expectedAmount must be greater than 0 with at most 2 decimals |
| payment_qr | 400 | A payment QR (PromptPay / merchant QR) was sent instead of the slip QR | Ask the customer for the slip after paying, not the QR used to pay |
| qr_not_found | 422 | No QR code could be read from the image | Use the slip image from the banking app with a clear, uncropped QR |
| invalid_api_key | 401 | Missing or malformed Authorization header, wrong key or revoked key | Check the key / create a new one (the Bearer scheme is case-insensitive) · the legacy error: invalid api key field goes away next release |
| no_plan | 402 | The shop has no plan yet | Buy a plan / claim the free trial |
| plan_expired | 402 | The plan has expired | Buy a new plan |
| quota_exceeded | 402 | All slips in the plan have been used | Buy another plan (remaining slips carry over only if the current plan hasn't expired) |
| insufficient_credit | 402 | Not enough credit (credit-based shops) | Top up on the wallet page |
| ip_not_allowed | 403 | The server's IP isn't on the IP whitelist | Add the IP on the IP whitelist page |
| service_disabled | 403 | This key or shop isn't allowed to use this service (e.g. verify/name, which isn't open yet) | Use a key with the verify:slip permission |
| feature_disabled | 403 | Slip verification isn't enabled for this shop's account | Contact technical support |
| merchant_suspended | 403 | The shop has been suspended | Contact technical support |
| not_found | 404 | No such result for your shop (GET /api/v1/verifications/:id), a different key type than at verification time, or an unknown path | Check the verificationId and use the same key type as when verifying (test / live) · test results are kept for 7 days |
| payload_too_large | 413 | Body over 4 MB (keep base64 images under ~3 MB), or over 64 KB on other endpoints | Resize/compress the image before sending, or send qrData instead |
| unsupported_media_type | 415 | Unsupported Content-Type, or the image isn't JPEG/PNG (WebP, GIF, HEIC, AVIF, PDF) — multipart uploads get param: file | Use application/json, image/jpeg, image/png, or multipart/form-data with a JPEG/PNG |
| rate_limited | 429 | Over your shop's per-minute limit (120 requests/min by default, live and test keys counted separately), or a test key over 1,000 verifications per day | Wait Retry-After seconds (at most 60 for the per-minute limit · seconds until reset for the test-key daily cap), then retry — queue requests on your side |
| auth_locked | 429 | This IP (or its /24 range) sent more than 30 invalid API keys in 1 minute — invalid keys get this; a valid key in use keeps working · before this code existed, this case returned rate_limited — if your code backs off on rate_limited, back off on auth_locked too | Make sure every server uses the current key (an old key after a reset / a typo) — retrying with the same key won't help; wait Retry-After and use the correct key (if a valid key gets this while its IP range is flooding bad keys, wait Retry-After and retry) |
| too_many_failed | 429 | More than 300 bank-rejected slips for your shop today | Stop sending QR data that isn't from a real slip, then wait Retry-After (seconds until reset) |
| internal_error | 500 | Unexpected server error | Retry later (for slip checks, look the result up before resending) · if it keeps happening, send the requestId to support |
| temporarily_unavailable | 503 | The bank's verification system, or a lookup endpoint's data source, is temporarily down | Retry after Retry-After (5 seconds) — the same slip can be resent |
Have your code decide on success and reason only — message is written in Thai, its wording may change, and it should never be compared
Error response format
Every non-2xx response from /api/v1/* has the same shape (reason values are in the table above)
HTTP/1.1 400 Bad Request
Content-Type: application/json
X-Request-Id: 2c9f4e1a-7b3d-4a8e-9f61-5d0c2b7a8e14
{
"success": false,
"status": "Failed",
"code": "400",
"reason": "invalid_request",
"message": "expectedAmount ต้องเป็นตัวเลขมากกว่า 0 ทศนิยมไม่เกิน 2 ตำแหน่ง",
"param": "expectedAmount",
"requestId": "2c9f4e1a-7b3d-4a8e-9f61-5d0c2b7a8e14"
}| Field name | Type | Meaning |
|---|---|---|
success | false | Always false |
status | "Failed" | Always Failed |
code | string | The HTTP status as a string, e.g. 400 |
reason | string | Reason code from the table above — branch on this |
message | string | Human-readable Thai text; wording may change |
requestId | string | Same as the X-Request-Id header — include it when contacting support |
param | string? | Only on 400 validation errors: the name of the invalid field |
error | string? | Only on 401: the legacy value invalid api key — kept for one more release; switch to reason |
Rejected or failed requests use no quota
success: false and no reasonThe maximum number of requests per minute, and what to do when you exceed it
- 120 requests per minute per shop by default (configurable per shop — your current limit is shown on the Callback / Webhook page), shared across all of that shop's live keys (extra keys don't add capacity) · test keys get a separate budget of the same size, so testing never eats into your live limit
- Every response that passes the key check carries
X-RateLimit-Limit·X-RateLimit-Remaining·X-RateLimit-Reset— Limit = the per-minute cap · Remaining = requests left this minute · Reset = seconds until the window starts over - Every endpoint that uses an API key counts: verify/slip, verifications, ping, balance and usage
- On top of the per-minute limit, test keys can verify at most 1,000 slips per day per shop
- More than 300 bank-rejected slips per shop per day returns HTTP 429 reason: too_many_failed (protection against junk QR data)
- Going over returns HTTP 429 with
reason: rate_limitedand aRetry-Afterheader (seconds) — every 429 and 503 carries it, test keys included. Wait that long before retrying (no slips are used) - For large volumes, queue requests on your side and send them in batches — don't retry immediately on 429
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 37
Content-Type: application/json
{
"success": false,
"status": "Failed",
"code": "429",
"reason": "rate_limited",
"message": "ส่งคำขอถี่เกินกำหนด กรุณาลองใหม่ภายหลัง",
"requestId": "8a4d2f6c-1e9b-4c7a-b3d5-6f0e8a2c4b19"
}Results are sent to your callback URL automatically for the events you select on the Callback / Webhook page — no polling code needed.
Events sent
| Event | Sent when |
|---|---|
| verify.success | The bank confirmed the slip (duplicates included — see data.duplicate) |
| verify.failed | Only when the bank rejects the slip — not for denied requests (with a reason) or system errors |
| credit.low | Remaining slips/credit drop to your threshold (sent once when crossing it) |
| credit.empty | Slips/credit used up (sent once when it runs out) |
Includes slips checked through your shop's LINE bot
Request headers
POST /webhooks/verifyslips HTTP/1.1
Content-Type: application/json
User-Agent: Slip2Try-Webhook/1.0
X-VerifySlips-Event: verify.success
X-VerifySlips-Delivery: 3e5b7c1d-9a2f-4d6e-8b0c-1f4a7e2d9c63
X-VerifySlips-Signature: sha256=9f86d081884c7d65...
X-VerifySlips-Signature-Previous: sha256=7d1a54127b222502... # V1 with the old secret — only for 24h after a secret rotation
X-VerifySlips-Signature-V2: t=1789753097,sha256=4e07408562bedb8b...
X-VerifySlips-Signature-V2-Previous: t=1789753097,sha256=b5c1e9d07a3f2c84... # only for 24h after a secret rotation (signed with the old secret)
X-VerifySlips-Timestamp: 1789753097
X-VerifySlips-Test: true # only on requests from the "Send test webhook" button or from test keysX-VerifySlips-Timestamp is the send time (unix seconds). X-VerifySlips-Signature-V2 also signs the time (HMAC-SHA256 of ${timestamp}.${body}) — prefer it, and reject requests more than 5 minutes old to block replays
Sample body for each event
Body · verify.success
{
"id": "3e5b7c1d-9a2f-4d6e-8b0c-1f4a7e2d9c63",
"event": "verify.success",
"environment": "live",
"createdAt": "2026-10-14T04:20:11.512Z",
"channel": "api",
"requestId": "3b1f0c9e-6a2d-4f5e-8b7c-1d2e3f4a5b6c",
"transactionRef": "A3828f7b4f3244ff3",
"data": {
"verificationId": "6f1c2a9e-4b7d-4c1e-9a52-0d3f8e7b1c24",
"status": "success",
"message": null,
"amount": 100000,
"currency": "THB",
"duplicate": false,
"firstVerifiedAt": null,
"checks": {
"stale": false,
"slipAgeHours": 0.1,
"receiver": "matched",
"receiverLabel": "บัญชีหลัก",
"amount": "matched",
"expectedAmount": 100000
},
"transfer": { "...": "Same transfer details as data.transfer" }
}
}data.duplicate is true when your shop has already verified this slip successfully, and data.firstVerifiedAt is the time of the first verification (null when it isn't a duplicate)
id identifies the delivery (the same across retries and manual resends — dedupe on it) · environment is live or test · verify.* also include channel (api / line / web) and the requestId of that verify request (null unless channel is api)
data.unit is slip (package plans) or credit (credit-based shops) · transactionRef is always null for credit.*
Delivery and retries
- Your endpoint must answer with HTTP 2xx within 10 seconds for the delivery to count as successful
- A non-2xx answer, a timeout, a connection error or a redirect (3xx) counts as a failure — redirects aren't followed, so use the final URL
- The first attempt is sent right after the check (once the API response has gone out — a webhook never arrives before the API response) · failed deliveries are retried no sooner than 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours (6 attempts in total), then stop. Retries run when requests reach the system (from any shop) or on a once-a-day scheduled run (07:00 Thai time), so when traffic is quiet a retry can be up to about 24 hours late · don't rely on webhooks alone — reconcile with GET /api/v1/verifications or resend from the delivery history
- Test sends from the portal include
"test": truein the body and results from test keys include"environment": "test"— both carry the X-VerifySlips-Test: true header and aren't retried - The URL must start with https:// and be publicly reachable (localhost and private IPs are rejected)
Always verify the HMAC-SHA256 signature before trusting a request
Prefer the V2 tabs: check X-VerifySlips-Signature-V2 (HMAC of timestamp.body) and reject timestamps more than 5 minutes from now · the V1 tabs (X-VerifySlips-Signature, body only) still work for existing integrations but can't stop replays
verify_signature_v2.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const TOLERANCE_SEC = 300; // Accept signatures up to 5 minutes old
// Secret configured on the Callback / Webhook page
const SECRETS = [
process.env.VERIFYSLIPS_CALLBACK_SECRET || 'your_webhook_secret',
process.env.VERIFYSLIPS_CALLBACK_SECRET_PREVIOUS, // old secret while you switch over (optional) — remove it once done
].filter(Boolean);
// t=<unix>,sha256=<hex>
function validV2(header, rawBody) {
const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.trim().split('=')));
const ts = parts.t || '';
// Timestamp too old or in the future → reject (replay protection)
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC) return false;
const given = Buffer.from(String(parts.sha256 || ''));
return SECRETS.some((secret) => {
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex'));
// Constant-time comparison to prevent timing attacks
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
// Read the raw body so HMAC-SHA256 is computed correctly
app.post('/webhooks/verifyslips', express.raw({ type: 'application/json' }), (req, res) => {
// check V2 first, then V2-Previous (for 24h after a secret rotation)
if (!validV2(req.headers['x-verifyslips-signature-v2'], req.body) && !validV2(req.headers['x-verifyslips-signature-v2-previous'], req.body)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// id is the same on every retry → skip deliveries you have already processed
if (alreadyProcessed(payload.id)) return res.status(200).send('ok');
// Test results (environment: test) are not real money → acknowledge, do not deliver
if (payload.environment !== 'live') return res.status(200).send('ok');
if (payload.event === 'verify.success') {
const { transactionRef, data } = payload;
const checks = data.checks || {};
if (data.duplicate) {
// Slip already used (first: data.firstVerifiedAt) → do not credit again
} else if (checks.receiver === 'mismatch' || checks.amount === 'mismatch' || checks.stale) {
// Wrong account, wrong amount or old slip (same as warnings in the API response) → review
} else {
console.log(`Slip verified: ${transactionRef} Amount: ${data.amount}`);
// TODO: mark the order as paid (keep transactionRef unique)
}
}
// Respond with 200 as fast as possible
res.status(200).send('ok');
});
app.listen(3000);Secret rotation and pausing
- Rotate the secret on the Webhook page and a new secret is issued immediately — for the next 24 hours every webhook carries two signatures:
X-VerifySlips-Signature-V2with the new secret andX-VerifySlips-Signature-V2-Previouswith the old one (same t=) - On your side: check
X-VerifySlips-Signature-V2first and, if it doesn't match, checkX-VerifySlips-Signature-V2-Previous— finish updating the secret in your system within 24 hours; after thatX-VerifySlips-Signature-V2-Previousis no longer sent - Still verifying V1 signatures? During the same 24 hours
X-VerifySlips-Signature-Previous(sha256= of the body with the old secret) is sent alongsideX-VerifySlips-Signature— checkX-VerifySlips-Signaturefirst and, if it doesn't match, checkX-VerifySlips-Signature-Previous - You can pause delivery on the same page — while paused, new events aren't sent and aren't replayed when you resume (look up past results with GET /api/v1/verifications) · the test button and deliveries already waiting to retry still send
1. Use the raw body
Compute the hash from the raw body before JSON parsing, otherwise it won't match
2. Compare in constant time
Use hash_equals / crypto.timingSafeEqual / hmac.compare_digest — never == or ===
3. Don't process twice
Webhooks can arrive more than once (retries) — use the body
id(same as theX-VerifySlips-Deliveryheader, stable across retries) as the idempotency key4. Respond with 200 quickly
Send heavy work like stock deduction or emails to a background queue
Use these to turn fromBankCode / toBankCode in verification results into bank names and logos (3-digit Bank of Thailand codes)

Bangkok Bank
BBL
002
Kasikorn Bank
KBANK
004
Krungthai Bank
KTB
006
TMBThanachart Bank
TTB
011
The Siam Commercial Bank
SCB
014
Citibank
CITI
017
CIMB Thai Bank
CIMB
022
United Overseas Bank
UOB
024
Krungsri Bank
BAY
025
Government Savings Bank
GSB
030
HSBC
HSBC
031
GH Bank
GHB
033
Bank for Agriculture and Agricultural Cooperatives
BAAC
034
Islamic Bank of Thailand
IBANK
066
Tisco Bank
TISCO
067
Kiatnakin Phatra Bank
KKP
069
ICBC (Thai)
ICBC
070
Thai Credit Bank
TCRB
071
LH Bank
LHB
073
CLICX Bank
CLICX
088
PromptPay and wallets (no bank code)
The API returns no code for PromptPay or e-wallets — when the destination is one of these, toBankCode may be empty. Use the bank/service name returned instead (our web pages and LINE bot match the logo by name).
- Matched by name

PromptPay
- Matched by name

TrueMoney
Services enabled for your shop, and who to contact when you can't fix something yourself
Your merchant's permissions
Services your shop's API keys can use
- Slip verification
verify:slipActive - Account name verification
verify:nameDisabled
Contact technical support
Try calling GET /api/v1/ping and check the history / webhook delivery history first — if you still can't fix it, message us
Telegram Technical support
@blacksteps
Discord Technical support
discord.gg/Utr2KgzjqE
Developer tools in the portal
Debug on your own before contacting us — every response has a requestId you can search in the API request logs