Skip to content

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: POST with Content-Type: application/json.
  • Target: the callbackUrl from 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

json
{
  "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

FieldTypeDescription
jobIdstringThe job's UUID (matches the jobId you received when enqueuing)
batchIdstring | nullThe batch UUID if the slip was part of a batch; null otherwise
statusstringsuccess if the slip was verified; not_found if it could not be verified (see below)
dataobjectThe verification result — same shape as the sync verify success data. See POST /verify/bank
timestampstringISO 8601 time the result was produced

status values

statusMeaningdata
successThe slip was verifiedFull verification result
not_foundThe slip could not be verified — invalid, or the data never arrived after retriesMinimal/absent slip data

Type definition

typescript
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.

http
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.

javascript
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
<?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);
python
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 "", 200

Retries

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/:jobId for ~7 days.
  • Make your handler idempotent — a retry can deliver the same jobId more than once. De-duplicate on jobId.
  • Return 2xx quickly; do heavy processing asynchronously so you don't time out and trigger needless retries.

Best Practices

  1. Verify the signature on every request before trusting the body.
  2. Respond 2xx fast, then process out-of-band.
  3. De-duplicate on jobId — treat delivery as at-least-once.
  4. Reconcile via polling — if you don't receive a webhook within your expected window, call GET .../jobs/:jobId.
  5. Keep the secret secret — store your webhook secret securely; never expose it client-side.

Bank Slip Verification API for Thai Banking