Webhook (Hook URL)

ให้ระบบ ยิง HTTP POST ไปยัง URL ของคุณทุกครั้งที่เกิดเหตุการณ์ที่บูธ (อัดคลิป / อัปโหลด / บูธหลุด / สแกนซ้ำ) — แต่ละคำขอจะ เซ็นด้วย HMAC-SHA256 และ retry อัตโนมัติ ถ้าปลายทางตอบไม่สำเร็จ

1. การตั้งค่า

ไปที่หน้า /me/hooks แล้วกรอกข้อมูล (สร้างได้ 1 อันต่อบัญชี):

ช่องคำอธิบาย
URL ปลายทางต้องเป็น https เท่านั้น และต้องเป็นที่อยู่ที่เซิร์ฟเวอร์เข้าถึงได้จากภายนอก (ห้ามเป็น IP ภายใน เช่น 127.0.0.1, 192.168.x.x, 10.x.x.x)
บูธเลือกว่าจะรับเฉพาะบูธใดบูธหนึ่ง หรือ ทุกบูธ ของคุณ
Eventติ๊กเลือกเหตุการณ์ที่ต้องการรับ (ดูรายการในข้อ 6)
Secretระบบสร้างให้อัตโนมัติ และ แสดงครั้งเดียว ตอนสร้าง/กดสร้างใหม่ — เก็บไว้ใช้ตรวจลายเซ็น (ข้อ 5)

หมายเหตุ: ถ้าต้องการเปลี่ยน scope บูธหลังสร้างแล้ว แก้ได้ที่การ์ดของ webhook นั้น หรือจะลบแล้วสร้างใหม่ก็ได้

2. การ Verify

หลังสร้าง webhook ระบบจะ ปิดไว้ก่อน เพื่อความปลอดภัย เมื่อกดปุ่ม Verify เซิร์ฟเวอร์จะส่งคำขอทดสอบ (event ชื่อ webhook.verify) แบบเดียวกับ event จริงทุกประการ (มีลายเซ็นครบ) ไปยัง URL ของคุณ

  • ปลายทางตอบ HTTP 2xx (เช่น 200) → ระบบ เปิดใช้งานให้อัตโนมัติ
  • ตอบรหัสอื่น / ต่อไม่ติด / DNS ไม่ได้ → คงปิดไว้ พร้อมแจ้งเหตุผล

การ Verify ยิงจาก เซิร์ฟเวอร์ → ปลายทาง ดังนั้น URL ต้องเปิดรับจากอินเทอร์เน็ตจริง (endpoint หลัง firewall ที่เซิร์ฟเวอร์เข้าไม่ถึงจะ verify ไม่ผ่าน)

3. การเปิด / ปิด

  • เริ่มต้น = ปิด — ตอนสร้างใหม่จะยังไม่ส่ง event ใด ๆ
  • เปิด — ทำได้โดยกด Verify แล้วปลายทางตอบ 2xx
  • ปิด — กดปุ่ม “ปิด” ได้ตลอด; ระหว่างปิดจะไม่มี event ถูกส่งออก

event จะถูกส่งก็ต่อเมื่อ webhook เปิดอยู่ และ event นั้นตรงกับที่เลือกไว้ (และตรง booth scope)

ถ้ากดปิดตอนมีงานค้างอยู่ในคิว

งานที่รอส่ง/รอ retry อยู่ จะไม่ถูกลบ (ยังเห็นใน log) แต่ จะไม่ถูกส่งต่อ — เมื่อระบบหยิบงานที่ถึงกำหนดขึ้นมาแล้วเจอว่า webhook ปิดอยู่ จะ ยกเลิกงานนั้นเป็นสถานะ Dead (ไม่ส่ง และไม่พักไว้รอเปิดใหม่)
สถานการณ์ผลลัพธ์
งานที่ถึงกำหนดแล้ว (รอยิงรอบนี้)รอบถัดไป (~20 วินาที) ระบบเจอว่าปิด → Dead (ไม่ส่ง)
งานที่ยังไม่ถึงกำหนด retry (เช่นรอ 12 ชม.)ค้างในคิวจนถึงเวลานั้น แล้วค่อยถูกตัดเป็น Dead
เปิดใหม่ก่อนระบบหยิบงานนั้นส่งตามปกติ (ไม่โดน Dead)
โดน Dead ไปแล้วต้องกดปุ่ม “ส่งใหม่” ใน /me/hooks เพื่อ queue ใหม่เอง

