Webhooks
เมื่อคุณส่งสลิปเข้าตรวจสอบแบบ async ผลลัพธ์จะถูกส่งมาที่ server ของคุณผ่าน webhook — HTTP POST ไปยัง URL ที่คุณควบคุม หน้านี้อธิบาย payload, วิธี verify signature และพฤติกรรมการส่ง/retry
การตั้งค่า callback URL
ปลายทางถูก resolve ตามลำดับนี้:
- field
callbackUrlใน request body ของ async ถ้ามี - ถ้าไม่มี ใช้ webhook URL ที่ตั้งไว้ที่ branch
ถ้าไม่มีทั้งคู่ request แบบ async จะถูกปฏิเสธด้วย VALIDATION_ERROR URL ต้องเป็น HTTPS และ resolve ไปยัง address สาธารณะ (URL ที่ชี้ไป IP ภายใน/ส่วนตัวจะถูกปฏิเสธ)
แต่ละ branch อาจมี webhook secret ด้วย เมื่อมี secret ทุก webhook จะถูก sign (ดูการ verify signature)
Payload
body ของ webhook เป็น JSON:
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"batchId": null,
"status": "success",
"data": {
"isDuplicate": false,
"rawSlip": {
"transRef": "68370160657749I376388B35",
"amount": { "amount": 1500.00, "local": { "amount": 1500.00, "currency": "THB" } },
"sender": { "bank": { "id": "004", "short": "KBANK" } },
"receiver": { "bank": { "id": "014", "short": "SCB" } }
}
},
"timestamp": "2024-01-15T14:30:00.000Z"
}| Field | ประเภท | คำอธิบาย |
|---|---|---|
jobId | string | job ที่ผลลัพธ์นี้เป็นของ |
batchId | string | null | batch ที่ job นี้อยู่ หรือ null เมื่อเป็น async แบบเดี่ยว |
status | string | success, not_found หรือ failed |
data | object | null | ผลสำเร็จแบบ sync, บริบทเมื่อ not_found หรือ null เมื่อ failed |
error | object | มีเฉพาะ failed: code และ message ที่ปลอดภัย |
timestamp | string | เวลา ISO-8601 ที่สร้าง webhook |
ตรวจสอบล้มเหลว
{
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"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"
}งานที่รับเข้าคิวแล้วแต่ตรวจต่อไม่ได้จะส่ง failed เช่น ข้อจำกัดบริการ, QUOTA_EXCEEDED, VALIDATION_ERROR และ API_SERVER_ERROR ส่วน error ที่ไม่รู้จักใช้ INTERNAL_SERVER_ERROR โดยไม่เปิดเผย exception ภายใน คำขอที่ถูกปฏิเสธก่อนรับเข้าคิวจะไม่มี callback
failed callback ใช้ signature และ retry ส่งแบบเดียวกับผลอื่น ส่งสำเร็จแล้ว polling ยังเป็น failed ถ้าส่งไม่สำเร็จจนครบรอบจะคง error.code/message ต้นเหตุไว้ และเพิ่ม error.webhook: "failed" พร้อมรายละเอียดการส่ง
การ verify signature
เมื่อ branch มี webhook secret request จะมี header:
X-EasySlip-Signature: sha256=<hex><hex> คือ HMAC-SHA256 ของ raw request body (bytes ที่ได้รับจริง — อย่า re-serialize) โดยใช้ webhook secret เป็น key แล้ว encode เป็น hex ให้คำนวณใหม่แล้วเทียบ ด้วยการเปรียบเทียบแบบ constant-time ถ้า signature ไม่ตรงให้ปฏิเสธ request
import crypto from 'crypto';
// ตัวอย่าง Express — เก็บ RAW body: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader || '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/easyslip', (req, res) => {
if (!verifyWebhook(req.rawBody, req.get('X-EasySlip-Signature'), process.env.EASYSLIP_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.rawBody.toString('utf8'));
// ... จัดการ event.jobId / event.status / event.data
res.sendStatus(200);
});import crypto from 'crypto';
function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined, secret: string): boolean {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader ?? '', 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}function verifyWebhook(string $rawBody, ?string $signatureHeader, string $secret): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return is_string($signatureHeader) && hash_equals($expected, $signatureHeader);
}
// การใช้งาน
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EASYSLIP_SIGNATURE'] ?? null;
if (!verifyWebhook($rawBody, $signature, getenv('EASYSLIP_WEBHOOK_SECRET'))) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return signature_header is not None and hmac.compare_digest(expected, signature_header)
# ตัวอย่าง Flask
@app.post('/webhooks/easyslip')
def easyslip_webhook():
raw = request.get_data() # raw bytes
if not verify_webhook(raw, request.headers.get('X-EasySlip-Signature'), os.environ['EASYSLIP_WEBHOOK_SECRET']):
return 'invalid signature', 401
event = request.get_json()
# ... จัดการ event
return '', 200ใช้ raw body
signature คำนวณจาก bytes ที่ส่งจริง ถ้าคุณ verify กับ JSON object ที่ serialize ใหม่ (สลับลำดับ key, เปลี่ยน whitespace) signature จะไม่ตรง ให้ sign จาก raw request body เสมอ
การส่ง & retry
- การส่ง webhook สำเร็จเมื่อได้ response
2xxให้ตอบเร็ว (งานหนักทำแบบ async) และคืน2xxทันทีที่รับ event แล้ว - เมื่อได้ non-2xx, timeout หรือ network error จะ retry สูงสุด 3 ครั้ง ด้วย backoff ประมาณ 10 วิ, 30 วิ, 2 นาที (1 ครั้งแรก + 3 retry)
- ไม่ตาม redirect — ให้ชี้
callbackUrlไปที่ปลายทางสุดท้าย jobIdคงที่ทุก retry การส่งจึงเป็น idempotent — ให้ dedupe ด้วยjobIdและถือเป็น at-least-once- ถ้าทุกครั้งล้มเหลว ผลลัพธ์ยังดึงได้ผ่าน GET /verify/bank/jobs/:jobId จนกว่า job จะหมดอายุ (7 วัน)
หมายเหตุ
- verify signature ก่อนเชื่อ webhook เสมอ (เมื่อมี secret)
- คืน
2xxเร็ว ๆ handler ที่ช้าเสี่ยง timeout และเกิด retry โดยไม่จำเป็น - ใช้
jobId(และbatchIdสำหรับ batch) จับคู่ผลลัพธ์กับ request เดิม