Skip to content

JavaScript and TypeScript SDK ​

@easyslip/sdk lets your Node.js, Bun or Deno server verify Thai bank transfer slips and TrueMoney Wallet receipts with the bank, confirm that a slip paid your account the right amount and was not used before, and receive the results of background checks through signed webhooks. The package has no dependencies, ships full TypeScript types, and works as an ES module or CommonJS.

This page is the full guide. The README on GitHub has a shorter summary, and the source code is at EASYSLIP-CO-LTD/sdk-js.

Requirements ​

  • Node.js 22, 24 or 26. Bun 1.4, Deno 2.9 and edge runtimes with fetch, such as Cloudflare Workers, work too.
  • An EasySlip API key. Sign in to the EasySlip developer portal and copy the API key of your application. Authentication explains keys and branches.

Installation ​

sh
npm install @easyslip/sdk
sh
pnpm add @easyslip/sdk
sh
yarn add @easyslip/sdk
sh
bun add @easyslip/sdk

On Deno, import from npm:@easyslip/sdk. In CommonJS code, use const { EasySlip } = require('@easyslip/sdk').

The examples on this page are TypeScript. To run one as plain JavaScript, remove the type annotations and save it as an .mjs file.

Quick start ​

This program checks the slip a customer sent for an order of 1,500 baht. It confirms that the slip paid one of your registered bank accounts, that the amount is right and that the slip was not used before, then prints the payment.

Save it as verify.mjs and put the slip image next to it as slip.jpg:

ts
import { EasySlip, SlipNotFoundError } from '@easyslip/sdk'

const easyslip = new EasySlip() // reads your key from EASYSLIP_API_KEY
const orderTotal = 1500 // baht

try {
    const result = await easyslip.verify.bankByImage('./slip.jpg', {
        matchAccount: true, // did the slip pay one of your bank accounts?
        matchAmount: orderTotal, // is the amount right?
        checkDuplicate: true, // was this slip used before?
    })
    const slip = result.rawSlip

    if (result.isDuplicate) {
        console.log('This slip was already used. Do not credit the order again.')
    } else if (!result.isAmountMatched) {
        console.log(`The slip pays ${result.amountInSlip} baht, but the order is ${orderTotal} baht.`)
    } else if (result.matchedAccount === null) {
        console.log('The slip does not pay any of your bank accounts.')
    } else {
        console.log('Payment confirmed')
        console.log(`Amount:    ${slip.amount.amount} THB`)
        console.log(`Date:      ${slip.date}`)
        console.log(`From:      ${slip.sender.account.name.th} (${slip.sender.bank?.short})`)
        console.log(`To:        ${slip.receiver.account.name.th} (${slip.receiver.bank?.short})`)
        console.log(`Reference: ${slip.transRef}`)
    }
} catch (error) {
    if (error instanceof SlipNotFoundError) {
        console.log('The bank has no record of this transfer. The slip may be fake or edited.')
    } else {
        throw error
    }
}

Run it with your API key:

sh
EASYSLIP_API_KEY="your-api-key" node verify.mjs

For a valid slip it prints:

text
Payment confirmed
Amount:    1500 THB
Date:      2024-01-15T14:30:00+07:00
From:      นาย ผู้โอน ทดสอบ (KBANK)
To:        บริษัท ตัวอย่าง จำกัด (SCB)
Reference: 68370160657749I376388B35

matchAccount compares the receiver with the bank accounts you registered, so add your receiving accounts first, in the developer portal or with bankAccounts.create. Each verification uses one unit of your quota.

Take the order total from your own database, never from the customer. Credit the order only when all three checks pass, and store rawSlip.transRef with a unique constraint in the same database transaction that marks the order paid, so that two requests with the same slip cannot both credit it.

Next steps:

Create a client ​

Create one client when your application starts and reuse it for every request. Pass options to new EasySlip({ ... }). An option you leave out comes from its environment variable, or else from its default.

