Webhooks
When you submit a slip for async verification, the result is delivered to your server via a webhook — an HTTP POST to a URL you control. This page covers the payload, how to verify the signature, and delivery/retry behavior.
Configuring the callback URL
The destination URL is resolved in this order:
- The
callbackUrlfield in the async request body, if provided. - Otherwise, the branch's configured webhook URL.
If neither is set, the async request is rejected with VALIDATION_ERROR. The URL must use HTTPS and resolve to a public address (URLs pointing at private/internal IP ranges are rejected).
Each branch may also have a webhook secret. When a secret is configured, every webhook is signed (see Verifying the signature).
Payload
The webhook body is JSON:
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"batchId": null,
"status": "success",
"data": {
"isDuplicate": false,
"rawSlip": {
"transRef": "68370160657749I376388B35",
"amount": { "amount": 1500.00, "local": { "amount": 1500.00, "currency": "THB" } },
"sender": { "bank": { "id": "004", "short": "KBANK" } },
"receiver": { "bank": { "id": "014", "short": "SCB" } }
}
},
"timestamp": "2024-01-15T14:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
jobId | string | The job this result belongs to |
batchId | string | null | The batch this job was part of, or null for a single async submit |
status | string | success (slip verified) or not_found (slip could not be verified) |
data | object | The verification result — same shape as the sync verify data. For not_found, carries the error detail. |
timestamp | string | ISO-8601 time the webhook was generated |
Verifying the signature
When a webhook secret is configured for the branch, the request includes:
X-EasySlip-Signature: sha256=<hex><hex> is the HMAC-SHA256 of the raw request body (the exact bytes received — do not re-serialize), keyed with your webhook secret, hex-encoded. Recompute it and compare using a constant-time comparison. Reject the request if the signatures differ.
import crypto from 'crypto';
// Express example — capture the RAW body: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/easyslip', (req, res) => {
if (!verifyWebhook(req.rawBody, req.get('X-EasySlip-Signature'), process.env.EASYSLIP_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.rawBody.toString('utf8'));
// ... handle event.jobId / event.status / event.data
res.sendStatus(200);
});import crypto from 'crypto';
function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader ?? '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}function verifyWebhook(string $rawBody, ?string $signatureHeader, string $secret): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return is_string($signatureHeader) && hash_equals($expected, $signatureHeader);
}
// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EASYSLIP_SIGNATURE'] ?? null;
if (!verifyWebhook($rawBody, $signature, getenv('EASYSLIP_WEBHOOK_SECRET'))) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return signature_header is not None and hmac.compare_digest(expected, signature_header)
# Flask example
@app.post('/webhooks/easyslip')
def easyslip_webhook():
raw = request.get_data() # raw bytes
if not verify_webhook(raw, request.headers.get('X-EasySlip-Signature'), os.environ['EASYSLIP_WEBHOOK_SECRET']):
return 'invalid signature', 401
event = request.get_json()
# ... handle event
return '', 200Use the raw body
The signature is computed over the exact bytes sent. If you verify against a re-serialized JSON object (reordered keys, changed whitespace) the signature won't match. Always sign the raw request body.
Delivery & retries
- A webhook delivery succeeds on any
2xxresponse. Respond quickly (do heavy work asynchronously) and return2xxas soon as you've accepted the event. - On a non-2xx, timeout, or network error, delivery is retried up to 3 times with backoff at roughly 10s, 30s, and 2 minutes (1 initial attempt + 3 retries).
- Redirects are not followed — point
callbackUrlat the final endpoint. jobIdis stable across retries, so deliveries are idempotent — dedupe onjobIdand treat repeats as at-least-once delivery.- If every attempt fails, the result is still available via GET /verify/bank/jobs/:jobId until the job expires (7 days).
Notes
- Always verify the signature before trusting a webhook (when a secret is configured).
- Return
2xxfast; a slow handler risks a timeout and an unnecessary retry. - Use
jobId(andbatchIdfor batches) to correlate the result with your original request.