Skip to content

Webhook Callback

เมื่อ Job การตรวจสอบแบบ async เสร็จสิ้น Thunder จะส่งผลลัพธ์ไปยัง callbackUrl ของคุณเป็น HTTP POST หน้านี้อธิบาย payload ของ callback, ลายเซ็นที่คุณต้องตรวจสอบ และพฤติกรรมการ retry

การตรวจสอบแบบ async รองรับ สลิปธนาคารเท่านั้น ฟิลด์ data ตรงกับ Response ของ POST /verify/bank แบบซิงโครนัสที่สำเร็จ

การส่ง (Delivery)

  • 1 POST ต่อ 1 สลิป แต่ละสลิปที่เข้าคิว — รวมถึงทุกสลิปใน batch — จะสร้าง Webhook หนึ่งครั้งเท่านั้น
  • Method: POST พร้อม Content-Type: application/json
  • ปลายทาง: callbackUrl จากคำขอ หรือ Default Webhook URL ของ branch หากไม่ได้ระบุมา
  • ปลายทางของคุณควรตอบด้วยสถานะ 2xx ใดก็ได้ การตอบที่ไม่ใช่ 2xx (หรือ timeout) จะทำให้เกิดการ retry

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"
}

ฟิลด์

ฟิลด์ประเภทคำอธิบาย
jobIdstringUUID ของ Job (ตรงกับ jobId ที่คุณได้ตอนส่งเข้าคิว)
batchIdstring | nullUUID ของ batch หากสลิปเป็นส่วนหนึ่งของ batch; เป็น null หากไม่ใช่
statusstringsuccess หากตรวจสอบสลิปได้; not_found หากตรวจสอบไม่ได้ (ดูด้านล่าง)
dataobjectผลการตรวจสอบ — โครงสร้างเดียวกัน กับ data ของ sync verify ที่สำเร็จ ดู POST /verify/bank
timestampstringเวลา ISO 8601 ที่สร้างผลลัพธ์

ค่าของ status

statusความหมายdata
successตรวจสอบสลิปสำเร็จผลการตรวจสอบเต็มรูปแบบ
not_foundตรวจสอบสลิปไม่ได้ — ไม่ถูกต้อง หรือข้อมูลไม่มาแม้ retry จนหมดข้อมูลสลิปน้อย/ไม่มี

Type definition

typescript
interface WebhookPayload {
  jobId: string;
  batchId: string | null;
  status: 'success' | 'not_found';
  data: VerifyBankData;                         // เหมือน data ของ sync verify ที่สำเร็จ
  timestamp: string;                            // ISO 8601
}

การตรวจสอบลายเซ็น (Signature Verification)

ทุก Webhook จะมี Header ลายเซ็นแนบมา คุณต้องตรวจสอบมัน เพื่อยืนยันว่า callback มาจาก Thunder จริง

http
X-Thunder-Signature: sha256=<hmac>

ลายเซ็นคือ HMAC-SHA256 ของ raw JSON request body โดยใช้ Webhook Secret ของ Branch เป็นกุญแจ เข้ารหัสเป็น hex

วิธีตรวจสอบ: คำนวณ HMAC-SHA256 ของ raw body ที่ได้รับ (byte ตามจริง ก่อนการ parse/serialize JSON ใหม่) ด้วย secret ของคุณ แล้วเปรียบเทียบ — โดยใช้การเปรียบเทียบแบบ constant-time — กับค่า hex ใน Header

ใช้ raw body

คำนวณ HMAC จาก byte ของ raw request body ไม่ใช่ออบเจกต์ที่ serialize ใหม่ การเข้ารหัส JSON ใหม่อาจเปลี่ยน whitespace/ลำดับ key และทำให้ลายเซ็นผิด จับ raw body ไว้ก่อน parse

javascript
import express from 'express';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.THUNDER_WEBHOOK_SECRET;
const app = express();

// จับ RAW body ไว้เพื่อตรวจสอบลายเซ็น
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 เป็น 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'));
    // ... จัดการ 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);
// ... จัดการ $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()
    # ... จัดการ event["jobId"] / event["status"] / event["data"] ...

    return "", 200

Retries

หากปลายทางของคุณตอบสถานะ 5xx หรือ timeout Thunder จะ retry Webhook สองสามครั้งแบบ backoff แล้วเลิก การตอบ 4xx จะถือเป็นการปฏิเสธถาวรและ ไม่ retry

  • แม้การส่ง Webhook จะล้มเหลวทั้งหมด ผลลัพธ์ยังดึงได้ผ่าน GET /verify/bank/jobs/:jobId นาน ~7 วัน
  • ทำ handler ให้ idempotent — การ retry อาจส่ง jobId เดิมมามากกว่าหนึ่งครั้ง กันซ้ำด้วย jobId
  • ตอบ 2xx เร็ว ๆ แล้วค่อยประมวลผลหนัก ๆ แบบ asynchronous เพื่อไม่ให้ timeout และกระตุ้น retry ที่ไม่จำเป็น

แนวทางที่แนะนำ

  1. ตรวจสอบลายเซ็น ทุกคำขอก่อนเชื่อถือ body
  2. ตอบ 2xx เร็ว ๆ แล้วค่อยประมวลผลนอกรอบ
  3. กันซ้ำด้วย jobId — ถือว่าการส่งเป็นแบบ at-least-once
  4. กระทบยอดด้วย polling — หากไม่ได้รับ Webhook ภายในช่วงเวลาที่คาดไว้ ให้เรียก GET .../jobs/:jobId
  5. เก็บ secret เป็นความลับ — จัดเก็บ Webhook Secret อย่างปลอดภัย อย่าเปิดเผยฝั่ง client

Bank Slip Verification API for Thai Banking