4. รูปแบบคำขอ + Header

ทุก event ส่งเป็น POST พร้อม body เป็น JSON (UTF-8) และ header ดังนี้:

Headerตัวอย่าง / ความหมาย
Content-Typeapplication/json
X-ECB-Eventชื่อ event เช่น video.uploaded
X-ECB-Deliveryเลขที่การส่ง (สำหรับ idempotency / ดู log)
X-ECB-Signaturet=<unix>,v1=<hex> — ลายเซ็น HMAC (ดูข้อ 5)

โครงสร้าง body (เหมือนกันทุก event — ต่างกันที่ data):

{
  "event": "video.uploaded",
  "occurredAt": "2026-06-11T09:15:30.1234567+07:00",
  "booth": {
    "guid": "f7364bed-40c7-42a7-a302-39e64cb8def1",
    "nickname": "เคาน์เตอร์แพ็คหน้าร้าน"
  },
  "data": { ... }
}

โปรดเขียนฝั่งรับให้ ยอมรับ key เพิ่มเติม ได้ (อนาคตอาจมีฟิลด์เพิ่ม) และอย่ายึดลำดับ key

5. การตรวจลายเซ็น (HMAC)

header X-ECB-Signature มีรูปแบบ t=<unix>,v1=<hex> โดย v1 = HMAC-SHA256(secret, "<t>.<raw body>") เป็น hex ตัวพิมพ์เล็ก

ขั้นตอนตรวจฝั่งคุณ:

  1. อ่าน raw body ดิบ ๆ (อย่า parse แล้ว serialize ใหม่ ไม่งั้นลายเซ็นจะไม่ตรง)
  2. แยก t และ v1 จาก header
  3. คำนวณ HMAC-SHA256(secret, t + "." + rawBody) เทียบกับ v1 แบบ constant-time
  4. (ทางเลือก) ปฏิเสธถ้า t เก่ากว่า ~5 นาที เพื่อกัน replay

ตัวอย่าง (Node.js):

const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(kv => kv.split('=')));   // { t, v1 }
  const expected = crypto.createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(parts.v1), Buffer.from(expected));
}

6. ข้อมูลของแต่ละ Event

ทุก event มี envelope เหมือนข้อ 4 ส่วน booth จะระบุบูธที่เกี่ยวข้องเสมอ ด้านล่างคือ data ของแต่ละ event:

ฟิลด์วันเวลาทุกตัว (occurredAt, recordedAt, uploadedAt, lastHeartBeat, offlineSince) ใช้รูปแบบ ISO 8601 พร้อม timezone offset เหมือนกันหมด เช่น 2026-06-11T09:15:30.1234567+07:00 — อ่านค่าได้ตรงโดยไม่ต้องเดา time zone

video.created

บูธอัปโหลด metadata ของคลิปใหม่เข้าระบบแล้ว (ยังไม่ได้อัปไฟล์ขึ้น CDN)

"data": {
  "videoId": 987,
  "sizeInMb": 42.7,
  "weight": 1.23,
  "barcode": "TH1234567890",
  "packedByEmployeeCode": "A01",
  "recordedAt": "2026-06-11T09:14:55.4820000+07:00"
}

video.uploaded

ไฟล์คลิปถูกอัปขึ้น CDN เรียบร้อย เปิดดูได้ที่ source