OptionEnvironment variableDefaultWhat it does
apiKeyEASYSLIP_API_KEYNoneYour API key. Without a key, new EasySlip() throws InputError.
No optionEASYSLIP_API_KEY_FILENoneThe path of a file that holds your API key, such as a Docker or Kubernetes secret. The SDK uses it when there is no apiKey and no EASYSLIP_API_KEY.
timeoutNo variable30000How long one attempt may take, in milliseconds.
maxRetriesNo variable2How many times the SDK retries a failed request. 0 turns retries off.
fetchNo variableThe global fetchThe function the SDK sends requests with.
v2BaseUrlEASYSLIP_V2_BASE_URLhttps://api.easyslip.com/v2The address of API v2.
v1BaseUrlEASYSLIP_V1_BASE_URLhttps://api.easyslip.com/v1The address of API v1.
No optionEASYSLIP_WEBHOOK_SECRETNoneYour webhook secret. The webhook functions use it when you do not pass a secret.
ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip({
    timeout: 15_000, // 15 seconds per attempt
    maxRetries: 1,
})
console.log((await easyslip.getInfo()).application.name)

EasySlip and Client are the same class, so new Client() works too.

Options for one call ​

Every method also takes timeout, maxRetries and signal for that call alone. Verification methods take them together with the verification options:

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const result = await easyslip.verify.bankByImage('./slip.jpg', {
    checkDuplicate: true,
    timeout: 10_000, // this call may take 10 seconds per attempt
    maxRetries: 0, // and is not retried
})
console.log(result.rawSlip.transRef)

Custom HTTP client ​

Pass your own fetch to log requests, send them through a proxy or record them in tests. It receives the URL and the request options, and it returns a Response.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip({
    fetch: async (url, init) => {
        const started = Date.now()
        const response = await fetch(url, init)
        console.log(init.method, url, response.status, `${Date.now() - started} ms`)
        return response
    },
})
console.log((await easyslip.getInfo()).application.name)

The client opens no connections of its own, so you do not need to close it. If your fetch uses its own connection pool, such as an undici Agent, close that pool when your application shuts down.

Verify a bank slip ​

Pick the method that matches what your customer gives you. All four call POST /verify/bank, return the same result and take the same options.

What you haveMethodAPI reference
An image file, an upload or a streamverify.bankByImage(image)Image
The text in the slip's QR code, read by a QR scannerverify.bankByPayload(payload)Payload
A public https URL of the slip imageverify.bankByUrl(url)URL
The image as a base64 string, with or without a data: prefixverify.bankByBase64(base64)Base64
ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const result = await easyslip.verify.bankByUrl('https://example.com/slips/1001.jpg')
console.log(result.rawSlip.transRef, result.amountInSlip)

The payload is the text encoded in the slip's QR code, not the transaction reference printed on the slip. If your app scans the QR code itself, bankByPayload is the fastest method because nothing is uploaded.

Image inputs ​

bankByImage and truewalletByImage accept:

  • a file path, as a string
  • a Uint8Array or a Node.js Buffer
  • a Blob or File, for example from request.formData()
  • a web ReadableStream or a Node.js readable stream

The image must be a JPEG, PNG, GIF or WebP file of at most 4 MB. The SDK checks the bytes before it uploads anything and throws InputError for an empty file, a file over 4 MB or a file that is not an image.

A string is a file path

A string passed to bankByImage is always read as a path on your server. Pass a customer's upload as a File or as bytes, never as a string the customer controls.

This handler takes a slip uploaded in a form. It uses the standard Request and Response classes, so it runs on Bun, Deno, Cloudflare Workers and Node.js frameworks that use them:

ts
import { EasySlip, InvalidImageError } from '@easyslip/sdk'

const easyslip = new EasySlip()

export async function handleSlipUpload(request: Request): Promise<Response> {
    const form = await request.formData()
    const upload = form.get('slip')
    if (!(upload instanceof File)) return new Response('Attach the slip image as "slip".', { status: 400 })

    try {
        const result = await easyslip.verify.bankByImage(upload, { checkDuplicate: true })
        return Response.json({ transRef: result.rawSlip.transRef, amount: result.amountInSlip })
    } catch (error) {
        if (error instanceof InvalidImageError) return new Response('Upload a clear slip image.', { status: 422 })
        throw error
    }
}

Check amount, receiving account and duplicates ​

Turn on the checks you need with these options. Each one adds fields to the result, and an option you leave out adds nothing. When a check fails, the API still returns the slip, and your code decides what to do.

