เว็บฮุค (Webhooks)
ร้านที่ใช้ระบบ POS หรือโปรแกรมบัญชีของตัวเอง ตั้งปลายทางได้ที่ ร้าน → เว็บฮุค ในพอร์ทัลเจ้าของร้าน (www.thaiqrorder.com/owner) ทุกครั้งที่มีเหตุการณ์ที่เลือก ThaiQROrder จะส่ง HTTP POST แบบ JSON ไปที่ปลายทางนั้น
เหตุการณ์
| type | เมื่อไร |
|---|---|
order.created | ออเดอร์ใหม่ที่ครัวต้องทำ (ออเดอร์เว็บไซต์/คีออสก์: เมื่อชำระแล้ว) |
order.paid | ออเดอร์ชำระเงินแล้ว (ออนไลน์ หรือร้านกดรับเงิน) |
order.status_changed | สถานะออเดอร์เปลี่ยน (กำลังทำ, เสิร์ฟแล้ว ฯลฯ) |
order.cancelled | ออเดอร์ถูกยกเลิก |
bill.settled | เคลียร์โต๊ะ / ปิดบิลแล้ว |
payment.succeeded | ลูกค้าชำระออนไลน์สำเร็จ (Stripe) |
payment.refunded | คืนเงินสำเร็จ |
reservation.created | มีคำขอจองโต๊ะใหม่ |
reservation.updated | สถานะการจองเปลี่ยน (ยืนยัน/ปฏิเสธ/ยกเลิก ฯลฯ) |
waiting.created | ออกบัตรคิวรอหน้าร้าน |
ping | ปุ่ม "ทดสอบส่ง" ในพอร์ทัล |
รูปแบบข้อมูล
ทุกเหตุการณ์มีโครงเดียวกัน: id (ไม่ซ้ำ ใช้กันการประมวลผลซ้ำได้), type, created_at, store และ data ซึ่งมี order, bill, payment, reservation หรือ waiting ตามประเภท ยอดเงินเป็นบาท
{
"id": "evt_8f0c2c1e-2a7b-4c1e-9a52-1f2d3c4b5a69",
"type": "order.created",
"created_at": "2026-10-07T12:34:56+07:00",
"store": {
"id": 62,
"name": "Baan Daniel Kitchen",
"slug": "daniel"
},
"data": {
"order": {
"id": 12345,
"status": "pending",
"fulfillment": "dine_in",
"origin": "guest",
"total": 185,
"delivery_fee": null,
"note": "ไม่เผ็ด",
"customer_name": null,
"customer_phone": null,
"table": {
"id": 528,
"number": 5,
"label": "ริมน้ำ"
},
"table_session_id": 27310,
"pickup_at": null,
"delivery_address": null,
"paid_at": null,
"paid_via": null,
"items": [
{
"id": 90001,
"menu_item_id": 14677,
"name": "กะเพราหมูสับ",
"quantity": 2,
"voided_quantity": 0,
"price": 60,
"note": null
},
{
"id": 90002,
"menu_item_id": 14680,
"name": "ชาไทยเย็น",
"quantity": 1,
"voided_quantity": 0,
"price": 65,
"note": null
}
],
"created_at": "2026-10-07T12:34:55+07:00"
}
}
}
หัวข้อ HTTP
X-ThaiQROrder-Event: ประเภทเหตุการณ์X-ThaiQROrder-Delivery: หมายเลขการส่ง (ส่งซ้ำได้หมายเลขใหม่ แต่ id ของเหตุการณ์เดิม)X-ThaiQROrder-Signature:t=<unix>,v1=<hex>
ตรวจลายเซ็น
v1 คือ HMAC-SHA256 (hex) ของข้อความ "<t>.<เนื้อหา request ดิบ>" โดยใช้รหัสลับของปลายทาง (whsec_tqo_…) ตรวจให้ตรงกันและปฏิเสธ t ที่เก่ากว่า 5 นาที
PHP
$body = file_get_contents('php://input');
[$t, $v1] = [null, null];
foreach (explode(',', $_SERVER['HTTP_X_THAIQRORDER_SIGNATURE'] ?? '') as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, null);
if ($k === 't') $t = $v; elseif ($k === 'v1') $v1 = $v;
}
$expected = hash_hmac('sha256', $t . '.' . $body, getenv('TQO_WEBHOOK_SECRET'));
if (!$v1 || !hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
http_response_code(400); exit;
}
$event = json_decode($body, true); // reply 2xx quickly; do slow work later
Node.js
import crypto from 'node:crypto';
// express.raw({ type: 'application/json' }) so req.body is the raw Buffer
app.post('/hooks/thaiqrorder', express.raw({ type: 'application/json' }), (req, res) => {
const sig = Object.fromEntries(String(req.get('X-ThaiQROrder-Signature')).split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.TQO_WEBHOOK_SECRET).update(`${sig.t}.${req.body}`).digest('hex');
const ok = sig.v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1)) && Math.abs(Date.now() / 1000 - sig.t) < 300;
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200);
});
Python
import hmac, hashlib, time, json
def verify(raw_body: bytes, header: str, secret: str) -> dict:
parts = dict(p.split('=', 1) for p in header.split(','))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get('v1', '')) or abs(time.time() - int(parts['t'])) > 300:
raise ValueError('bad signature')
return json.loads(raw_body)
การส่งซ้ำและการปิดอัตโนมัติ
- ตอบ HTTP 2xx ภายใน 10 วินาทีถือว่าสำเร็จ ไม่ติดตาม redirect
- ถ้าไม่สำเร็จ ลองใหม่หลัง 1 นาที, 5 นาที, 30 นาที, 2 ชั่วโมง และ 6 ชั่วโมง (รวม 6 ครั้ง)
- เหตุการณ์อาจมาถึงซ้ำหรือไม่เรียงลำดับ ใช้ id และสถานะใน data เป็นหลัก
- ปลายทางที่ส่งไม่สำเร็จติดต่อกัน 15 รายการจะถูกปิดอัตโนมัติ เปิดใหม่ได้ในพอร์ทัล
- ปลายทางต้องเป็นที่อยู่สาธารณะ (ไม่รับ localhost หรือเครือข่ายภายใน)