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)
ถ้ากดปิดตอนมีงานค้างอยู่ในคิว
Dead (ไม่ส่ง และไม่พักไว้รอเปิดใหม่)
| สถานการณ์ | ผลลัพธ์ |
|---|---|
| งานที่ถึงกำหนดแล้ว (รอยิงรอบนี้) | รอบถัดไป (~20 วินาที) ระบบเจอว่าปิด → Dead (ไม่ส่ง) |
| งานที่ยังไม่ถึงกำหนด retry (เช่นรอ 12 ชม.) | ค้างในคิวจนถึงเวลานั้น แล้วค่อยถูกตัดเป็น Dead |
| เปิดใหม่ก่อนระบบหยิบงานนั้น | ส่งตามปกติ (ไม่โดน Dead) |
โดน Dead ไปแล้ว | ต้องกดปุ่ม “ส่งใหม่” ใน /me/hooks เพื่อ queue ใหม่เอง |
4. รูปแบบคำขอ + Header
ทุก event ส่งเป็น POST พร้อม body เป็น JSON (UTF-8) และ header ดังนี้:
| Header | ตัวอย่าง / ความหมาย |
|---|---|
Content-Type | application/json |
X-ECB-Event | ชื่อ event เช่น video.uploaded |
X-ECB-Delivery | เลขที่การส่ง (สำหรับ idempotency / ดู log) |
X-ECB-Signature | t=<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 ตัวพิมพ์เล็ก
ขั้นตอนตรวจฝั่งคุณ:
- อ่าน raw body ดิบ ๆ (อย่า parse แล้ว serialize ใหม่ ไม่งั้นลายเซ็นจะไม่ตรง)
- แยก
tและv1จาก header - คำนวณ
HMAC-SHA256(secret, t + "." + rawBody)เทียบกับv1แบบ constant-time - (ทางเลือก) ปฏิเสธถ้า
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 (คุณพล) ได้เลย