Webhook Callback
เมื่อ Job การตรวจสอบแบบ async เสร็จสิ้น EasySlip จะส่งผลลัพธ์ไปยัง callbackUrl ของคุณเป็น HTTP POST หน้านี้อธิบาย payload ของ callback, ลายเซ็นที่คุณต้องตรวจสอบ และพฤติกรรมการ retry
การตรวจสอบแบบ async รองรับ สลิปธนาคารเท่านั้น เมื่อ success ฟิลด์ data ตรงกับ Response ของ POST /verify/bank แบบ sync เมื่อ failed จะมี data: null และ error อธิบายสาเหตุ
การส่ง (Delivery)
- 1 ผลลัพธ์ต่อ 1 สลิป แต่ละสลิป รวมถึงใน batch มี callback ของตัวเอง การ retry ส่งอาจทำให้ได้รับ callback เดิมซ้ำ
- Method:
POSTพร้อมContent-Type: application/jsonและUser-Agent: EasySlip-Webhook/2.0 - ไม่ตาม redirect และใช้ timeout เริ่มต้น 5 วินาทีต่อการส่ง ควรตอบกลับให้เร็ว
- ปลายทาง:
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 หรือ failed (ดูด้านล่าง) |
data | object | null | ผลตรวจแบบ sync เมื่อสำเร็จ; บริบทเมื่อ not_found; null เมื่อ failed |
error | object | มีเฉพาะ failed: code และ message ที่ไม่เปิดเผยข้อมูลภายใน |
timestamp | string | เวลา ISO 8601 ที่สร้างผลลัพธ์ |
ค่าของ status
status | ความหมาย | data |
|---|---|---|
success | ตรวจสอบสลิปสำเร็จ | ผลการตรวจสอบเต็มรูปแบบ |
not_found | ยังไม่พบสลิปหลัง retry ครบแล้ว | บริบทการตรวจ ไม่มีสลิปที่ยืนยันแล้ว |
failed | ตรวจต่อไม่ได้เนื่องจาก error หรือข้อจำกัดบัญชี/บริการ | null; ดู error |
Type definition
type WebhookPayload = {
jobId: string;
batchId: string | null;
timestamp: string; // ISO 8601
} & (
| { status: 'success'; data: VerifyBankData }
| { status: 'not_found'; data: unknown }
| { status: 'failed'; data: null; error: { code: string; message: string } }
);ตรวจสอบล้มเหลว
{
"jobId": "3f2b1c8a-9d4e-4f10-b7a2-6c5d4e3f2a1b",
"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"
}ตัวอย่าง code ได้แก่ QUOTA_EXCEEDED, SERVICE_EXPIRED, BRANCH_INACTIVE, VALIDATION_ERROR, API_SERVER_ERROR และ RENEWAL_TEMPORARILY_UNAVAILABLE ส่วน error ที่ไม่รู้จักใช้ INTERNAL_SERVER_ERROR โดยไม่เปิดเผย exception ภายใน กรณี billing จะส่ง failed หลัง retry ของ billing ครบแล้วเท่านั้น ไม่ส่งระหว่าง retry
ส่ง failure callback สำเร็จแล้ว Job ยังเป็น failed ไม่เปลี่ยนเป็น done ถ้าส่ง callback ไม่สำเร็จจนหมดรอบ polling ยังคง error.code/message ต้นเหตุไว้ และเพิ่ม error.webhook: "failed" พร้อมรายละเอียดการส่ง HTTP error ที่ปฏิเสธคำขอ ก่อนรับเข้าคิว จะไม่มี callback
การตรวจสอบลายเซ็น (Signature Verification)
Header ลายเซ็นจะมี เมื่อ Branch ตั้ง Webhook Secret แล้วเท่านั้น ถ้าไม่ได้ตั้ง callback จะไม่มีลายเซ็น ควรตั้ง Secret ก่อนใช้ production แล้วปฏิเสธคำขอที่ไม่มีลายเซ็นหรือตรวจไม่ผ่าน
EASYSLIP_WEBHOOK_SECRET ด้านล่างคือ environment variable ใน ระบบรับ callback ของคุณ ที่เก็บ Secret ของ Branch ไม่ใช่ env ใหม่ส่วนกลางของ EasySlip API หรือ worker
X-EasySlip-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.EASYSLIP_WEBHOOK_SECRET;
const app = express();
// จับ RAW body ไว้เพื่อตรวจสอบลายเซ็น
app.post('/webhooks/easyslip',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-EasySlip-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body เป็น Buffer (raw bytes)
.digest('hex');
const ok =
/^sha256=[0-9a-f]{64}$/.test(header) &&
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('EASYSLIP_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_EASYSLIP_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["EASYSLIP_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/easyslip")
def easyslip_webhook():
raw = request.get_data() # raw bytes
header = request.headers.get("X-EasySlip-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
การ retry ตรวจสลิปและการส่ง callback แยกงบกัน:
ตรวจสลิป:
not_foundทุกธนาคารและ BBL pending ใช้งบร่วมกัน ครั้งแรก + retry 4 ครั้ง เว้น 30 / 60 / 120 / 240 วินาที พบแล้วจบทันที ครบแล้วยังไม่พบส่งnot_foundไม่ใช่failedเวลารอตามตารางรวม 7 นาที 30 วินาที ไม่รวมประมวลผล รอคิวและการเลื่อนอื่น ๆ จึงไม่ใช่เวลาจบงานสูงสุดส่ง callback: ทั้ง
success,not_foundและfailedส่งครั้งแรก + retry 3 ครั้ง เว้น 10 / 30 / 120 วินาที ใช้กับทุกสถานะที่ไม่ใช่ 2xx รวม 4xx และ timeout/network errorแม้การส่ง Webhook จะล้มเหลวทั้งหมด ผลลัพธ์ยังดึงได้ผ่าน
GET /verify/bank/jobs/:jobIdนาน ~7 วันทำ handler ให้ idempotent — การ retry อาจส่ง
jobIdเดิมมามากกว่าหนึ่งครั้ง กันซ้ำด้วยjobIdตอบ
2xxเร็ว ๆ แล้วค่อยประมวลผลหนัก ๆ แบบ asynchronous เพื่อไม่ให้ timeout และกระตุ้น retry ที่ไม่จำเป็น
แนวทางที่แนะนำ
- ตรวจสอบลายเซ็น ทุกคำขอก่อนเชื่อถือ body
- ตอบ 2xx เร็ว ๆ แล้วค่อยประมวลผลนอกรอบ
- กันซ้ำด้วย
jobId— รองรับการส่งซ้ำ และไม่รับประกันว่าจะส่งถึงหาก retry ครบแล้วยังล้มเหลว - กระทบยอดด้วย polling — หากไม่ได้รับ Webhook ภายในช่วงเวลาที่คาดไว้ ให้เรียก
GET .../jobs/:jobId - เก็บ secret เป็นความลับ — จัดเก็บ Webhook Secret อย่างปลอดภัย อย่าเปิดเผยฝั่ง client