Skip to content

Python SDK ​

easyslip-sdk lets your Python application 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. It has a synchronous Client and an AsyncClient with the same methods, typed result models that mypy checks in strict mode, and one dependency, HTTPX.

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

Requirements ​

Installation ​

sh
python -m pip install easyslip-sdk

With uv, run uv add easyslip-sdk. With Poetry, run poetry add easyslip-sdk. The package is imported as easyslip.

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.py and put the slip image next to it as slip.jpg:

python
from easyslip import Client
from easyslip.errors import SlipNotFoundError
from easyslip.models import MatchedAccount, VerifyOptions

order_total = 1500.0  # baht

with Client() as client:  # reads your key from EASYSLIP_API_KEY
    try:
        result = client.verify.bank_by_image(
            "slip.jpg",
            VerifyOptions(
                match_account=True,  # did the slip pay one of your bank accounts?
                match_amount=order_total,  # is the amount right?
                check_duplicate=True,  # was this slip used before?
            ),
        )
    except SlipNotFoundError:
        print("The bank has no record of this transfer. The slip may be fake or edited.")
    else:
        slip = result.raw_slip
        if result.is_duplicate is not False:
            print("This slip was already used. Do not credit the order again.")
        elif result.is_amount_matched is not True:
            print(f"The slip pays {result.amount_in_slip} baht, but the order is {order_total} baht.")
        elif not isinstance(result.matched_account, MatchedAccount):
            print("The slip does not pay any of your bank accounts.")
        else:
            print("Payment confirmed")
            print(f"Amount:    {slip.amount.amount} THB")
            print(f"Date:      {slip.date}")
            print(f"From:      {slip.sender.account.name.th}")
            print(f"To:        {slip.receiver.account.name.th}")
            print(f"Reference: {slip.trans_ref}")

Run it with your Thunder API key. The two EASYSLIP_*_BASE_URL variables point the SDK at Thunder; without them it calls the EasySlip address and your Thunder key is rejected:

sh
export EASYSLIP_V2_BASE_URL="https://api.thunder.in.th/v2"
export EASYSLIP_V1_BASE_URL="https://api.thunder.in.th/v1"
EASYSLIP_API_KEY="your-api-key" python verify.py

For a valid slip it prints:

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

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

The program accepts the payment only when each check returned the value that means it passed. A check result is UNSET when the API did not send it, and the program treats that as a failed check too.

Take the order total from your own database, never from the customer. Credit the order only when all three checks pass, and store raw_slip.trans_ref 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. Use it as a context manager, or call client.close() when you are done, so that its connections are closed. An option you leave out comes from its environment variable, or else from its default.

OptionEnvironment variableDefaultWhat it does
api_keyEASYSLIP_API_KEYNoneYour API key. Without a key, Client() raises 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 api_key and no EASYSLIP_API_KEY.
timeoutNo variable30How long one attempt may take, in seconds.
max_retriesNo variable2How many times the SDK retries a failed request, from 0 to 20. 0 turns retries off.
httpNo variableA new httpx.ClientThe HTTPX client the SDK sends requests with. AsyncClient takes an httpx.AsyncClient.
v2_base_urlEASYSLIP_V2_BASE_URLhttps://api.easyslip.com/v2 (set https://api.thunder.in.th/v2 for Thunder)The address of API v2.
v1_base_urlEASYSLIP_V1_BASE_URLhttps://api.easyslip.com/v1 (set https://api.thunder.in.th/v1 for Thunder)The address of API v1.
python
from easyslip import Client

with Client(timeout=15, max_retries=1) as client:  # 15 seconds per attempt
    print(client.get_info().application.name)

easyslip.EasySlip is another name for Client.

Custom HTTP client ​

Pass your own httpx.Client to send requests through a proxy, add event hooks for logging, or record requests in tests. The SDK leaves a client you pass open, so close it yourself:

python
import httpx
from easyslip import Client


def log_response(response: httpx.Response) -> None:
    print(response.request.method, response.request.url, response.status_code)


with httpx.Client(trust_env=False, follow_redirects=False, event_hooks={"response": [log_response]}) as http:
    with Client(timeout=15, max_retries=1, http=http) as client:
        print(client.get_info().application.name)

