ข้ามไปยังเนื้อหา
Preview: API ยังอยู่ระหว่างเปิดใช้ รายละเอียดอาจเปลี่ยนก่อนเปิดจริง ดูความคืบหน้าที่ บันทึกการเปลี่ยนแปลง

Webhook

webhook คือ URL บนเซิร์ฟเวอร์ของคุณที่ Sukson ส่งข้อมูลไปหา เมื่อมีข้อความที่เรียกบอท Sukson จะส่ง POST แบบ JSON ไปที่ URL นี้ หนึ่งข้อความในหนึ่งห้อง เป็นหนึ่ง event

Sukson อ่านเนื้อความจากฐานข้อมูลตอนกำลังจะส่ง ถ้าข้อความถูกลบไปก่อน ก็จะไม่ถูกส่งออกไป

  • ต้องเป็น https:// พอร์ต 443 หรือ 8443 เท่านั้น
  • ใช้ชื่อโดเมน ห้ามใช้ IP ตรง ๆ ห้ามมีชื่อผู้ใช้หรือรหัสผ่านใน URL และห้ามชี้ไปที่ sukson.com หรือ sukson.dev
  • โดเมนต้องชี้ไปที่ IP สาธารณะ ระบบตรวจ IP ซ้ำทุกครั้งที่เชื่อมต่อ และไม่ตาม redirect
  • ยาวไม่เกิน 2,048 ตัวอักษร

ถ้า URL ใช้ไม่ได้ จะได้ 422 INVALID_WEBHOOK_URL และ details.reason บอกสาเหตุ (webhook API)