OptionWhat it checksResult fieldsWhen the check fails
matchAmount: 1500The slip amount equals the amount you expect, in baht.amountInOrder, isAmountMatchedisAmountMatched is false.
matchAccount: trueThe receiver is one of your registered bank accounts.matchedAccountmatchedAccount is null.
checkDuplicate: trueThe slip was verified before.isDuplicateisDuplicate is true.
remark: 'Order 1001'Nothing. It saves your note of 1 to 255 characters with the verification.remarkIt never fails.
ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const result = await easyslip.verify.bankByImage('./slip.jpg', {
    matchAmount: 1500,
    matchAccount: true,
    checkDuplicate: true,
    remark: 'Order 1001',
})
const accepted =
    result.isAmountMatched === true && result.matchedAccount !== null && result.isDuplicate === false
console.log(accepted ? 'accept' : 'reject', result.remark)

If the same branch verified the slip before, the API returns the saved result and uses no quota. If another branch verified it, the API checks it with the bank again and uses quota. isDuplicate is true in both cases.

matchAccount only compares against accounts linked to the branch of your API key. If a slip you expect to pass comes back with matchedAccount: null, check that the receiving account is linked to that branch.

TrueMoney Wallet receipts ​

TrueMoney Wallet receipts work the same way and take the same options. They call POST /verify/truewallet, and their result has a shorter slip.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const result = await easyslip.verify.truewalletByImage('./wallet-receipt.png', {
    matchAmount: 500,
    checkDuplicate: true,
})
console.log(result.rawSlip.transactionId, result.rawSlip.amount, result.rawSlip.receiver.name)

verify.truewalletByUrl takes an image URL and verify.truewalletByBase64 takes base64. TrueMoney Wallet receipts have no payload method.

Account info and remaining quota ​

getInfo calls GET /info and returns your application, branch and quota. quota.remaining is null when your plan has no limit.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const info = await easyslip.getInfo()
const { used, remaining } = info.application.quota
console.log(`${info.application.name}, branch ${info.branch.name}`)
console.log(`Used ${used}, remaining ${remaining ?? 'unlimited'}`)

Receiving bank accounts ​

These are the accounts that matchAccount compares the receiver with. An account belongs to your service, and a branch only matches against the accounts linked to it. bankAccounts.create adds an account and links it to the branch of your API key. The Bank Accounts reference describes each endpoint.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

const account = await easyslip.bankAccounts.create({
    bankCode: '004', // bank codes come from easyslip.banks.list()
    bankNumber: '1234567890',
    nameTh: 'บริษัท ตัวอย่าง จำกัด',
    nameEn: 'EXAMPLE CO., LTD.',
    type: 'JURISTIC',
})
await easyslip.bankAccounts.update(account.id, { nameEn: 'EXAMPLE COMPANY LIMITED' })

// One page of the accounts linked to this branch
const page = await easyslip.bankAccounts.list({ page: 1, limit: 20 })
console.log(`${page.total} accounts linked to this branch`)

// Every account in the service. The SDK fetches the pages for you.
for await (const item of easyslip.bankAccounts.iterateAll()) {
    console.log(item.id, item.bankNumber, item.nameEn)
}
MethodWhat it does
bankAccounts.create(params)Adds an account to the service and links it to this branch.
bankAccounts.get(id)Returns one account.
bankAccounts.update(id, params)Changes only the fields you pass. extraVerify: null clears that field.
bankAccounts.link(id)Links an account of the service to this branch. Linking twice is fine.
bankAccounts.unlink(id)Removes the link to this branch. The account stays in the service.
bankAccounts.list(params)Returns one page of the accounts linked to this branch. page starts at 1, and limit is 1 to 100 (default 20).
bankAccounts.listAll(params)Returns one page of every account in the service, with the same paging.
bankAccounts.iterate(params)Goes through every account linked to this branch, 100 per page.
bankAccounts.iterateAll(params)Goes through every account in the service, 100 per page.

type is NATURAL for a personal account or JURISTIC for a company account, and the AccountType type accepts only those two values. The four list methods can filter by type and by bankCode. Adding an account that already exists throws ConflictError.

Bank codes ​

banks.list calls GET /banks and returns every bank the API knows, with the code you pass as bankCode. Bank Codes has the same list.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

for (const bank of await easyslip.banks.list()) {
    console.log(bank.code, bank.shortCode, bank.nameEn)
}

Verify many slips in the background ​