The SDK applies timeout to each attempt and never follows redirects, even with your own client.

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.bank_by_image(image)Image
The text in the slip's QR code, read by a QR scannerverify.bank_by_payload(payload)Payload
A public https URL of the slip imageverify.bank_by_url(url)URL
The image as a base64 string, with or without a data: prefixverify.bank_by_base64(base64)Base64
python
from easyslip import Client

with Client() as client:
    result = client.verify.bank_by_url("https://example.com/slips/1001.jpg")
    print(result.raw_slip.trans_ref, result.amount_in_slip)

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, bank_by_payload is the fastest method because nothing is uploaded.

Image inputs ​

bank_by_image and truewallet_by_image accept:

  • a file path, as a str or a pathlib.Path
  • the image as bytes
  • a binary file object, such as one from open(path, "rb")

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 raises 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 str passed to bank_by_image is always read as a path on your server. Pass a customer's upload as bytes, never as a string the customer controls.

This Flask route takes a slip uploaded in a form field named slip:

python
from flask import Flask, Response, jsonify, request
from easyslip import Client
from easyslip.errors import InvalidImageError
from easyslip.models import VerifyOptions

app = Flask(__name__)
client = Client()


@app.post("/slips")
def upload_slip() -> Response | tuple[str, int]:
    upload = request.files.get("slip")
    if upload is None:
        return 'Attach the slip image as "slip".', 400
    try:
        result = client.verify.bank_by_image(upload.read(), VerifyOptions(check_duplicate=True))
    except InvalidImageError:
        return "Upload a clear slip image.", 422
    return jsonify(trans_ref=result.raw_slip.trans_ref, amount=result.amount_in_slip)

In Django, pass request.FILES["slip"].read(). In FastAPI, use AsyncClient and pass await upload.read().

Check amount, receiving account and duplicates ​

Turn on the checks you need with the fields of VerifyOptions. 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
match_amount=1500.0The slip amount equals the amount you expect, in baht.amount_in_order, is_amount_matchedis_amount_matched is False.
match_account=TrueThe receiver is one of your registered bank accounts.matched_accountmatched_account is None.
check_duplicate=TrueThe slip was verified before.is_duplicateis_duplicate is True.
remark="Order 1001"Nothing. It saves your note of 1 to 255 characters with the verification.remarkIt never fails.
python
from easyslip import Client
from easyslip.models import MatchedAccount, VerifyOptions