"data": {
  "videoId": 987,
  "source": "https://static.e2app.co/987.mp4",
  "sizeInMb": 42.7,
  "weight": 1.23,
  "barcode": "TH1234567890",
  "packedByEmployeeCode": "A01",
  "recordedAt": "2026-06-11T09:14:55.4820000+07:00",
  "uploadedAt": "2026-06-11T09:15:30.9170000+07:00"
}

video.upload_failed

อัปโหลดคลิปไม่สำเร็จขั้นสุดท้าย — บูธลองครบตามจำนวนรอบที่กำหนดแล้วยังล้มเหลว หรือ ไม่มีไฟล์คลิปอยู่ในเครื่องบูธ (code = FILE_NOT_FOUND) งานในคิวจะถูกลบออก — reason คือสาเหตุที่บูธรายงานมา

"data": {
  "videoId": 987,
  "taskId": 12345,
  "attempt": 6,
  "code": "STORAGE",
  "reason": "FTP upload failed: connection timed out"
}

booth.offline

บูธขาดการเชื่อมต่อ (heartbeat เงียบเกิน 10 นาที) — ส่ง ครั้งเดียว ต่อการหลุด 1 รอบ

"data": {
  "lastHeartBeat": "2026-06-11T09:00:12.0000000+07:00",
  "offlineSince": "2026-06-11T09:10:30.5310000+07:00"
}

scan.rejected

บูธปฏิเสธการสแกน (ปัจจุบัน: สแกนซ้ำออเดอร์ที่กำลังอัดอยู่) — detail คือรหัสที่สแกน

"data": {
  "detail": "TH1234567890"
}

sms.credit_low

เครดิต SMS เหลือน้อยถึงเกณฑ์ — ส่ง ครั้งเดียวต่อการลดลง 1 รอบ และจะแจ้งอีกครั้งก็ต่อเมื่อยอดกลับขึ้นไปเหนือเกณฑ์แล้วลดลงมาใหม่ (เครดิตที่คืนให้จากข้อความที่ส่งไม่สำเร็จไม่นับว่ายอดฟื้นแล้ว)

"data": {
  "balance": 12,
  "threshold": 50,
  "at": "2026-06-11T09:10:30.5310000+07:00"
}

7. การ Retry

  • ปลายทางตอบ 2xx = สำเร็จ (สถานะ Delivered)
  • ตอบรหัสอื่น / ต่อไม่ติด = ลองใหม่ตามตาราง: 1 นาที → 5 → 15 → 1 ชม. → 3 → 6 → 12 ชม. รวม ~8 ครั้ง
  • ครบแล้วยังไม่สำเร็จ = หยุด (สถานะ Dead) — กดปุ่ม “ส่งใหม่” ใน /me/hooks เพื่อ queue ใหม่ได้
  • URL ที่ผิดรูป/ชี้ไปที่ภายใน จะถูกตัดเป็น Dead ทันที (ไม่ retry)

ฝั่งรับควรทำงานแบบ idempotent (ใช้ X-ECB-Delivery หรือ data.videoId กันซ้ำ) เพราะการ retry อาจทำให้ event เดิมมาถึงมากกว่าหนึ่งครั้ง

8. ตัวอย่างโค้ดฝั่งรับ (หลายภาษา)

ตัวอย่างการรับ webhook + ตรวจลายเซ็น (ข้อ 5) แล้ว decode payload เป็นชนิดข้อมูลที่ เก็บ time zone ของวันเวลาไว้ถูกต้อง (DateTimeOffset / OffsetDateTime / time.Time / chrono ฯลฯ) — อย่าลืมแทน YOUR_SECRET ด้วย secret ของคุณ และอ่าน raw body ดิบ ๆ (อย่า parse แล้ว serialize ใหม่ ไม่งั้นลายเซ็นจะไม่ตรง)