A background job checks a slip on the EasySlip side and posts the result to your webhook. Use jobs when you have many slips or do not want to wait for the bank. Pass an https callbackUrl, unless your branch already has a default webhook URL. Only bank slips can be checked in the background. Async Verify describes the endpoints.

This example reads the QR code text of several slips from payloads.txt, one per line, and submits them as one batch:

ts
import { readFile } from 'node:fs/promises'
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()
const payloads = (await readFile('payloads.txt', 'utf8')).split('\n').filter(Boolean)

const batch = await easyslip.jobs.submitBankBatch(
    payloads.map((payload) => ({ payload, checkDuplicate: true })),
    { callbackUrl: 'https://example.com/webhooks/easyslip' },
)

// Your webhook receives every result. You can also wait for a job here:
for (const { jobId } of batch.jobs) {
    const job = await easyslip.jobs.waitForJob(jobId)
    console.log(jobId, job.status, job.result?.status, job.result?.data?.amountInSlip)
}
MethodWhat it does
jobs.submitBank(payload, options)Submits one slip, with the same options as verify plus callbackUrl.
jobs.submitBankBatch(slips, { callbackUrl })Submits 1 to 100 slips in one request. Each slip has its own options.
jobs.submitBankMany(slips, { callbackUrl })Submits any number of slips in batches of 100, one batch after another.
jobs.get(jobId)Returns the job, with its result once it has finished.
jobs.waitForJob(jobId, options)Checks the job every interval milliseconds (default 2000) until it is done or failed, for at most timeout milliseconds (default 600000, which is 10 minutes).

Each slip in a job uses quota when it is verified, the same as a direct verification. Checking a job with jobs.get or waitForJob uses no quota and never submits the slip again. The API keeps a job for about 7 days, so you can still read the result with jobs.get if your webhook missed it.

A job goes through queued and retrying to done or failed. done means the bank answered: result.status is success with the verification in result.data, or not_found when the bank still had no matching transfer after the retries. failed means the check could not finish, and error.code says why, for example QUOTA_EXCEEDED.

If submitBankMany stops part way, it throws BatchSubmitError. The batches in error.submitted were accepted and keep running, so submit only the slips from error.failedChunk onward again.

Generate a payment QR code ​

v1.generateQr creates PromptPay and bill payment QR codes that your customers scan to pay you. Creating a QR code uses no quota. amount is in baht, from 0.01 to 9,999,999,999.99 with at most two decimals. Leave it out to let the payer type the amount.

ts
import { writeFile } from 'node:fs/promises'
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

// PromptPay to a phone number, for 250 baht
const promptPay = await easyslip.v1.generateQr({ type: 'PROMPTPAY', msisdn: '0812345678', amount: 250 })
await writeFile('promptpay.png', promptPay.imageBytes())

// Bill payment to your biller ID, with your invoice number as reference 1
const bill = await easyslip.v1.generateQr({
    type: 'PROMPTPAY',
    billerId: '010555512345601',
    ref1: 'INV1001',
    amount: 1500,
})
await writeFile('bill.png', bill.imageBytes())
typeRequired fieldsOptional fields
PROMPTPAYOne of msisdn (phone number), natId (national ID or tax ID), eWalletId or billerId; with billerId, ref1 is also requiredamount, and ref2 with billerId
KSHOPref1, the shop ID: KB followed by 12 digitsamount
MAE_MANEEref1, the 15-digit shop IDamount
TUNGNGERNref1, the merchant code, and merchantName, the shop name in Englishamount

The older BILL_PAYMENT type still works. With the same billerId it creates the same QR code as PROMPTPAY. The QrType type lists the five types the API accepts. The result also has payload, the text in the QR code, if you want to draw the code yourself.

Legacy API v1 ​

Use easyslip.v1 only if your integration already uses API v1. New code should use the v2 methods above. The v1 methods return the slip itself, and with checkDuplicate: true a slip that was verified before throws DuplicateSlipError.

