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
{
"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"
}ฟิลด์
| ฟิลด์ | ประเภท | คำอธิบาย |
|---|---|---|
jobId | string | UUID ของ Job (ตรงกับ jobId ที่คุณได้ตอนส่งเข้าคิว) |
batchId | string | null | UUID ของ batch หากสลิปเป็นส่วนหนึ่งของ batch; เป็น null หากไม่ใช่ |
status | string | success หากตรวจสอบสลิปได้; not_found หากตรวจสอบไม่ได้ (ดูด้านล่าง) |
data | object | ผลการตรวจสอบ — โครงสร้างเดียวกัน กับ data ของ sync verify ที่สำเร็จ ดู POST /verify/bank |
timestamp | string | เวลา ISO 8601 ที่สร้างผลลัพธ์ |
ค่าของ status
status | ความหมาย | data |
|---|---|---|
success | ตรวจสอบสลิปสำเร็จ | ผลการตรวจสอบเต็มรูปแบบ |
not_found | ตรวจสอบสลิปไม่ได้ — ไม่ถูกต้อง หรือข้อมูลไม่มาแม้ retry จนหมด | ข้อมูลสลิปน้อย/ไม่มี |
Type definition
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 จริง
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
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
$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);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 "", 200Retries
หากปลายทางของคุณตอบสถานะ 5xx หรือ timeout Thunder จะ retry Webhook สองสามครั้งแบบ backoff แล้วเลิก การตอบ 4xx จะถือเป็นการปฏิเสธถาวรและ ไม่ retry
- แม้การส่ง Webhook จะล้มเหลวทั้งหมด ผลลัพธ์ยังดึงได้ผ่าน
GET /verify/bank/jobs/:jobIdนาน ~7 วัน - ทำ handler ให้ idempotent — การ retry อาจส่ง
jobIdเดิมมามากกว่าหนึ่งครั้ง กันซ้ำด้วยjobId - ตอบ
2xxเร็ว ๆ แล้วค่อยประมวลผลหนัก ๆ แบบ asynchronous เพื่อไม่ให้ timeout และกระตุ้น retry ที่ไม่จำเป็น
แนวทางที่แนะนำ
- ตรวจสอบลายเซ็น ทุกคำขอก่อนเชื่อถือ body
- ตอบ 2xx เร็ว ๆ แล้วค่อยประมวลผลนอกรอบ
- กันซ้ำด้วย
jobId— ถือว่าการส่งเป็นแบบ at-least-once - กระทบยอดด้วย polling — หากไม่ได้รับ Webhook ภายในช่วงเวลาที่คาดไว้ ให้เรียก
GET .../jobs/:jobId - เก็บ secret เป็นความลับ — จัดเก็บ Webhook Secret อย่างปลอดภัย อย่าเปิดเผยฝั่ง client
