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, not_found, or failed |
data | object | null | Sync-shaped success data, context on not_found, or null on failed |
error | object | Only on failed: safe code and message |
timestamp | string | ISO-8601 time the webhook was generated |
Failed verification
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"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"
}An accepted job that cannot complete verification sends failed, including policy restrictions, QUOTA_EXCEEDED, VALIDATION_ERROR and API_SERVER_ERROR. Unexpected errors use INTERNAL_SERVER_ERROR without raw exception details. Errors rejecting the request before acceptance do not create callbacks.
Failed callbacks use the same signature and delivery retries. Successful delivery leaves the polled job failed. If delivery also exhausts its retries, polling preserves error.code/message and adds error.webhook: "failed" plus delivery details.
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.