Webhook Callback
When an async verification job finishes, Thunder 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. The data field matches the synchronous POST /verify/bank success response.
Delivery
- One POST per slip. Each enqueued slip — including every slip inside a batch — produces exactly one webhook.
- Method:
POSTwithContent-Type: application/json. - 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 if the slip was verified; not_found if it could not be verified (see below) |
data | object | The verification result — same shape as the sync verify success data. See POST /verify/bank |
timestamp | string | ISO 8601 time the result was produced |
status values
status | Meaning | data |
|---|---|---|
success | The slip was verified | Full verification result |
not_found | The slip could not be verified — invalid, or the data never arrived after retries | Minimal/absent slip data |
Type definition
interface WebhookPayload {
jobId: string;
batchId: string | null;
status: 'success' | 'not_found';
data: VerifyBankData; // same as the sync verify success `data`
timestamp: string; // ISO 8601
}Signature Verification
Every webhook includes a signature header. You must verify it to confirm the callback genuinely came from Thunder.
X-Thunder-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.THUNDER_WEBHOOK_SECRET;
const app = express();
// Capture the RAW body for signature verification
app.post('/webhooks/thunder',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Thunder-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer (raw bytes)
.digest('hex');
const ok =
header.length === expected.length &&
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('THUNDER_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_THUNDER_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["THUNDER_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/thunder")
def thunder_webhook():
raw = request.get_data() # raw bytes
header = request.headers.get("X-Thunder-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
If your endpoint returns a 5xx status or times out, Thunder retries the webhook a few times with backoff, then gives up. A 4xx response is treated as a permanent rejection and is not retried.
- 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— treat delivery as at-least-once. - 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.
