Skip to content

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

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 หรือ failed (ดูด้านล่าง)
dataobject | nullผลตรวจแบบ sync เมื่อสำเร็จ; บริบทเมื่อ not_found; null เมื่อ failed
errorobjectมีเฉพาะ failed: code และ message ที่ไม่เปิดเผยข้อมูลภายใน
timestampstringเวลา ISO 8601 ที่สร้างผลลัพธ์

ค่าของ status

statusความหมายdata
successตรวจสอบสลิปสำเร็จผลการตรวจสอบเต็มรูปแบบ
not_foundยังไม่พบสลิปหลัง retry ครบแล้วบริบทการตรวจ ไม่มีสลิปที่ยืนยันแล้ว
failedตรวจต่อไม่ได้เนื่องจาก error หรือข้อจำกัดบัญชี/บริการnull; ดู error

Type definition

typescript
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 } }
);

ตรวจสอบล้มเหลว

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

http
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

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

Retries

การ 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 ที่ไม่จำเป็น

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

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

Bank Slip Verification API for Thai Banking