app.MapPost("/webhook", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body);
    string body = await reader.ReadToEndAsync();

    // header: "t=<unix>,v1=<hex>"
    var sig = req.Headers["X-ECB-Signature"].ToString()
        .Split(',').Select(p => p.Split('=', 2))
        .ToDictionary(a => a[0], a => a[1]);

    string secret = "YOUR_SECRET";
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    string expected = Convert.ToHexString(
        hmac.ComputeHash(Encoding.UTF8.GetBytes(sig["t"] + "." + body)))
        .ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig["v1"])))
        return Results.Unauthorized();

    // Typed payload — DateTimeOffset decodes ISO 8601 + offset and
    // keeps the time zone (no guessing).
    var evt = JsonSerializer.Deserialize<WebhookEvent>(
        body, new JsonSerializerOptions(JsonSerializerDefaults.Web));
    Console.WriteLine($"{evt.Event} @ {evt.OccurredAt:o}");
    return Results.Ok();
});

record WebhookEvent(string Event, DateTimeOffset OccurredAt, BoothInfo Booth, JsonElement Data);
record BoothInfo(string Guid, string? Nickname);

<?php
$secret = 'YOUR_SECRET';
$body   = file_get_contents('php://input');

// header: "t=<unix>,v1=<hex>"
$header = $_SERVER['HTTP_X_ECB_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig);   // $sig['t'], $sig['v1']

$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
if (!hash_equals($expected, $sig['v1'] ?? '')) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);

// DateTimeImmutable parses ISO 8601 + offset (keeps the time zone)
$occurredAt = new DateTimeImmutable($event['occurredAt']);
error_log($event['event'] . ' @ ' . $occurredAt->format(DateTimeInterface::ATOM));
http_response_code(200);

require 'sinatra'
require 'openssl'
require 'json'
require 'time'

SECRET = 'YOUR_SECRET'

post '/webhook' do
  body = request.body.read
  # header: "t=<unix>,v1=<hex>"
  sig = (request.env['HTTP_X_ECB_SIGNATURE'] || '')
          .split(',').map { |kv| kv.split('=', 2) }.to_h

  expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{sig['t']}.#{body}")
  halt 401 unless Rack::Utils.secure_compare(expected, sig['v1'].to_s)

  event = JSON.parse(body)
  # Time.iso8601 keeps the offset
  occurred_at = Time.iso8601(event['occurredAt'])
  puts "#{event['event']} @ #{occurred_at.iso8601}"
  status 200
end

use axum::{body::Bytes, http::{HeaderMap, StatusCode}};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use chrono::{DateTime, FixedOffset};
use serde::Deserialize;

const SECRET: &[u8] = b"YOUR_SECRET";

#[derive(Deserialize)]
struct Event {
    event: String,
    #[serde(rename = "occurredAt")]
    occurred_at: DateTime<FixedOffset>,   // ISO 8601 + offset
}

async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let header = headers.get("X-ECB-Signature")
        .and_then(|v| v.to_str().ok()).unwrap_or("");

    // header: "t=<unix>,v1=<hex>"
    let (mut t, mut v1) = ("", "");
    for kv in header.split(',') {
        match kv.split_once('=') {
            Some(("t", v))  => t = v,
            Some(("v1", v)) => v1 = v,
            _ => {}
        }
    }

    let mut mac = Hmac::<Sha256>::new_from_slice(SECRET).unwrap();
    mac.update(format!("{t}.").as_bytes());
    mac.update(&body);
    match hex::decode(v1) {
        Ok(raw) if mac.verify_slice(&raw).is_ok() => {}
        _ => return StatusCode::UNAUTHORIZED,
    }

    let evt: Event = serde_json::from_slice(&body).unwrap();
    println!("{} @ {}", evt.event, evt.occurred_at);
    StatusCode::OK
}

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"
)

const secret = "YOUR_SECRET"

type Event struct {
    Event      string    `json:"event"`
    OccurredAt time.Time `json:"occurredAt"`   // RFC3339 -> time.Time (with offset)
}

func webhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    // header: "t=<unix>,v1=<hex>"
    sig := map[string]string{}
    for _, kv := range strings.Split(r.Header.Get("X-ECB-Signature"), ",") {
        if p := strings.SplitN(kv, "=", 2); len(p) == 2 {
            sig[p[0]] = p[1]
        }
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(sig["t"] + "." + string(body)))
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(sig["v1"])) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    var evt Event
    json.Unmarshal(body, &evt)   // OccurredAt keeps its offset
    fmt.Printf("%s @ %s\n", evt.Event, evt.OccurredAt.Format(time.RFC3339Nano))
    w.WriteHeader(http.StatusOK)
}

const express = require('express');
const crypto  = require('crypto');
const app = express();

const SECRET = 'YOUR_SECRET';

// keep the RAW body so the signature matches
app.use(express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
  const raw = req.body;                         // Buffer
  const header = req.get('X-ECB-Signature') || '';
  const sig = Object.fromEntries(header.split(',').map(kv => kv.split('=')));

  const expected = crypto.createHmac('sha256', SECRET)
    .update(sig.t + '.' + raw.toString('utf8'))
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1 || ''))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(raw.toString('utf8'));
  // Date parses ISO 8601 + offset (stored as the correct UTC instant).
  // For the ORIGINAL offset use Luxon: DateTime.fromISO(event.occurredAt, { setZone: true })
  const occurredAt = new Date(event.occurredAt);
  console.log(event.event, '@', occurredAt.toISOString());
  res.sendStatus(200);
});

app.listen(8080);

import hmac, hashlib, json
from flask import Flask, request, abort
from dateutil import parser            # pip install python-dateutil

SECRET = b'YOUR_SECRET'
app = Flask(__name__)

@app.post('/webhook')
def webhook():
    raw = request.get_data()                  # raw bytes
    header = request.headers.get('X-ECB-Signature', '')
    sig = dict(kv.split('=', 1) for kv in header.split(','))

    msg = (sig['t'] + '.').encode() + raw
    expected = hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.get('v1', '')):
        abort(401)

    event = json.loads(raw)
    # isoparse keeps the offset AND handles .NET's 7-digit fractional
    # (datetime.fromisoformat only accepts up to 6 digits)
    occurred_at = parser.isoparse(event['occurredAt'])
    print(event['event'], '@', occurred_at.isoformat())
    return '', 200

import javax.servlet.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.OffsetDateTime;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class WebhookServlet extends HttpServlet {
    static final String SECRET = "YOUR_SECRET";
    static final ObjectMapper MAPPER = new ObjectMapper();

    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws Exception {
        byte[] body = req.getInputStream().readAllBytes();

        // header: "t=<unix>,v1=<hex>"
        String header = req.getHeader("X-ECB-Signature");
        String t = "", v1 = "";
        for (String kv : header.split(",")) {
            String[] p = kv.split("=", 2);
            if (p[0].equals("t"))  t = p[1];
            if (p[0].equals("v1")) v1 = p[1];
        }

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
            SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
        byte[] hash = mac.doFinal(body);

        StringBuilder hex = new StringBuilder();
        for (byte b : hash) hex.append(String.format("%02x", b));

        if (!MessageDigest.isEqual(hex.toString().getBytes(), v1.getBytes())) {
            resp.setStatus(401);
            return;
        }

        JsonNode root = MAPPER.readTree(body);
        // OffsetDateTime.parse decodes ISO 8601 + offset + fractional
        OffsetDateTime occurredAt = OffsetDateTime.parse(root.get("occurredAt").asText());
        System.out.println(root.get("event").asText() + " @ " + occurredAt);
        resp.setStatus(200);
    }
}

ติดปัญหาการใช้งาน หรืออยากสอบถาม?

ทักได้ที่ LINE @happym หรือโทร 085-926-9797 (คุณพล) ได้เลย