Webhook Callback
When an async verification job finishes, EasySlip sends the result to your callbackUrl as an HTTP POST. This page describes the callback payload, the signature you must verify, and the retry behaviour.
Async verification is available for bank slips only. On success, data matches the synchronous POST /verify/bank success response. On failed, data is null and error explains why verification could not complete.
Delivery
- One result callback per slip. Each enqueued slip — including every slip inside a batch — has its own callback; delivery retries may repeat it.
- Method:
POSTwithContent-Type: application/jsonandUser-Agent: EasySlip-Webhook/2.0. - Redirects are not followed. Delivery has a 5-second timeout by default; respond promptly.
- Target: the
callbackUrlfrom the request, or the branch's configured default webhook URL if none was provided. - Your endpoint should respond with any 2xx status. Non-2xx responses (or timeouts) trigger retries.
Request Body
{
"jobId": "3f2b1c8a-9d4e-4f10-b7a2-6c5d4e3f2a1b",
"batchId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"status": "success",
"data": {
"remark": "Order #1001",
"isDuplicate": false,
"amountInSlip": 1500.00,
"isAmountMatched": true,
"rawSlip": {
"payload": "00000000000000000000000000000000000000",
"transRef": "68370160657749I376388B35",
"date": "2024-01-15T14:30:00+07:00",
"countryCode": "TH",
"amount": {
"amount": 1500.00,
"local": { "amount": 1500.00, "currency": "THB" }
},
"fee": 0,
"ref1": "",
"ref2": "",
"ref3": "",
"sender": {
"bank": { "id": "004", "name": "กสิกรไทย", "short": "KBANK" },
"account": {
"name": { "th": "นาย ผู้โอน ทดสอบ", "en": "MR. SENDER TEST" },
"bank": { "type": "BANKAC", "account": "123-4-xxxxx-5" }
}
},
"receiver": {
"bank": { "id": "014", "name": "ไทยพาณิชย์", "short": "SCB" },
"account": {
"name": { "th": "บริษัท ตัวอย่าง จำกัด" },
"bank": { "type": "BANKAC", "account": "xxx-x-x5678-x" }
},
"merchantId": null
}
}
},
"timestamp": "2024-01-15T14:32:05+07:00"
}Fields
| Field | Type | Description |
|---|---|---|
jobId | string | The job's UUID (matches the jobId you received when enqueuing) |
batchId | string | null | The batch UUID if the slip was part of a batch; null otherwise |
status | string | success, not_found, or failed (see below) |
data | object | null | Sync-shaped verification data on success; failure context on not_found; null on failed |
error | object | Present only on failed: safe code and message |
timestamp | string | ISO 8601 time the result was produced |
status values
status | Meaning | data |
|---|---|---|
success | The slip was verified | Full verification result |
not_found | Still not found after verification retries | Failure context, no verified slip |
failed | Verification could not complete because of an error or policy restriction | null; see error |
Type definition
type WebhookPayload = {
jobId: string;
batchId: string | null;
timestamp: string; // ISO 8601
} & (
| { status: 'success'; data: VerifyBankData }
| { status: 'not_found'; data: unknown }
| { status: 'failed'; data: null; error: { code: string; message: string } }
);Failed verification
{
"jobId": "3f2b1c8a-9d4e-4f10-b7a2-6c5d4e3f2a1b",
"batchId": null,
"status": "failed",
"data": null,
"error": {
"code": "API_SERVER_ERROR",
"message": "External API service is temporarily unavailable"
},
"timestamp": "2026-09-11T00:00:00.000Z"
}Examples include QUOTA_EXCEEDED, SERVICE_EXPIRED, BRANCH_INACTIVE, VALIDATION_ERROR, API_SERVER_ERROR and RENEWAL_TEMPORARILY_UNAVAILABLE. Unknown errors use INTERNAL_SERVER_ERROR without internal exception details. Billing outages send failed only after their separate retry budget is exhausted, never during an intermediate retry.
Successful delivery of a failure callback leaves the polled job failed, not done. If delivery retries are also exhausted, polling preserves the original error.code/message and adds error.webhook: "failed" and delivery details. HTTP errors rejecting a request before acceptance do not create callbacks.
Signature Verification
The signature header is included only when a webhook secret is configured for the branch. Without a secret, callbacks are unsigned. Configure a secret before accepting production callbacks, then reject missing or invalid signatures.
EASYSLIP_WEBHOOK_SECRET below is an environment variable in your receiver application holding that branch secret, not a new global setting for the EasySlip API or worker.
X-EasySlip-Signature: sha256=<hmac>The signature is the HMAC-SHA256 of the raw JSON request body, keyed with your branch's webhook secret, hex-encoded.
To verify: compute the HMAC-SHA256 of the received raw body (the exact bytes, before any JSON parsing/re-serialization) using your secret, then compare it — using a constant-time comparison — against the hex value in the header.
Use the raw body
Compute the HMAC over the raw request body bytes, not a re-serialized object. Re-encoding JSON can change whitespace/key order and break the signature. Capture the raw body before parsing.
import express from 'express';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.EASYSLIP_WEBHOOK_SECRET;
const app = express();
// Capture the RAW body for signature verification
app.post('/webhooks/easyslip',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-EasySlip-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer (raw bytes)
.digest('hex');
const ok =
/^sha256=[0-9a-f]{64}$/.test(header) &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event.jobId / event.status / event.data ...
res.sendStatus(200);
});<?php
$secret = getenv('EASYSLIP_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_EASYSLIP_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($raw, true);
// ... handle $event['jobId'] / $event['status'] / $event['data'] ...
http_response_code(200);import hmac, hashlib, os
from flask import Flask, request, abort
WEBHOOK_SECRET = os.environ["EASYSLIP_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/easyslip")
def easyslip_webhook():
raw = request.get_data() # raw bytes
header = request.headers.get("X-EasySlip-Signature", "")
expected = "sha256=" + hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header):
abort(401)
event = request.get_json()
# ... handle event["jobId"] / event["status"] / event["data"] ...
return "", 200Retries
Verification and callback delivery have separate retry budgets:
Verification: any bank's
not_foundand BBL pending share one initial attempt plus four retries after 30 / 60 / 120 / 240 seconds. Success finishes immediately; exhausted attempts producenot_found, notfailed. Total scheduled delay is 7m30s, excluding processing, queue wait and other deferrals; it is not a hard deadline.Callback delivery:
success,not_foundandfailedall use one initial delivery plus three retries after 10 / 30 / 120 seconds. All non-2xx responses, including 4xx, and timeout/network failures use this budget.Even if all webhook deliveries fail, the result is still retrievable via
GET /verify/bank/jobs/:jobIdfor ~7 days.Make your handler idempotent — a retry can deliver the same
jobIdmore than once. De-duplicate onjobId.Return
2xxquickly; do heavy processing asynchronously so you don't time out and trigger needless retries.
Best Practices
- Verify the signature on every request before trusting the body.
- Respond 2xx fast, then process out-of-band.
- De-duplicate on
jobId— allow repeated delivery; delivery is not guaranteed after retries are exhausted. - Reconcile via polling — if you don't receive a webhook within your expected window, call
GET .../jobs/:jobId. - Keep the secret secret — store your webhook secret securely; never expose it client-side.