ts
import { DuplicateSlipError, EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()

try {
    const slip = await easyslip.v1.verifyBankByImage('./slip.jpg', { checkDuplicate: true })
    console.log(slip.transRef, slip.amount.amount)
} catch (error) {
    if (!(error instanceof DuplicateSlipError)) throw error
    console.log('This slip was already used. Transfer date:', error.slip?.date)
}

API v1 also has verifyBankByPayload, verifyBankByUrl, verifyBankByBase64, verifyTruemoneyByImage and getMe. Version Comparison lists what changed in v2.

Promises, timeouts and cancelling ​

Every method returns a promise, so you can check several slips at once. Pass signal to cancel a call or to give it a deadline. Keep the number of calls in flight small, because the API limits how many requests you can send.

ts
import { EasySlip } from '@easyslip/sdk'

const easyslip = new EasySlip()
const files = ['./slip-1001.jpg', './slip-1002.jpg', './slip-1003.jpg']

const outcomes = await Promise.allSettled(
    files.map((file) =>
        easyslip.verify.bankByImage(file, { checkDuplicate: true, signal: AbortSignal.timeout(60_000) }),
    ),
)
outcomes.forEach((outcome, index) => {
    if (outcome.status === 'fulfilled') console.log(files[index], outcome.value.rawSlip.transRef)
    else console.log(files[index], 'failed:', outcome.reason)
})

When the signal aborts, the call rejects at once with the signal's reason, and the SDK does not retry it. For more than a handful of slips, submit them as background jobs instead.

The result object ​

A v2 verification returns your check results at the top level and the slip as the bank reports it in rawSlip. The API reference describes the full model.

Bank slip ​

FieldTypeMeaningExample
amountInSlipnumberThe amount transferred, in baht.1500
isAmountMatchedbooleanWhether the amount equals matchAmount. Only with matchAmount.true
amountInOrdernumberThe matchAmount you sent. Only with matchAmount.1500
matchedAccountobject or nullYour registered account that the slip paid, or null. Only with matchAccount.{ bankNumber: '123-4-56789-0', ... }
isDuplicatebooleanWhether the slip was verified before. Only with checkDuplicate.false
remarkstringYour remark. Only when you sent one.'Order 1001'
rawSlip.transRefstringThe bank's reference for the transfer. Each transfer has its own, so you can store it to catch reuse.'68370160657749I376388B35'
rawSlip.datestringWhen the transfer happened, in ISO 8601 with the Thai time offset.'2024-01-15T14:30:00+07:00'
rawSlip.amount.amountnumberThe amount, in baht.1500
rawSlip.feenumberThe transfer fee, when the bank reports one.0
rawSlip.sender.bankobjectThe sender's bank: id (the bank code), name and short.{ id: '004', name: 'กสิกรไทย', short: 'KBANK' }
rawSlip.sender.account.nameobjectThe sender's name in Thai (th) and English (en), when the bank shows it.{ th: 'นาย ผู้โอน ทดสอบ', en: 'MR. SENDER TEST' }
rawSlip.sender.account.bankobjectThe sender's account number, partly hidden by the bank.{ type: 'BANKAC', account: '123-4-xxxxx-5' }
rawSlip.receiver.bankobjectThe receiver's bank, in the same form as the sender's.{ id: '014', name: 'ไทยพาณิชย์', short: 'SCB' }
rawSlip.receiver.account.nameobjectThe receiver's name.{ th: 'บริษัท ตัวอย่าง จำกัด' }
rawSlip.receiver.account.bankobjectThe receiver's account number, partly hidden.{ type: 'BANKAC', account: 'xxx-x-x5678-x' }
rawSlip.receiver.account.proxyobjectThe PromptPay ID the money was sent to, when the transfer used one.{ type: 'MSISDN', account: '08xxxxxxxx89' }
rawSlip.ref1, rawSlip.ref2, rawSlip.ref3stringBill payment references. They are empty for ordinary transfers.''

TrueMoney Wallet receipt ​

The top-level fields are the same as for a bank slip. rawSlip holds:

FieldTypeMeaningExample
rawSlip.transactionIdstringThe TrueMoney transaction ID.'50012345678901'
rawSlip.datestringWhen the payment happened, in ISO 8601.'2026-10-26T14:30:00+07:00'
rawSlip.amountnumberThe amount, in baht.500
rawSlip.sender.namestringThe payer's name.'นาย ผู้โอน ทดสอบ'
rawSlip.receiver.namestringThe receiver's name.'นาย รับเงิน ทดสอบ'
rawSlip.receiver.phonestringThe receiver's phone number, partly hidden.'08x-xxx-4567'

Jobs and webhook events ​

jobs.get and waitForJob return a job, and your webhook receives an event:

FieldJobWebhook eventMeaning
jobIdYesYesThe job ID that submitBank or submitBankBatch returned.
batchIdYesYesThe batch ID, when the slip was part of a batch.
statusqueued, processing, retrying, done or failedsuccess, not_found or failedWhere the job is, or how it ended.
result.statusYesNosuccess or not_found, once the job is done.
result.data, dataYesYesThe same verification result as a direct call, when the slip was verified.
error.code, error.messageYesYesWhy the check could not finish, when the status is failed.
timestampNoYesWhen the result was produced.

Response metadata ​

Every result also has a meta property with the HTTP status, the requestId, the rate limit counters and the response headers. It is not part of the data, so JSON.stringify(result) leaves it out. Include meta.requestId when you contact support about a call.

Error handling ​

Every error the SDK throws is an EasySlipError. Errors that come from the API are ApiErrors, and they carry status, code (such as SLIP_NOT_FOUND), message, requestId and retryAfter. Some classes are subclasses of others: QuotaExceededError is a PermissionDeniedError; InvalidImageError, InvalidUrlError and DuplicateSlipError are InvalidRequestErrors; SlipNotFoundError and SlipPendingError are NotFoundErrors; ServiceUnavailableError is a ServerError; and RequestTimeoutError is a NetworkError. Error Codes lists every code the API returns.

An error code the SDK knows decides the class first. Otherwise the HTTP status in the table decides it, and any other 4xx status gives ApiError.

Error classHTTP statusWhen it happensWhat to do
AuthenticationError401The API key is missing, wrong or revoked.Check the key you pass or EASYSLIP_API_KEY. Create a new key in the developer portal if you need one.
PermissionDeniedError403The key is valid but may not make this request, for example because the branch is turned off, the plan has expired or your server's IP address is not on the allowed list.Read error.code, then fix that setting in the developer portal.
QuotaExceededError403Your quota is used up. This request was not charged.Top up or change your plan, then send the request again.
InvalidRequestError400The API rejected a value in the request, such as the QR code text or an option.Fix the value that error.message names. Sending the same request again fails the same way.
InvalidImageError400The image has no readable slip QR code, is not a supported type or is too large.Ask the customer for a clear JPEG, PNG, GIF or WebP image under 4 MB.
InvalidUrlError400The image URL or callbackUrl points to a private address, does not use http or https, or cannot be reached.Use a public https URL.
DuplicateSlipError400Only in API v1 with checkDuplicate: true: the slip was verified before. error.slip holds the first result.Treat the payment as already received and do not credit it again.
NotFoundError404The bank account, job or other item you asked for does not exist.Check the ID you passed.
SlipNotFoundError404The bank has no transfer that matches this slip.Do not accept the payment. Ask the customer for the original slip.
SlipPendingError404The bank has not published the transfer yet. This happens with very recent Bangkok Bank slips.Verify the slip again after a minute.
ConflictError409The bank account you are adding already exists.Use the existing account. bankAccounts.listAll() finds it.
RateLimitError429You sent too many requests. error.retryAfter says how many seconds to wait.Send fewer requests at the same time. The SDK has already retried this one.
ServerError500, 502, 504; other 5xx except 503Something failed on the EasySlip side.Try again later. If it keeps happening, contact support with error.requestId.
ServiceUnavailableError503The service is briefly unavailable, for example while quotas renew.Try again in a few minutes.
ApiErrorAnother 4xx statusThe API answered with an error status that has no class of its own.Log status, code and requestId, and contact support if it continues.
NetworkErrorNetworkNo response arrived, because of a DNS, connection or TLS failure.Check your network or proxy, then try again.
RequestTimeoutErrorNetworkNo response arrived within timeout.Try again, or raise timeout. A verification may already have been counted.
InputErrorBefore sendingThe SDK found a problem before sending, such as a missing API key, empty QR code text, an image over 4 MB or a file that is not an image. error.code names the problem.Fix the value you passed.
WebhookSignatureErrorBefore sendingA webhook has no signature, or its signature does not match.Reject the webhook with status 400.
JobWaitTimeoutErrorBefore sendingwaitForJob reached its timeout. error.lastJob is the last state it saw.Wait longer, or rely on the webhook.
BatchSubmitErrorBefore sendingsubmitBankMany stopped part way. error.submitted lists the batches that were sent, and error.failedChunk is the batch that failed.Submit the failed batch and the ones after it again. Batches that were already sent keep running.

This example checks an uploaded slip at checkout and turns each failure into a message for the customer:

ts
import { readFile } from 'node:fs/promises'
import {
    ApiError,
    EasySlip,
    InvalidImageError,
    QuotaExceededError,
    RateLimitError,
    SlipNotFoundError,
    SlipPendingError,
} from '@easyslip/sdk'

const easyslip = new EasySlip()

async function checkPayment(upload: File, orderTotal: number): Promise<string> {
    try {
        const result = await easyslip.verify.bankByImage(upload, {
            matchAmount: orderTotal,
            matchAccount: true,
            checkDuplicate: true,
        })
        if (result.isDuplicate) return 'This slip has already been used.'
        if (!result.isAmountMatched) return `This slip is for ${result.amountInSlip} baht.`
        if (result.matchedAccount === null) return 'This slip was not paid to our account.'
        return 'Payment received.'
    } catch (error) {
        if (error instanceof SlipNotFoundError) return 'We could not find this transfer at the bank.'
        if (error instanceof SlipPendingError) return 'The bank is still processing this transfer. Try again in a minute.'
        if (error instanceof InvalidImageError) return 'Please upload a clear photo or screenshot of the slip.'
        if (error instanceof QuotaExceededError || error instanceof RateLimitError) {
            console.error('EasySlip limit reached', error.code, error.requestId)
            return 'We cannot check slips right now. Please try again shortly.'
        }
        if (error instanceof ApiError) {
            console.error('EasySlip error', error.status, error.code, error.requestId)
            return 'We could not check the slip. Please try again.'
        }
        throw error
    }
}

const upload = new File([await readFile('./slip.jpg')], 'slip.jpg', { type: 'image/jpeg' })
console.log(await checkPayment(upload, 1500))

What the SDK retries ​

The SDK retries a failed request up to maxRetries times (2 by default). It waits about half a second before the first retry and doubles the wait each time, up to 8 seconds. When the API sends Retry-After, the SDK waits that long instead, but if the wait is longer than 60 seconds it throws the error at once.

RequestRetried whenNever retried when
Reading data and linking accounts: getInfo, banks.list, bankAccounts.list, listAll, get and link, jobs.get, v1.getMe, v1.generateQrThe network fails, the call times out, or the API answers 429, 500, 502, 503 or 504.The API answers 400, 401, 403, 404 or 409.
Verifications and job submissions, which use quotaThe API answers 429, or the connection failed before the request left your server.The request was sent and then timed out or got a 5xx answer.
Changing bank accounts: create, update and unlinkThe API answers 429, or the connection failed before the request left your server.The request was sent and then timed out or got a 5xx answer.

A verification that reached the API may have been counted even if no answer came back. Retrying it could charge you twice, so the SDK leaves that decision to you. If you do send it again, use checkDuplicate: true: when the first attempt was counted, the second one returns the saved result with isDuplicate: true and uses no quota.

Webhooks ​

When a background job finishes, EasySlip sends a POST request to your callbackUrl. If your branch has a webhook secret, the request has an X-EasySlip-Signature header. Check the signature against the raw body before you parse or trust anything in it. Webhook Callback describes the request in full.

ts
import { createServer } from 'node:http'
import {
    constructWebhookEvent,
    InputError,
    WEBHOOK_SIGNATURE_HEADER,
    WebhookSignatureError,
} from '@easyslip/sdk'

if (!process.env.EASYSLIP_WEBHOOK_SECRET) throw new Error('Set EASYSLIP_WEBHOOK_SECRET')

createServer(async (request, response) => {
    try {
        const chunks: Buffer[] = []
        for await (const chunk of request) chunks.push(chunk as Buffer)
        const rawBody = Buffer.concat(chunks)
        const signature = request.headers[WEBHOOK_SIGNATURE_HEADER.toLowerCase()]

        // Uses the secret in EASYSLIP_WEBHOOK_SECRET
        const event = constructWebhookEvent(rawBody, signature)
        if (event.status === 'success') {
            console.log('Verified', event.jobId, event.data?.rawSlip.transRef, event.data?.amountInSlip)
        } else {
            console.log('Not verified', event.jobId, event.status, event.error?.code)
        }
        response.writeHead(200).end('ok')
    } catch (error) {
        if (error instanceof WebhookSignatureError || error instanceof InputError) {
            // The signature is missing or wrong, or the signed body is not a webhook event
            response.writeHead(400).end('invalid webhook')
        } else {
            console.error('Webhook failed', error)
            response.writeHead(500).end('error')
        }
    }
}).listen(3000)
ts
import express from 'express'
import {
    constructWebhookEvent,
    InputError,
    WEBHOOK_SIGNATURE_HEADER,
    WebhookSignatureError,
} from '@easyslip/sdk'

if (!process.env.EASYSLIP_WEBHOOK_SECRET) throw new Error('Set EASYSLIP_WEBHOOK_SECRET')

const app = express()

// express.raw keeps the body as the original bytes. Do not use express.json() on this route.
app.post('/webhooks/easyslip', express.raw({ type: 'application/json' }), (req, res) => {
    try {
        const event = constructWebhookEvent(req.body as Buffer, req.get(WEBHOOK_SIGNATURE_HEADER))
        if (event.status === 'success') {
            console.log('Verified', event.jobId, event.data?.rawSlip.transRef, event.data?.amountInSlip)
        } else {
            console.log('Not verified', event.jobId, event.status, event.error?.code)
        }
        res.sendStatus(200)
    } catch (error) {
        if (error instanceof WebhookSignatureError || error instanceof InputError) {
            // The signature is missing or wrong, or the signed body is not a webhook event
            res.status(400).send('invalid webhook')
        } else {
            console.error('Webhook failed', error)
            res.status(500).send('error')
        }
    }
})

app.listen(3000)

Both servers answer 400 when the signature is missing or wrong and when a signed body is not a webhook event, and 500 when anything else fails. Parsing the JSON and turning it back into text changes the bytes, and the signature then no longer matches. Pass the body exactly as it arrived.

FunctionWhat it does
constructWebhookEvent(rawBody, header, secret?)Checks the signature, then returns the parsed event. It throws WebhookSignatureError when the signature is missing or wrong, and InputError when the body is not a JSON object with jobId, status and timestamp.
verifyWebhookSignature(rawBody, header, secret?)Returns true when the signature matches and false when it does not.
parseWebhookEvent(rawBody)Parses the event without checking the signature, and throws InputError for a body that is not an event. Use it only after you have checked the signature yourself.

secret defaults to EASYSLIP_WEBHOOK_SECRET. Set the secret for your branch in the developer portal. Without one, callbacks arrive unsigned and constructWebhookEvent rejects them, so configure it before you go live. If you pass no secret and EASYSLIP_WEBHOOK_SECRET is not set, the webhook functions throw InputError.

Delivery rules:

  • Answer with a 2xx status within 5 seconds. Save the event and do slow work afterwards.
  • A non-2xx answer or a timeout makes EasySlip send the event again, after about 10, 30 and 120 seconds.
  • EasySlip may send the same event more than once, so use jobId to skip events you have already handled.
  • If every delivery fails, jobs.get(jobId) still returns the result for about 7 days.

Security and privacy ​

  • Keep your API key on your server. Never put it in a web page, a mobile app or a public repository, because anyone who has the key can use your quota.
  • Load the key from EASYSLIP_API_KEY or a secret file instead of writing it in your code.
  • The SDK sends the key only in the Authorization header and never puts it in error messages, so you can log its errors.
  • Slips show names, bank accounts and amounts, which are personal data. Store only what you need, protect it like your other customer data and follow the Thai Personal Data Protection Act (PDPA).
  • Check the signature of every webhook before you act on it.

Versioning and support policy ​

  • Releases follow semantic versioning. Minor and patch releases do not break existing code. A breaking change comes in a new major version with upgrade notes in the changelog.
  • The SDK supports the Node.js versions that the Node.js project still maintains. After a Node.js version reaches its end of life, a later release may stop supporting it, and the changelog says so.
  • The changelog and the releases page list every release and its changes.

See also ​

  • The examples folder has one runnable program for every method.
  • API v2 overview and Error Codes describe every endpoint, field and error code.
  • Questions about your account, quota or billing: contact EasySlip on LINE @ai-easy, by phone at 02-114-8806 or through the contact page, and include the requestId of the call.
  • Bugs and feature requests: open an issue. Never post your API key or a real slip. Report security problems privately as SECURITY.md describes.

Bank Slip Verification API for Thai Banking