เว็บฮุค (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 หรือเครือข่ายภายใน)