{
"event_id": "01933b5e-7c2a-7d4e-9f10-2a6b8c4d5e6f",
"type": "message",
"trigger": "mention",
"reply_token": "rt_q8Jf3vYb0kZ2mW9xT1sLr6PaN4cVe7Hd",
"bot": { "id": "01933b40-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "handle": "weather_bot" },
"chat": { "id": "0b6c3a52-5d0e-4c8e-9a51-6f1e7f6f2b10", "kind": "group", "title": "ชมรมเดินป่า" },
"message": {
"id": 812345,
"kind": "text",
"text": "@weather_bot พรุ่งนี้ฝนตกไหม",
"reply_to_message_id": null,
"sent_at": "2026-11-06T03:15:00.000Z"
},
"sender": { "id": "9f2c4e7a1b3d5f60718293a4b5c6d7e8", "name": "สมชาย", "is_visitor": false }
}
ฟิลด์ คำอธิบาย
event_id id ของ event ค่าเดียวกับหัว X-Sukson-Event-Id ใช้เช็กว่าเคยทำ event นี้ไปแล้วหรือยัง
trigger เหตุที่บอทได้รับข้อความนี้: mention, reply, command หรือ all (ภาพรวม)
reply_token ใช้ตอบข้อความนี้ได้ครั้งเดียวภายใน 60 วินาที (Reply token)
bot บอทที่รับ event (id เดียวกับ GET /v1/me)
chat.id id ของห้อง ใช้เป็น chat_id ใน API บอทตัวเดียวอยู่ได้หลายห้อง จึงใช้ค่านี้แยกว่าข้อความมาจากห้องไหน ส่วน chat.kind เป็น room, group หรือ channel
message.id id ของข้อความ ใช้เป็น reply_to_message_id
message.kind ชนิดข้อความ ถ้าเป็นรูปหรือไฟล์ บอทได้แค่ชนิด ไม่ได้ไฟล์ (ชนิดข้อความ)
sender.id รหัสแทนตัวผู้ส่งที่ใช้ได้เฉพาะบอทนี้ ไม่ใช่ id บัญชีจริง และ เปลี่ยนเมื่อตั้ง webhook URL ใหม่ (รหัสลับใหม่)
sender.is_visitor true สำหรับผู้มาเยือนที่ไม่ได้ล็อกอินในห้องสาธารณะ

Sukson ส่ง ping ตอนคุณตั้ง URL ใหม่ และตอนเรียก POST /v1/me/webhook/test เซิร์ฟเวอร์ต้องตอบ 2xx ภายใน 10 วินาที

{ "event_id": "5d1e0c4b-2f3a-4b5c-9d6e-7f8091a2b3c4", "type": "ping" }

event ชนิดอื่น (join, leave, memberJoined, memberLeft, postback, unsend) จะตามมาในเวอร์ชันถัดไป ถ้าเจอ type ที่โค้ดไม่รู้จัก ให้ ตอบ 200 แล้วข้ามไป เสมอ

event ที่มีอยู่แล้วก็อาจได้ field ใหม่ เพิ่มเข้ามาได้ตลอด เช่น reply_token และ chat.kind อาจมีค่าใหม่ (ตอนนี้มี room, group, channel) อย่าเขียนโค้ดที่ปฏิเสธ event เพราะเจอ field หรือค่าที่ไม่รู้จัก ให้อ่านเฉพาะ field ที่ใช้ แล้วข้าม field อื่นไป ถ้าตอบ 4xx กลับมา Sukson จะนับว่าส่งไม่สำเร็จ และถ้าล้มเหลวต่อเนื่องนานพอ บอทจะถูกหยุดอัตโนมัติ

Header ค่า
Content-Type application/json
User-Agent SuksonBot/1.0
X-Sukson-Event-Id id ของ event (เท่ากับ event_id)
X-Sukson-Timestamp เวลาที่ลงลายเซ็น เป็นวินาทีแบบ Unix
X-Sukson-Signature v1= ตามด้วย HMAC-SHA256 แบบ hex ตัวพิมพ์เล็ก

ลายเซ็นช่วยให้คุณรู้ว่า event มาจาก Sukson จริง และไม่ถูกแก้ระหว่างทาง ลายเซ็นคำนวณแบบนี้

X-Sukson-Signature = "v1=" + hex( HMAC-SHA256( key = รหัสลับ webhook, data = timestamp + "." + body ดิบ ) )
  1. อ่าน body ดิบ (ไบต์ตามที่ได้รับมา) ก่อนแปลงเป็น JSON ห้ามแปลงเป็น JSON แล้วแปลงกลับเป็นข้อความใหม่ เพราะไบต์จะไม่ตรงกับต้นฉบับ
  2. ใช้รหัสลับ webhook ตามตัวอักษรที่ได้มา เป็น key (ไม่ต้อง base64-decode)
  3. เทียบลายเซ็นด้วยฟังก์ชันที่ใช้เวลาคงที่ (timingSafeEqual, hmac.Equal, hmac.compare_digest)
  4. ปฏิเสธ event ที่ X-Sukson-Timestamp ห่างจากเวลาตอนนี้ เกิน 5 นาที เพื่อกันคนเอา event เก่ามาส่งซ้ำ (replay) และตั้งนาฬิกาเซิร์ฟเวอร์ให้ตรงด้วย NTP
  5. จำ event_id ที่ทำไปแล้วไว้อย่างน้อย 24 ชั่วโมง เพราะถ้าคุณตอบช้าหรือผิดพลาด ระบบอาจส่ง event เดิมมาอีก
server.mjs
// Node.js 20+ — no dependencies. Run behind a reverse proxy that terminates HTTPS.
import { createHmac, timingSafeEqual } from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.SUKSON_WEBHOOK_SECRET; // the webhook secret, used as is
if (!SECRET) throw new Error('Set SUKSON_WEBHOOK_SECRET'); // fail at start, not in every request
const TOLERANCE_SECONDS = 300;
const MAX_BODY_BYTES = 1 << 20; // 1 MB; events are far smaller
const seen = new Map(); // event_id -> expiry; in production keep seen event_ids in a shared store or a database table
export function verify(headers, rawBody, now = Date.now() / 1000) {
const timestamp = String(headers['x-sukson-timestamp'] ?? '');
const signature = String(headers['x-sukson-signature'] ?? '');
if (!/^\d+$/.test(timestamp) || !signature.startsWith('v1=')) return false;
// Older or newer than 5 minutes: a replay, or a clock to fix.
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(rawBody) // the bytes as received, never JSON.stringify(JSON.parse(...))
.digest('hex');
const given = signature.slice(3);
// Check the format first: timingSafeEqual throws on buffers of different
// byte lengths, and a non-ASCII header would crash the server.
if (!/^[0-9a-f]{64}$/.test(given)) return false;
return timingSafeEqual(Buffer.from(given, 'utf8'), Buffer.from(expected, 'utf8'));
}
function firstTime(eventId) {
const now = Date.now();
for (const [id, expiry] of seen) if (expiry < now) seen.delete(id);
if (seen.has(eventId)) return false;
seen.set(eventId, now + 24 * 60 * 60 * 1000);
return true;
}
http
.createServer((req, res) => {
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
res.writeHead(413, { Connection: 'close' }).end(); // too big: stop before buffering more
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verify(req.headers, rawBody)) return res.writeHead(401).end();
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.writeHead(400).end();
}
res.writeHead(200, { 'Content-Type': 'application/json' });
if (!firstTime(event.event_id)) return res.end('{}'); // sent again: already handled
if (event.type === 'message' && event.trigger === 'command') {
return res.end(JSON.stringify({ reply: 'pong' })); // posted as a reply for you
}
res.end('{}'); // ping, or nothing to say
});
})
.listen(3000);