with Client() as client:
    result = client.verify.bank_by_image(
        "slip.jpg",
        VerifyOptions(remark="Order 1001", match_account=True, match_amount=1500.0, check_duplicate=True),
    )
    accepted = (
        result.is_amount_matched is True
        and isinstance(result.matched_account, MatchedAccount)
        and result.is_duplicate is False
    )
    print("accept" if accepted else "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. is_duplicate is True in both cases.

match_account only compares against accounts linked to the branch of your API key. If a slip you expect to pass comes back with matched_account set to None, check that the receiving account is linked to that branch.

UNSET and None ​

A result field that the API did not send has the value easyslip.UNSET, while a field the API sent as JSON null is None. For example, matched_account is UNSET when you did not pass match_account=True, and None when you did and the slip paid none of your accounts. Compare with is True, is False or isinstance, as the examples on this page do, so that an UNSET value never counts as a passed check.

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.

python
from easyslip import Client
from easyslip.models import VerifyOptions

with Client() as client:
    result = client.verify.truewallet_by_image(
        "wallet-receipt.png", VerifyOptions(match_amount=500.0, check_duplicate=True)
    )
    slip = result.raw_slip
    print(slip.transaction_id, slip.amount, slip.receiver.name)

verify.truewallet_by_url takes an image URL and verify.truewallet_by_base64 takes base64. TrueMoney Wallet receipts have no payload method.

Account info and remaining quota ​

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

python
from easyslip import Client

with Client() as client:
    info = client.get_info()
    quota = info.application.quota
    print(f"{info.application.name}, branch {info.branch.name}")
    print(f"Used {quota.used}, remaining {quota.remaining if quota.remaining is not None else 'unlimited'}")

Receiving bank accounts ​

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

python
from easyslip import Client
from easyslip.models import CreateBankAccountParams, ListBankAccountsParams, UpdateBankAccountParams

with Client() as client:
    account = client.bank_accounts.create(
        CreateBankAccountParams(
            bank_code="004",  # bank codes come from client.banks.list()
            bank_number="1234567890",
            name_th="บริษัท ตัวอย่าง จำกัด",
            name_en="EXAMPLE CO., LTD.",
            type="JURISTIC",
        )
    )
    client.bank_accounts.update(account.id, UpdateBankAccountParams(name_en="EXAMPLE COMPANY LIMITED"))

    # One page of the accounts linked to this branch
    page = client.bank_accounts.list(ListBankAccountsParams(page=1, limit=20))
    print(f"{page.total} accounts linked to this branch")

    # Every account in the service. The SDK fetches the pages for you.
    for item in client.bank_accounts.iterate_all():
        print(item.id, item.bank_number, item.name_en)
MethodWhat it does
bank_accounts.create(params)Adds an account to the service and links it to this branch.
bank_accounts.get(id)Returns one account.
bank_accounts.update(id, params)Changes only the fields you pass. extra_verify=None clears that field.
bank_accounts.link(id)Links an account of the service to this branch. Linking twice is fine.
bank_accounts.unlink(id)Removes the link to this branch. The account stays in the service.
bank_accounts.list(params)Returns one page of the accounts linked to this branch. page starts at 1, and limit is 1 to 100 (default 20).
bank_accounts.list_all(params)Returns one page of every account in the service, with the same paging.
bank_accounts.iterate(params)Goes through every account linked to this branch, 100 per page.
bank_accounts.iterate_all(params)Goes through every account in the service, 100 per page.

type is "NATURAL" for a personal account or "JURISTIC" for a company account. Its type, AccountType from easyslip, allows only those two values, so mypy rejects any other. The four list methods take a ListBankAccountsParams that can filter by type and by bank_code. Adding an account that already exists raises ConflictError.

Bank codes ​

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

python
from easyslip import Client

with Client() as client:
    for bank in client.banks.list().items:
        print(bank.code, bank.short_code, bank.name_en)

Verify many slips in the background ​

A background job checks a slip on the Thunder 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 callback_url, 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:

python
from pathlib import Path
from easyslip import Client
from easyslip.models import BatchOptions, BatchSlip

payloads = [line for line in Path("payloads.txt").read_text().splitlines() if line]

with Client() as client:
    batch = client.jobs.submit_bank_batch(
        [BatchSlip(payload=payload, check_duplicate=True) for payload in payloads],
        BatchOptions(callback_url="https://example.com/webhooks/easyslip"),
    )

    # Your webhook receives every result. You can also wait for a job here:
    for submitted in batch.jobs:
        job = client.jobs.wait_for_job(submitted.job_id)
        print(job.job_id, job.status, job.result)
MethodWhat it does
jobs.submit_bank(payload, options)Submits one slip. AsyncVerifyOptions takes the same options as VerifyOptions plus callback_url.
jobs.submit_bank_batch(slips, options)Submits 1 to 100 slips in one request. Each BatchSlip has its own options, and BatchOptions holds the callback_url.
jobs.submit_bank_many(slips, options)Submits any number of slips in batches of 100, one batch after another, and returns the list of batches.
jobs.get(job_id)Returns the job, with its result once it has finished.
jobs.wait_for_job(job_id, interval=2, timeout=600)Checks the job every interval seconds until it is done or failed, for at most timeout seconds (600 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 wait_for_job 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 submit_bank_many stops part way, it raises BatchSubmitError. The batches in error.submitted were accepted and keep running, so submit only the slips from error.failed_chunk onward again.

Generate a payment QR code ​

v1.generate_qr 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.

python
from pathlib import Path
from easyslip import Client
from easyslip.models import QrRequest

with Client() as client:
    # PromptPay to a phone number, for 250 baht
    prompt_pay = client.v1.generate_qr(QrRequest(type="PROMPTPAY", msisdn="0812345678", amount=250.0))
    Path("promptpay.png").write_bytes(prompt_pay.image_bytes())

    # Bill payment to your biller ID, with your invoice number as reference 1
    bill = client.v1.generate_qr(
        QrRequest(type="PROMPTPAY", biller_id="010555512345601", ref1="INV1001", amount=1500.0)
    )
    Path("bill.png").write_bytes(bill.image_bytes())
typeRequired fieldsOptional fields
"PROMPTPAY"One of msisdn (phone number), nat_id (national ID or tax ID), e_wallet_id or biller_id; with biller_id, ref1 is also requiredamount, and ref2 with biller_id
"KSHOP"ref1, the shop ID: KB followed by 12 digitsamount
"MAE_MANEE"ref1, the 15-digit shop IDamount
"TUNGNGERN"ref1, the merchant code, and merchant_name, the shop name in Englishamount

The older "BILL_PAYMENT" type still works. With the same biller_id it creates the same QR code as "PROMPTPAY". The QrType type from easyslip 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 client.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 check_duplicate=True a slip that was verified before raises DuplicateSlipError.

python
from easyslip import Client
from easyslip.errors import DuplicateSlipError
from easyslip.models import V1VerifyOptions

with Client() as client:
    try:
        slip = client.v1.verify_bank_by_image("slip.jpg", V1VerifyOptions(check_duplicate=True))
        print(slip.trans_ref, slip.amount.amount)
    except DuplicateSlipError as error:
        print("This slip was already used. Transfer date:", error.slip.date if error.slip else None)

API v1 also has verify_bank_by_payload, verify_bank_by_url, verify_bank_by_base64, verify_truemoney_by_image and get_me. Version Comparison lists what changed in v2.

Async code ​

AsyncClient has the same resources and methods as Client, and you await each call. Use it in asyncio applications such as FastAPI, and close it with async with or await client.close(). Keep the number of calls in flight small, because the API limits how many requests you can send. This example checks three slips at a time at most:

python
import asyncio
from easyslip import AsyncClient
from easyslip.models import BankSlipResult, VerifyOptions


async def main() -> None:
    files = ["slip-1001.jpg", "slip-1002.jpg", "slip-1003.jpg"]
    limit = asyncio.Semaphore(3)
    async with AsyncClient() as client:

        async def verify(path: str) -> BankSlipResult:
            async with limit:
                return await client.verify.bank_by_image(path, VerifyOptions(check_duplicate=True))

        outcomes = await asyncio.gather(*(verify(path) for path in files), return_exceptions=True)
    for path, outcome in zip(files, outcomes):
        if isinstance(outcome, BaseException):
            print(path, "failed:", outcome)
        else:
            print(path, outcome.raw_slip.trans_ref)


asyncio.run(main())

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 raw_slip. The API reference describes the full model.

Bank slip ​

verify.bank_by_* returns a BankSlipResult:

FieldTypeMeaningExample
amount_in_slipfloatThe amount transferred, in baht.1500.0
is_amount_matchedboolWhether the amount equals match_amount. Only with match_amount, otherwise UNSET.True
amount_in_orderfloatThe match_amount you sent. Only with match_amount.1500.0
matched_accountMatchedAccount or NoneYour registered account that the slip paid, or None. Only with match_account.bank_number is "123-4-56789-0"
is_duplicateboolWhether the slip was verified before. Only with check_duplicate.False
remarkstrYour remark. Only when you sent one."Order 1001"
raw_slip.trans_refstrThe bank's reference for the transfer. Each transfer has its own, so you can store it to catch reuse."68370160657749I376388B35"
raw_slip.datestrWhen the transfer happened, in ISO 8601 with the Thai time offset."2024-01-15T14:30:00+07:00"
raw_slip.amount.amountfloatThe amount, in baht.1500.0
raw_slip.feefloatThe transfer fee, when the bank reports one.0.0
raw_slip.sender.bankSlipBankThe sender's bank: id (the bank code), name and short."004", "กสิกรไทย", "KBANK"
raw_slip.sender.account.nameAccountNameThe sender's name in Thai (th) and English (en), when the bank shows it."นาย ผู้โอน ทดสอบ", "MR. SENDER TEST"
raw_slip.sender.account.bankAccountNumberThe sender's account number (account) and its type, partly hidden by the bank."BANKAC", "123-4-xxxxx-5"
raw_slip.receiver.bankSlipReceiverBankThe receiver's bank, in the same form as the sender's."014", "ไทยพาณิชย์", "SCB"
raw_slip.receiver.account.nameSlipReceiverAccountNameThe receiver's name."บริษัท ตัวอย่าง จำกัด"
raw_slip.receiver.account.bankAccountNumberThe receiver's account number, partly hidden."BANKAC", "xxx-x-x5678-x"
raw_slip.receiver.account.proxyAccountNumberThe PromptPay ID the money was sent to, when the transfer used one."MSISDN", "08xxxxxxxx89"
raw_slip.ref1, ref2, ref3strBill payment references. They are empty for ordinary transfers.""

Fields that the bank does not always report, such as fee, the bank of each party, the names and the account numbers, are UNSET when they are missing. Check them with isinstance before you use their attributes.

TrueMoney Wallet receipt ​

verify.truewallet_by_* returns a TrueWalletResult. Its top-level fields are the same as for a bank slip, and raw_slip holds:

FieldTypeMeaningExample
raw_slip.transaction_idstrThe TrueMoney transaction ID."50012345678901"
raw_slip.datestrWhen the payment happened, in ISO 8601."2026-10-26T14:30:00+07:00"
raw_slip.amountfloatThe amount, in baht.500.0
raw_slip.sender.namestrThe payer's name."นาย ผู้โอน ทดสอบ"
raw_slip.receiver.namestrThe receiver's name."นาย รับเงิน ทดสอบ"
raw_slip.receiver.phonestrThe receiver's phone number, partly hidden."08x-xxx-4567"

Jobs and webhook events ​

jobs.get and wait_for_job return a Job, and your webhook receives a WebhookEvent:

FieldJobWebhookEventMeaning
job_idYesYesThe job ID that submit_bank or submit_bank_batch returned.
batch_idYesYesThe 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 BankSlipResult 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 attribute with the HTTP status, the request_id, the rate_limit counters and the response headers. Include meta.request_id when you contact support about a call.

Error handling ​

Every exception the SDK raises is an easyslip.EasySlipError. The classes are in easyslip.errors. Exceptions that come from the API are ApiErrors, and they carry status, code (such as SLIP_NOT_FOUND), message, request_id and retry_after. 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. InputError is also a ValueError. 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.

Exception 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 callback_url 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 check_duplicate=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. bank_accounts.iterate_all() finds it.
RateLimitError429You sent too many requests. error.retry_after 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 Thunder side.Try again later. If it keeps happening, contact support with error.request_id.
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 request_id, 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 sendingwait_for_job reached its timeout. error.last_job is the last state it saw.Wait longer, or rely on the webhook.
BatchSubmitErrorBefore sendingsubmit_bank_many stopped part way. error.submitted lists the batches that were sent, error.failed_chunk is the index of the batch that failed, counting from 0, and error.cause is its error.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:

python
import logging
from easyslip import Client
from easyslip.errors import (
    ApiError,
    InvalidImageError,
    QuotaExceededError,
    RateLimitError,
    SlipNotFoundError,
    SlipPendingError,
)
from easyslip.models import MatchedAccount, VerifyOptions

logger = logging.getLogger(__name__)


def check_payment(client: Client, upload: bytes, order_total: float) -> str:
    try:
        result = client.verify.bank_by_image(
            upload, VerifyOptions(match_amount=order_total, match_account=True, check_duplicate=True)
        )
    except SlipNotFoundError:
        return "We could not find this transfer at the bank."
    except SlipPendingError:
        return "The bank is still processing this transfer. Try again in a minute."
    except InvalidImageError:
        return "Please upload a clear photo or screenshot of the slip."
    except (QuotaExceededError, RateLimitError) as error:
        logger.error("Thunder limit reached: %s %s", error.code, error.request_id)
        return "We cannot check slips right now. Please try again shortly."
    except ApiError as error:
        logger.error("Thunder error: %s %s %s", error.status, error.code, error.request_id)
        return "We could not check the slip. Please try again."
    if result.is_duplicate is not False:
        return "This slip has already been used."
    if result.is_amount_matched is not True:
        return f"This slip is for {result.amount_in_slip} baht."
    if not isinstance(result.matched_account, MatchedAccount):
        return "This slip was not paid to our account."
    return "Payment received."


with Client() as client, open("slip.jpg", "rb") as file:
    print(check_payment(client, file.read(), 1500.0))

What the SDK retries ​

The SDK retries a failed request up to max_retries 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 raises the exception at once.

RequestRetried whenNever retried when
Reading data and linking accounts: get_info, banks.list, bank_accounts.list, list_all, get and link, jobs.get, v1.get_me, v1.generate_qrThe 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 check_duplicate=True: when the first attempt was counted, the second one returns the saved result with is_duplicate set to True and uses no quota.

Webhooks ​

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

python
import os
from flask import Flask, Response, request
from easyslip.errors import InputError, WebhookSignatureError
from easyslip.webhooks import construct_event

app = Flask(__name__)
webhook_secret = os.environ["EASYSLIP_WEBHOOK_SECRET"]


@app.post("/webhooks/easyslip")
def receive_webhook() -> Response:
    raw_body = request.get_data(cache=False)  # the body exactly as it arrived
    try:
        event = construct_event(raw_body, request.headers.get("X-Thunder-Signature"), webhook_secret)
    except (WebhookSignatureError, InputError):
        return Response("invalid webhook", status=400)
    if event.status == "success" and event.data is not None:
        app.logger.info("Verified %s %s %s", event.job_id, event.data.raw_slip.trans_ref, event.data.amount_in_slip)
    else:
        app.logger.info("Not verified %s %s", event.job_id, event.status)
    return Response(status=204)
python
import logging
import os
from fastapi import FastAPI, Request, Response
from easyslip.errors import InputError, WebhookSignatureError
from easyslip.webhooks import construct_event

app = FastAPI()
logger = logging.getLogger(__name__)
webhook_secret = os.environ["EASYSLIP_WEBHOOK_SECRET"]


@app.post("/webhooks/easyslip")
async def receive_webhook(request: Request) -> Response:
    raw_body = await request.body()  # the body exactly as it arrived
    try:
        event = construct_event(raw_body, request.headers.get("X-Thunder-Signature"), webhook_secret)
    except (WebhookSignatureError, InputError):
        return Response("invalid webhook", status_code=400)
    if event.status == "success" and event.data is not None:
        logger.info("Verified %s %s %s", event.job_id, event.data.raw_slip.trans_ref, event.data.amount_in_slip)
    else:
        logger.info("Not verified %s %s", event.job_id, event.status)
    return Response(status_code=204)

Both handlers answer 400 when the signature is missing or wrong and when a signed body is not a webhook event. In Django, pass request.body and request.headers.get("X-Thunder-Signature"), on a view marked @csrf_exempt. Do not parse the JSON and dump it again before you check it. That changes the bytes, and the signature then no longer matches.

Function in easyslip.webhooksWhat it does
construct_event(raw_body, signature, secret)Checks the signature, then returns the parsed WebhookEvent. It raises WebhookSignatureError when the signature is missing or wrong, and InputError when the body is not a JSON webhook event.
verify_signature(raw_body, signature, secret)Returns True when the signature matches and False when it does not.
parse_event(raw_body)Parses the event without checking the signature, and raises InputError for a body that is not an event. Use it only after you have checked the signature yourself.

secret is your branch's webhook secret, and you pass it on every call. Set it for your branch in the developer portal. Without one, callbacks arrive unsigned and construct_event rejects them, so configure it before you go live.

Delivery rules:

  • Answer with a 2xx status within 5 seconds. Save the event and do slow work afterwards, for example in a task queue.
  • A non-2xx answer or a timeout makes Thunder send the event again, after about 10, 30 and 120 seconds.
  • Thunder may send the same event more than once, so use job_id to skip events you have already handled.
  • If every delivery fails, jobs.get(job_id) 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 removes it from exception messages and from the headers it gives you, so you can log its errors. error.sensitive_raw_body() returns the original error body; keep it out of your logs.
  • 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 Python versions that the Python project still supports. After a Python 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 Thunder Solution through the developer portal, and include the request_id 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