Go SDK
github.com/EASYSLIP-CO-LTD/sdk-go lets your Go service 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 uses only the standard library, takes a context.Context on every call, and returns typed errors that work with errors.As.
This page is the full guide. The README on GitHub has a shorter summary, and the source code is at EASYSLIP-CO-LTD/sdk-go.
Requirements
- Go 1.26 or 1.27.
- 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
go get github.com/EASYSLIP-CO-LTD/sdk-goThe package name is easyslip. The examples on this page import it as easyslip "github.com/EASYSLIP-CO-LTD/sdk-go" so that the name is clear.
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 main.go in a new module and put the slip image next to it as slip.jpg:
package main
import (
"context"
"errors"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("") // reads your key from EASYSLIP_API_KEY
if err != nil {
log.Fatal(err)
}
defer client.Close()
orderTotal := 1500.0 // baht
result, err := client.Verify.BankByImage(context.Background(), "slip.jpg", &easyslip.VerifyOptions{
MatchAccount: easyslip.Some(true), // did the slip pay one of your bank accounts?
MatchAmount: easyslip.Some(orderTotal), // is the amount right?
CheckDuplicate: easyslip.Some(true), // was this slip used before?
})
var notFound *easyslip.SlipNotFoundError
if errors.As(err, ¬Found) {
fmt.Println("The bank has no record of this transfer. The slip may be fake or edited.")
return
}
if err != nil {
log.Fatal(err)
}
duplicate, checked := result.IsDuplicate.Value()
amountMatched, _ := result.IsAmountMatched.Value()
account, _ := result.MatchedAccount.Value()
slip := result.RawSlip
switch {
case !checked || duplicate:
fmt.Println("This slip was already used. Do not credit the order again.")
case !amountMatched:
fmt.Printf("The slip pays %v baht, but the order is %v baht.\n", result.AmountInSlip, orderTotal)
case account == nil:
fmt.Println("The slip does not pay any of your bank accounts.")
default:
sender, _ := slip.Sender.Account.Name.Th.Value()
receiver, _ := slip.Receiver.Account.Name.Th.Value()
fmt.Println("Payment confirmed")
fmt.Printf("Amount: %v THB\n", slip.Amount.Amount)
fmt.Printf("Date: %s\n", slip.Date)
fmt.Printf("From: %s\n", sender)
fmt.Printf("To: %s\n", receiver)
fmt.Printf("Reference: %s\n", slip.TransRef)
}
}Run it with your API key:
EASYSLIP_API_KEY="your-api-key" go run .For a valid slip it prints:
Payment confirmed
Amount: 1500 THB
Date: 2024-01-15T14:30:00+07:00
From: นาย ผู้โอน ทดสอบ
To: บริษัท ตัวอย่าง จำกัด
Reference: 68370160657749I376388B35MatchAccount 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.
The program accepts the payment only when each check returned the value that means it passed. Value() reports false as its second result when the API did not send that check, and the program treats that as a failed check too. Optional fields explains Field.
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:
- Verify slips you receive in another form, such as QR code text or an image URL.
- See every field of the result.
- Handle errors and see what the SDK retries for you.
- Receive background results on a webhook.
Create a client
Create one client when your service starts, share it between goroutines, and call Close when the service stops. NewClient takes your API key, or "" to read it from the environment, followed by options. A setting you leave out comes from its environment variable, or else from its default.
| Option | Environment variable | Default | What it does |
|---|---|---|---|
NewClient(apiKey) | EASYSLIP_API_KEY | None | Your API key. Without a key, NewClient returns an *InputError. |
| No option | EASYSLIP_API_KEY_FILE | None | The path of a file that holds your API key, such as a Docker or Kubernetes secret. The SDK uses it when the key is "" and there is no EASYSLIP_API_KEY. |
WithTimeout(d) | No variable | 30 * time.Second | How long one attempt may take. |
WithMaxRetries(n) | No variable | 2 | How many times the SDK retries a failed request, from 0 to 20. 0 turns retries off. |
WithHTTPClient(c) | No variable | A new http.Client | The *http.Client the SDK sends requests with. |
WithBaseURLs(v2, v1) | EASYSLIP_V2_BASE_URL, EASYSLIP_V1_BASE_URL | https://api.easyslip.com/v2, https://api.easyslip.com/v1 | The addresses of API v2 and API v1. |
package main
import (
"context"
"fmt"
"log"
"time"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("",
easyslip.WithTimeout(15*time.Second), // 15 seconds per attempt
easyslip.WithMaxRetries(1),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
info, err := client.GetInfo(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(info.Application.Name)
}easyslip.EasySlip is another name for easyslip.Client.
Custom HTTP client
Pass your own *http.Client to send requests through a proxy, wrap its transport for logging or tracing, or record requests in tests. The SDK uses a copy of it, applies WithTimeout to each attempt and never follows redirects. Close leaves a client you pass alone, so close its idle connections yourself:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
type loggingTransport struct{ next http.RoundTripper }
func (t loggingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
started := time.Now()
response, err := t.next.RoundTrip(request)
if err == nil {
log.Printf("%s %s %d %s", request.Method, request.URL, response.StatusCode, time.Since(started))
}
return response, err
}
func main() {
httpClient := &http.Client{Transport: loggingTransport{next: http.DefaultTransport.(*http.Transport).Clone()}}
defer httpClient.CloseIdleConnections()
client, err := easyslip.NewClient("", easyslip.WithHTTPClient(httpClient))
if err != nil {
log.Fatal(err)
}
defer client.Close()
info, err := client.GetInfo(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(info.Application.Name)
}The default client does not read proxy settings from the environment. To use a proxy, set Proxy on the transport of the client you pass.
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. Pass nil as the options when you need no checks.
| What you have | Method | API reference |
|---|---|---|
| An image file, an upload or a stream | Verify.BankByImage(ctx, image, options) | Image |
| The text in the slip's QR code, read by a QR scanner | Verify.BankByPayload(ctx, payload, options) | Payload |
| A public https URL of the slip image | Verify.BankByURL(ctx, url, options) | URL |
The image as a base64 string, with or without a data: prefix | Verify.BankByBase64(ctx, base64, options) | Base64 |
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Verify.BankByURL(context.Background(), "https://example.com/slips/1001.jpg", nil)
if err != nil {
log.Fatal(err)
}
fmt.Println(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 - the image as a
[]byte - an
io.Reader, such as amultipart.Filefromr.FormFile - an
easyslip.Imagefromeasyslip.OpenImage,easyslip.ReadImageoreasyslip.ImageBytes
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 returns an *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 []byte or an io.Reader, never as a string the customer controls.
This handler takes a slip uploaded in a form field named slip:
package main
import (
"errors"
"fmt"
"log"
"net/http"
"time"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func uploadSlip(client *easyslip.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
file, _, err := r.FormFile("slip")
if err != nil {
http.Error(w, `Attach the slip image as "slip".`, http.StatusBadRequest)
return
}
defer file.Close()
result, err := client.Verify.BankByImage(r.Context(), file, &easyslip.VerifyOptions{CheckDuplicate: easyslip.Some(true)})
var badImage *easyslip.InvalidImageError
if errors.As(err, &badImage) {
http.Error(w, "Upload a clear slip image.", http.StatusUnprocessableEntity)
return
}
if err != nil {
http.Error(w, "We could not check the slip.", http.StatusBadGateway)
return
}
fmt.Fprintf(w, "Reference %s, %v baht", result.RawSlip.TransRef, result.AmountInSlip)
}
}
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
mux := http.NewServeMux()
mux.Handle("POST /slips", uploadSlip(client))
server := &http.Server{Addr: ":8000", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(server.ListenAndServe())
}Check amount, receiving account and duplicates
Turn on the checks you need with the fields of VerifyOptions. Wrap each value in easyslip.Some. 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.
| Option | What it checks | Result fields | When the check fails |
|---|---|---|---|
MatchAmount: easyslip.Some(1500.0) | The slip amount equals the amount you expect, in baht. | AmountInOrder, IsAmountMatched | IsAmountMatched is false. |
MatchAccount: easyslip.Some(true) | The receiver is one of your registered bank accounts. | MatchedAccount | MatchedAccount is null. |
CheckDuplicate: easyslip.Some(true) | The slip was verified before. | IsDuplicate | IsDuplicate is true. |
Remark: easyslip.Some("Order 1001") | Nothing. It saves your note of 1 to 255 characters with the verification. | Remark | It never fails. |
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Verify.BankByImage(context.Background(), "slip.jpg", &easyslip.VerifyOptions{
Remark: easyslip.Some("Order 1001"),
MatchAccount: easyslip.Some(true),
MatchAmount: easyslip.Some(1500.0),
CheckDuplicate: easyslip.Some(true),
})
if err != nil {
log.Fatal(err)
}
amountMatched, _ := result.IsAmountMatched.Value()
account, _ := result.MatchedAccount.Value()
duplicate, checked := result.IsDuplicate.Value()
accepted := amountMatched && account != nil && checked && !duplicate
remark, _ := result.Remark.Value()
fmt.Println(map[bool]string{true: "accept", false: "reject"}[accepted], 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 a null MatchedAccount, check that the receiving account is linked to that branch.
Optional fields
Fields that the API may leave out, or send as JSON null, have the type easyslip.Field[T]:
Value()returns the value andtruewhen the field holds a value, and the zero value andfalsewhen it is absent or null.IsSet()reports whether the API sent the field at all, andIsNull()whether it sentnull.- In options,
easyslip.Some(v)sends a value andeasyslip.Null[T]()sendsnull. A field you leave at its zero value is not sent.
For example, MatchedAccount is not set when you did not ask for MatchAccount, and set but null when you did and the slip paid none of your accounts.
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.
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Verify.TruewalletByImage(context.Background(), "wallet-receipt.png", &easyslip.VerifyOptions{
MatchAmount: easyslip.Some(500.0),
CheckDuplicate: easyslip.Some(true),
})
if err != nil {
log.Fatal(err)
}
slip := result.RawSlip
fmt.Println(slip.TransactionID, slip.Amount, slip.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 nil when your plan has no limit.
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
info, err := client.GetInfo(context.Background())
if err != nil {
log.Fatal(err)
}
quota := info.Application.Quota
remaining := "unlimited"
if quota.Remaining != nil {
remaining = fmt.Sprint(*quota.Remaining)
}
fmt.Printf("%s, branch %s\n", info.Application.Name, info.Branch.Name)
fmt.Printf("Used %d, remaining %s\n", quota.Used, remaining)
}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.
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
account, err := client.BankAccounts.Create(ctx, easyslip.CreateBankAccountParams{
BankCode: "004", // bank codes come from client.Banks.List
BankNumber: "1234567890",
NameTh: "บริษัท ตัวอย่าง จำกัด",
NameEn: "EXAMPLE CO., LTD.",
Type: easyslip.AccountTypeJuristic,
})
if err != nil {
log.Fatal(err)
}
if _, err := client.BankAccounts.Update(ctx, account.ID, easyslip.UpdateBankAccountParams{
NameEn: easyslip.Some("EXAMPLE COMPANY LIMITED"),
}); err != nil {
log.Fatal(err)
}
// One page of the accounts linked to this branch
page, err := client.BankAccounts.List(ctx, &easyslip.ListBankAccountsParams{Page: easyslip.Some(1), Limit: easyslip.Some(20)})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d accounts linked to this branch\n", page.Total)
// Every account in the service. The SDK fetches the pages for you.
for item, err := range client.BankAccounts.IterateAll(ctx, nil) {
if err != nil {
log.Fatal(err)
}
fmt.Println(item.ID, item.BankNumber, item.NameEn)
}
}| Method | What it does |
|---|---|
BankAccounts.Create(ctx, params) | Adds an account to the service and links it to this branch. |
BankAccounts.Get(ctx, id) | Returns one account. |
BankAccounts.Update(ctx, id, params) | Changes only the fields you set. ExtraVerify: easyslip.Null[*string]() clears that field. |
BankAccounts.Link(ctx, id) | Links an account of the service to this branch. Linking twice is fine. |
BankAccounts.Unlink(ctx, id) | Removes the link to this branch. The account stays in the service. |
BankAccounts.List(ctx, 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(ctx, params) | Returns one page of every account in the service, with the same paging. |
BankAccounts.Iterate(ctx, params) | Goes through every account linked to this branch, 100 per page, as an iter.Seq2[BankAccount, error]. |
BankAccounts.IterateAll(ctx, params) | Goes through every account in the service, 100 per page. |
Type is an easyslip.AccountType: easyslip.AccountTypeNatural ("NATURAL") for a personal account or easyslip.AccountTypeJuristic ("JURISTIC") for a company account. In UpdateBankAccountParams and ListBankAccountsParams, wrap it as easyslip.Some(easyslip.AccountTypeJuristic). The four list methods take a *ListBankAccountsParams that can filter by Type and by BankCode, or nil. Adding an account that already exists returns a *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.
package main
import (
"context"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
banks, err := client.Banks.List(context.Background())
if err != nil {
log.Fatal(err)
}
for _, bank := range banks.Items {
fmt.Println(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:
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
text, err := os.ReadFile("payloads.txt")
if err != nil {
log.Fatal(err)
}
var slips []easyslip.BatchSlip
for _, payload := range strings.Fields(string(text)) {
slips = append(slips, easyslip.BatchSlip{Payload: payload, CheckDuplicate: easyslip.Some(true)})
}
batch, err := client.Jobs.SubmitBankBatch(ctx, slips, &easyslip.BatchOptions{
CallbackURL: easyslip.Some("https://example.com/webhooks/easyslip"),
})
if err != nil {
log.Fatal(err)
}
// Your webhook receives every result. You can also wait for a job here:
for _, submitted := range batch.Jobs {
job, err := client.Jobs.WaitForJob(ctx, submitted.JobID, nil)
if err != nil {
log.Fatal(err)
}
if result, ok := job.Result.Value(); ok && result.Data != nil {
fmt.Println(job.JobID, job.Status, result.Status, result.Data.AmountInSlip)
} else {
fmt.Println(job.JobID, job.Status)
}
}
}| Method | What it does |
|---|---|
Jobs.SubmitBank(ctx, payload, options) | Submits one slip. AsyncVerifyOptions takes the same options as VerifyOptions plus CallbackURL. |
Jobs.SubmitBankBatch(ctx, slips, options) | Submits 1 to 100 slips in one request. Each BatchSlip has its own options, and BatchOptions holds the CallbackURL. |
Jobs.SubmitBankMany(ctx, slips, options) | Submits any number of slips in batches of 100, one batch after another, and returns the batches. |
Jobs.Get(ctx, jobID) | Returns the job, with its result once it has finished. |
Jobs.WaitForJob(ctx, jobID, options) | Checks the job every Interval (default 2 seconds) until it is done or failed, for at most Timeout (default 10 minutes). Pass a *WaitOptions to change them, or nil. |
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 returns a *BatchSubmitError. The batches in Submitted were accepted and keep running, so submit only the slips from 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.
package main
import (
"context"
"log"
"os"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
// PromptPay to a phone number, for 250 baht
promptPay, err := client.V1.GenerateQR(ctx, easyslip.QRRequest{
Type: easyslip.QRTypePromptPay,
Msisdn: easyslip.Some("0812345678"),
Amount: easyslip.Some(250.0),
})
if err != nil {
log.Fatal(err)
}
save("promptpay.png", promptPay)
// Bill payment to your biller ID, with your invoice number as reference 1
bill, err := client.V1.GenerateQR(ctx, easyslip.QRRequest{
Type: easyslip.QRTypePromptPay,
BillerID: easyslip.Some("010555512345601"),
Ref1: easyslip.Some("INV1001"),
Amount: easyslip.Some(1500.0),
})
if err != nil {
log.Fatal(err)
}
save("bill.png", bill)
}
func save(name string, qr *easyslip.QRCode) {
image, err := qr.ImageBytes()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile(name, image, 0o644); err != nil {
log.Fatal(err)
}
}Type | Required fields | Optional fields |
|---|---|---|
QRTypePromptPay | One of Msisdn (phone number), NatID (national ID or tax ID), EWalletID or BillerID; with BillerID, Ref1 is also required | Amount, and Ref2 with BillerID |
QRTypeKShop | Ref1, the shop ID: KB followed by 12 digits | Amount |
QRTypeMaeManee | Ref1, the 15-digit shop ID | Amount |
QRTypeTungngern | Ref1, the merchant code, and MerchantName, the shop name in English | Amount |
Type takes the easyslip.QRType constants in the table. The older easyslip.QRTypeBillPayment ("BILL_PAYMENT") type still works. With the same BillerID it creates the same QR code as easyslip.QRTypePromptPay. 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 CheckDuplicate a slip that was verified before returns a *DuplicateSlipError.
package main
import (
"context"
"errors"
"fmt"
"log"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
slip, err := client.V1.VerifyBankByImage(context.Background(), "slip.jpg", &easyslip.V1VerifyOptions{
CheckDuplicate: easyslip.Some(true),
})
var duplicate *easyslip.DuplicateSlipError
if errors.As(err, &duplicate) {
if first, ok := duplicate.Slip.(*easyslip.BankSlip); ok {
fmt.Println("This slip was already used. Transfer date:", first.Date)
}
return
}
if err != nil {
log.Fatal(err)
}
fmt.Println(slip.TransRef, slip.Amount.Amount)
}API v1 also has VerifyBankByPayload, VerifyBankByURL, VerifyBankByBase64, VerifyTruemoneyByImage and GetMe. Version Comparison lists what changed in v2.
Contexts and goroutines
Every method takes a context.Context. Cancel the context, or give it a deadline, to stop a call and the retries that follow it. A cancelled call returns an error that matches context.Canceled or context.DeadlineExceeded with errors.Is.
One client is safe to use from many goroutines. 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:
package main
import (
"context"
"fmt"
"log"
"sync"
"time"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
files := []string{"slip-1001.jpg", "slip-1002.jpg", "slip-1003.jpg"}
limit := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, file := range files {
wg.Go(func() {
limit <- struct{}{}
defer func() { <-limit }()
result, err := client.Verify.BankByImage(ctx, file, &easyslip.VerifyOptions{CheckDuplicate: easyslip.Some(true)})
if err != nil {
fmt.Println(file, "failed:", err)
return
}
fmt.Println(file, result.RawSlip.TransRef)
})
}
wg.Wait()
}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
Verify.BankBy... returns a *BankSlipResult:
| Field | Type | Meaning | Example |
|---|---|---|---|
AmountInSlip | float64 | The amount transferred, in baht. | 1500 |
IsAmountMatched | Field[bool] | Whether the amount equals MatchAmount. Only with MatchAmount. | true |
AmountInOrder | Field[float64] | The MatchAmount you sent. Only with MatchAmount. | 1500 |
MatchedAccount | Field[*MatchedAccount] | Your registered account that the slip paid, or null. Only with MatchAccount. | BankNumber is "123-4-56789-0" |
IsDuplicate | Field[bool] | Whether the slip was verified before. Only with CheckDuplicate. | false |
Remark | Field[string] | Your Remark. Only when you sent one. | "Order 1001" |
RawSlip.TransRef | string | The bank's reference for the transfer. Each transfer has its own, so you can store it to catch reuse. | "68370160657749I376388B35" |
RawSlip.Date | string | When the transfer happened, in ISO 8601 with the Thai time offset. | "2024-01-15T14:30:00+07:00" |
RawSlip.Amount.Amount | float64 | The amount, in baht. | 1500 |
RawSlip.Fee | Field[float64] | The transfer fee, when the bank reports one. | 0 |
RawSlip.Sender.Bank | Field[SlipBank] | The sender's bank: ID (the bank code), Name and Short. | "004", "กสิกรไทย", "KBANK" |
RawSlip.Sender.Account.Name | AccountName | The sender's name in Thai (Th) and English (En), when the bank shows it. | "นาย ผู้โอน ทดสอบ", "MR. SENDER TEST" |
RawSlip.Sender.Account.Bank | Field[AccountNumber] | The sender's account number (Account) and its Type, partly hidden by the bank. | "BANKAC", "123-4-xxxxx-5" |
RawSlip.Receiver.Bank | Field[SlipReceiverBank] | The receiver's bank, in the same form as the sender's. | "014", "ไทยพาณิชย์", "SCB" |
RawSlip.Receiver.Account.Name | SlipReceiverAccountName | The receiver's name. | "บริษัท ตัวอย่าง จำกัด" |
RawSlip.Receiver.Account.Bank | Field[AccountNumber] | The receiver's account number, partly hidden. | "BANKAC", "xxx-x-x5678-x" |
RawSlip.Receiver.Account.Proxy | Field[AccountNumber] | The PromptPay ID the money was sent to, when the transfer used one. | "MSISDN", "08xxxxxxxx89" |
RawSlip.Ref1, Ref2, Ref3 | Field[string] | Bill payment references. They are empty for ordinary transfers. | "" |
TrueMoney Wallet receipt
Verify.TruewalletBy... returns a *TrueWalletResult. Its top-level fields are the same as for a bank slip, and RawSlip holds:
| Field | Type | Meaning | Example |
|---|---|---|---|
RawSlip.TransactionID | string | The TrueMoney transaction ID. | "50012345678901" |
RawSlip.Date | string | When the payment happened, in ISO 8601. | "2026-10-26T14:30:00+07:00" |
RawSlip.Amount | float64 | The amount, in baht. | 500 |
RawSlip.Sender.Name | string | The payer's name. | "นาย ผู้โอน ทดสอบ" |
RawSlip.Receiver.Name | string | The receiver's name. | "นาย รับเงิน ทดสอบ" |
RawSlip.Receiver.Phone | string | The receiver's phone number, partly hidden. | "08x-xxx-4567" |
Jobs and webhook events
Jobs.Get and WaitForJob return a *Job, and your webhook receives a *WebhookEvent:
| Field | Job | WebhookEvent | Meaning |
|---|---|---|---|
JobID | Yes | Yes | The job ID that SubmitBank or SubmitBankBatch returned. |
BatchID | Yes | Yes | The batch ID, when the slip was part of a batch. |
Status | queued, processing, retrying, done or failed | success, not_found or failed | Where the job is, or how it ended. |
Result.Status | Yes | No | success or not_found, once the job is done. |
Result.Data, Data | Yes | Yes | The same *BankSlipResult as a direct call, when the slip was verified. |
Error.Code, Error.Message | Yes | Yes | Why the check could not finish, when the status is failed. |
Timestamp | No | Yes | When the result was produced. |
Response metadata
Every result also has a Meta field with the HTTP Status, the RequestID, the RateLimit counters and the response Headers. It is not part of the data, so json.Marshal(result) leaves it out. Include Meta.RequestID when you contact support about a call.
Error handling
Errors from the SDK implement the easyslip.EasySlipError interface, and errors.As finds their type. The one exception is a context you cancel: WaitForJob then returns the context's own error, and other calls return a *NetworkError that matches it with errors.Is. Errors that come from the API unwrap to *easyslip.ApiError, which has Status, Code (such as SLIP_NOT_FOUND), Message, RequestID and RetryAfter. Some types unwrap to others: QuotaExceededError to PermissionDeniedError; InvalidImageError, InvalidUrlError and DuplicateSlipError to InvalidRequestError; SlipNotFoundError and SlipPendingError to NotFoundError; ServiceUnavailableError to ServerError; and RequestTimeoutError to NetworkError. Error Codes lists every Code the API returns.
An error code the SDK knows decides the type first. Otherwise the HTTP status in the table decides it, and any other 4xx status gives *ApiError.
| Error type | HTTP status | When it happens | What to do |
|---|---|---|---|
*AuthenticationError | 401 | The 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. |
*PermissionDeniedError | 403 | The 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 Code, then fix that setting in the developer portal. |
*QuotaExceededError | 403 | Your quota is used up. This request was not charged. | Top up or change your plan, then send the request again. |
*InvalidRequestError | 400 | The API rejected a value in the request, such as the QR code text or an option. | Fix the value that Message names. Sending the same request again fails the same way. |
*InvalidImageError | 400 | The 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. |
*InvalidUrlError | 400 | The image URL or CallbackURL points to a private address, does not use http or https, or cannot be reached. | Use a public https URL. |
*DuplicateSlipError | 400 | Only in API v1 with CheckDuplicate: the slip was verified before. Slip holds the first result. | Treat the payment as already received and do not credit it again. |
*NotFoundError | 404 | The bank account, job or other item you asked for does not exist. | Check the ID you passed. |
*SlipNotFoundError | 404 | The bank has no transfer that matches this slip. | Do not accept the payment. Ask the customer for the original slip. |
*SlipPendingError | 404 | The bank has not published the transfer yet. This happens with very recent Bangkok Bank slips. | Verify the slip again after a minute. |
*ConflictError | 409 | The bank account you are adding already exists. | Use the existing account. BankAccounts.IterateAll finds it. |
*RateLimitError | 429 | You sent too many requests. RetryAfter says how many seconds to wait. | Send fewer requests at the same time. The SDK has already retried this one. |
*ServerError | 500, 502, 504; other 5xx except 503 | Something failed on the EasySlip side. | Try again later. If it keeps happening, contact support with RequestID. |
*ServiceUnavailableError | 503 | The service is briefly unavailable, for example while quotas renew. | Try again in a few minutes. |
*ApiError | Another 4xx status | The API answered with an error status that has no type of its own. | Log Status, Code and RequestID, and contact support if it continues. |
*NetworkError | Network | No response arrived, because of a DNS, connection or TLS failure. | Check your network or proxy, then try again. |
*RequestTimeoutError | Network | No response arrived within the timeout. | Try again, or raise the timeout. A verification may already have been counted. |
*InputError | Before sending | The 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. Code names the problem. | Fix the value you passed. |
*WebhookSignatureError | Before sending | A webhook has no signature, or its signature does not match. | Reject the webhook with status 400. |
*JobWaitTimeoutError | Before sending | WaitForJob reached its Timeout. LastJob is the last state it saw. | Wait longer, or rely on the webhook. |
*BatchSubmitError | Before sending | SubmitBankMany stopped part way. Submitted lists the batches that were sent, FailedChunk is the index of the batch that failed, counting from 0, and 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:
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func checkPayment(ctx context.Context, client *easyslip.Client, upload []byte, orderTotal float64) string {
result, err := client.Verify.BankByImage(ctx, upload, &easyslip.VerifyOptions{
MatchAmount: easyslip.Some(orderTotal),
MatchAccount: easyslip.Some(true),
CheckDuplicate: easyslip.Some(true),
})
var (
notFound *easyslip.SlipNotFoundError
pending *easyslip.SlipPendingError
badImage *easyslip.InvalidImageError
quota *easyslip.QuotaExceededError
rateLimited *easyslip.RateLimitError
apiError *easyslip.ApiError
)
switch {
case errors.As(err, ¬Found):
return "We could not find this transfer at the bank."
case errors.As(err, &pending):
return "The bank is still processing this transfer. Try again in a minute."
case errors.As(err, &badImage):
return "Please upload a clear photo or screenshot of the slip."
case errors.As(err, "a), errors.As(err, &rateLimited):
log.Printf("EasySlip limit reached: %v", err)
return "We cannot check slips right now. Please try again shortly."
case errors.As(err, &apiError):
requestID := ""
if apiError.RequestID != nil {
requestID = *apiError.RequestID
}
log.Printf("EasySlip error: %v (request %s)", apiError, requestID)
return "We could not check the slip. Please try again."
case err != nil:
log.Printf("EasySlip unreachable: %v", err)
return "We could not check the slip. Please try again."
}
duplicate, checked := result.IsDuplicate.Value()
amountMatched, _ := result.IsAmountMatched.Value()
account, _ := result.MatchedAccount.Value()
switch {
case !checked || duplicate:
return "This slip has already been used."
case !amountMatched:
return fmt.Sprintf("This slip is for %v baht.", result.AmountInSlip)
case account == nil:
return "This slip was not paid to our account."
}
return "Payment received."
}
func main() {
client, err := easyslip.NewClient("")
if err != nil {
log.Fatal(err)
}
defer client.Close()
upload, err := os.ReadFile("slip.jpg")
if err != nil {
log.Fatal(err)
}
fmt.Println(checkPayment(context.Background(), client, upload, 1500))
}err.Error() never contains your API key, so you can log errors as they are.
What the SDK retries
The SDK retries a failed request up to WithMaxRetries 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 returns the error at once.
| Request | Retried when | Never retried when |
|---|---|---|
Reading data and linking accounts: GetInfo, Banks.List, BankAccounts.List, ListAll, Get and Link, Jobs.Get, V1.GetMe, V1.GenerateQR | The 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 quota | The 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 Unlink | The 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: when the first attempt was counted, the second one returns the saved result with IsDuplicate set to 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.
package main
import (
"io"
"log"
"net/http"
"os"
"time"
easyslip "github.com/EASYSLIP-CO-LTD/sdk-go"
)
func main() {
secret := os.Getenv("EASYSLIP_WEBHOOK_SECRET")
if secret == "" {
log.Fatal("set EASYSLIP_WEBHOOK_SECRET")
}
mux := http.NewServeMux()
mux.HandleFunc("POST /webhooks/easyslip", func(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20)) // the body exactly as it arrived
if err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
event, err := easyslip.ConstructWebhookEvent(rawBody, r.Header.Get(easyslip.WebhookSignatureHeader), secret)
if err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
if event.Status == "success" && event.Data != nil {
log.Printf("Verified %s %s %v", event.JobID, event.Data.RawSlip.TransRef, event.Data.AmountInSlip)
} else {
code := ""
if e, ok := event.Error.Value(); ok {
code = e.Code
}
log.Printf("Not verified %s %s %s", event.JobID, event.Status, code)
}
w.WriteHeader(http.StatusNoContent)
})
server := &http.Server{Addr: ":8000", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(server.ListenAndServe())
}The handler answers 400 when the signature is missing or wrong and when a signed body is not a webhook event. In Gin, Echo or another framework, read the raw body with io.ReadAll before any JSON binding. Decoding the JSON and encoding it again changes the bytes, and the signature then no longer matches.
| Function | What it does |
|---|---|
ConstructWebhookEvent(rawBody, signature, secret) | Checks the signature, then returns the parsed *WebhookEvent. It returns a *WebhookSignatureError when the signature is missing or wrong, and an *InputError when the body is not a JSON object with jobId, status and timestamp strings. |
VerifyWebhookSignature(rawBody, signature, secret) | Returns true when the signature matches and false when it does not. |
ParseWebhookEvent(rawBody) | Parses the event without checking the signature, and returns an *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 ConstructWebhookEvent 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 goroutine fed by a queue.
- 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
JobIDto skip events you have already handled. - If every delivery fails,
Jobs.Getstill 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_KEYor a secret file instead of writing it in your code. - The SDK sends the key only in the
Authorizationheader and removes it from error messages and from the headers it gives you, so you can log its errors.SensitiveRawBody()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 Go releases that the Go project still supports. After a Go release reaches its end of support, a later release of the SDK may stop supporting it, and the changelog says so.
- The changelog and the releases page list every release and its changes.
See also
- The
examplesfolder 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
RequestIDof 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.