ใช้เช็กว่าโค้ดของคุณคำนวณลายเซ็นถูก (เป็นค่าตัวอย่าง ไม่ใช่รหัสลับจริง)

รหัสลับ example-webhook-secret
X-Sukson-Timestamp 1762400100
body {"event_id":"5d1e0c4b-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"ping"}
X-Sukson-Signature v1=b79371bab1a082deb81e7ccfb17a3a53fcdd56166e78c0a23a93d411e7b8a14c

วิธีที่ง่ายที่สุด: ตอบ HTTP 200 พร้อม JSON ที่มีฟิลด์ reply ภายใน 10 วินาที แล้ว Sukson จะโพสต์ข้อความนั้นเป็นการตอบกลับข้อความที่เรียกบอทให้

HTTP/1.1 200 OK
Content-Type: application/json
{ "reply": "พรุ่งนี้เชียงใหม่มีโอกาสฝนตก 70%" }
  • ถ้าตอบแบบอื่น หรือ body ว่าง แปลว่าบอทไม่พูดอะไร
  • คำตอบทั้งก้อนต้องไม่เกิน 64 KB และ reply เป็นข้อความตัวอักษรเท่านั้น
  • ถ้างานใช้เวลานาน ให้ตอบ 200 ไปก่อน แล้วเรียก POST /v1/chats/{chat_id}/messages พร้อม reply_token (ภายใน 60 วินาที) หรือส่งแบบ push ภายหลัง
  • ใช้แค่วิธีเดียวต่อหนึ่ง event บอทจะได้ไม่ตอบซ้ำสองครั้ง
คำตอบของเซิร์ฟเวอร์คุณ สิ่งที่ Sukson ทำ
2xx ถือว่าส่งถึง
3xx, 4xx (ยกเว้น 429) ไม่ส่งซ้ำ และนับเป็นความล้มเหลวหนึ่งครั้ง
429 รอตาม Retry-After (ไม่เกิน 5 นาที) แล้วส่งใหม่ สูงสุด 4 ครั้ง
5xx, หมดเวลา 10 วินาที, เชื่อมต่อไม่ได้ ส่งใหม่หลัง 30 วินาที, 2 นาที และ 10 นาที
  • ระบบไม่ตาม redirect (3xx ถือว่าล้มเหลว) และอ่านคำตอบไม่เกิน 64 KB
  • event ที่ค้างเกิน 15 นาทีจะถูกทิ้ง
  • ส่งพร้อมกันได้ไม่เกิน 2 event ต่อบอท บอทที่ตอบช้าจึงไม่ไปแย่งคิวบอทอื่น และ event ของคุณอาจมาไม่ตรงลำดับ
  • ถ้าล้มเหลวติดกัน 20 ครั้ง ในช่วงเวลาอย่างน้อย 30 นาที บอทจะหยุดเอง และเจ้าของจะได้อีเมลกับการแจ้งเตือน แก้เซิร์ฟเวอร์ให้เรียบร้อย แล้วกด “เปิดบอทอีกครั้ง” ในหน้าของบอท
  • คำตอบ 3xx และ 4xx ก็นับเป็นความล้มเหลว ระหว่างเปลี่ยนรหัสลับ ถ้าเซิร์ฟเวอร์ตอบ 401 กับลายเซ็นที่ไม่ผ่านอยู่นาน บอทอาจหยุดเองได้
  • ดูจำนวนครั้งที่ล้มเหลวติดกันได้ที่ GET /v1/me/webhook