Webhook
webhook คือ URL บนเซิร์ฟเวอร์ของคุณที่ Sukson ส่งข้อมูลไปหา เมื่อมีข้อความที่เรียกบอท Sukson จะส่ง POST แบบ JSON ไปที่ URL นี้ หนึ่งข้อความในหนึ่งห้อง เป็นหนึ่ง event
Sukson อ่านเนื้อความจากฐานข้อมูลตอนกำลังจะส่ง ถ้าข้อความถูกลบไปก่อน ก็จะไม่ถูกส่งออกไป
เงื่อนไขของ URL
หัวข้อที่มีชื่อว่า “เงื่อนไขของ URL”- ต้องเป็น
https://พอร์ต 443 หรือ 8443 เท่านั้น - ใช้ชื่อโดเมน ห้ามใช้ IP ตรง ๆ ห้ามมีชื่อผู้ใช้หรือรหัสผ่านใน URL และห้ามชี้ไปที่
sukson.comหรือsukson.dev - โดเมนต้องชี้ไปที่ IP สาธารณะ ระบบตรวจ IP ซ้ำทุกครั้งที่เชื่อมต่อ และไม่ตาม redirect
- ยาวไม่เกิน 2,048 ตัวอักษร
ถ้า URL ใช้ไม่ได้ จะได้ 422 INVALID_WEBHOOK_URL และ details.reason บอกสาเหตุ (webhook API)
message
หัวข้อที่มีชื่อว่า “message”{ "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 จะนับว่าส่งไม่สำเร็จ และถ้าล้มเหลวต่อเนื่องนานพอ บอทจะถูกหยุดอัตโนมัติ
หัว HTTP
หัวข้อที่มีชื่อว่า “หัว HTTP”| 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 ดิบ ) )- อ่าน body ดิบ (ไบต์ตามที่ได้รับมา) ก่อนแปลงเป็น JSON ห้ามแปลงเป็น JSON แล้วแปลงกลับเป็นข้อความใหม่ เพราะไบต์จะไม่ตรงกับต้นฉบับ
- ใช้รหัสลับ webhook ตามตัวอักษรที่ได้มา เป็น key (ไม่ต้อง base64-decode)
- เทียบลายเซ็นด้วยฟังก์ชันที่ใช้เวลาคงที่ (
timingSafeEqual,hmac.Equal,hmac.compare_digest) - ปฏิเสธ event ที่
X-Sukson-Timestampห่างจากเวลาตอนนี้ เกิน 5 นาที เพื่อกันคนเอา event เก่ามาส่งซ้ำ (replay) และตั้งนาฬิกาเซิร์ฟเวอร์ให้ตรงด้วย NTP - จำ
event_idที่ทำไปแล้วไว้อย่างน้อย 24 ชั่วโมง เพราะถ้าคุณตอบช้าหรือผิดพลาด ระบบอาจส่ง event เดิมมาอีก
// 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 isif (!SECRET) throw new Error('Set SUKSON_WEBHOOK_SECRET'); // fail at start, not in every requestconst TOLERANCE_SECONDS = 300;const MAX_BODY_BYTES = 1 << 20; // 1 MB; events are far smallerconst 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);// Go 1.22+ — standard library only. Run behind a reverse proxy that terminates HTTPS.package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "os" "regexp" "strconv" "strings" "time")
const tolerance = 5 * time.Minute
var digits = regexp.MustCompile(`^[0-9]+$`)
// verify checks X-Sukson-Signature against the raw body. secret is the// webhook secret exactly as Sukson showed it (do not base64-decode it).func verify(secret string, h http.Header, body []byte, now time.Time) bool { ts := h.Get("X-Sukson-Timestamp") sig := h.Get("X-Sukson-Signature") if !digits.MatchString(ts) || !strings.HasPrefix(sig, "v1=") { return false } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance { return false // a replay, or a clock to fix } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig[3:]), []byte(expected))}
func main() { secret := os.Getenv("SUKSON_WEBHOOK_SECRET") if secret == "" { log.Fatal("set SUKSON_WEBHOOK_SECRET") } http.HandleFunc("POST /sukson", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || !verify(secret, r.Header, body, time.Now()) { w.WriteHeader(http.StatusUnauthorized) return } var event struct { EventID string `json:"event_id"` Type string `json:"type"` Trigger string `json:"trigger"` } if json.Unmarshal(body, &event) != nil { w.WriteHeader(http.StatusBadRequest) return } // Remember event.EventID for 24 hours (a set-if-absent with expiry in your store) and skip repeats. w.Header().Set("Content-Type", "application/json") if event.Type == "message" && event.Trigger == "command" { json.NewEncoder(w).Encode(map[string]string{"reply": "pong"}) return } w.Write([]byte("{}")) }) log.Fatal(http.ListenAndServe(":3000", nil))}# Python 3.9+, standard library only. Run behind a reverse proxy that terminates HTTPS.from __future__ import annotations
import hashlibimport hmacimport jsonimport osimport reimport timefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ["SUKSON_WEBHOOK_SECRET"].encode() # used as is, not base64-decodedTOLERANCE_SECONDS = 300MAX_BODY_BYTES = 1 << 20 # 1 MB; events are far smaller
def verify(headers, raw_body: bytes, now: float | None = None) -> bool: timestamp = headers.get("X-Sukson-Timestamp", "") signature = headers.get("X-Sukson-Signature", "") if not re.fullmatch("[0-9]+", timestamp) or not signature.startswith("v1="): return False given = signature[3:] if not re.fullmatch("[0-9a-f]{64}", given): return False # compare_digest raises TypeError on non-ASCII text if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS: return False # a replay, or a clock to fix expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(given.encode(), expected.encode())
class Handler(BaseHTTPRequestHandler): def send(self, status: int, body: dict | None = None) -> None: data = b"" if body is None else json.dumps(body).encode() self.send_response(status) if body is not None: self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data)
def do_POST(self) -> None: if self.path != "/sukson": return self.send(404) try: length = int(self.headers.get("Content-Length", "0")) except ValueError: return self.send(400) if length < 0 or length > MAX_BODY_BYTES: self.close_connection = True return self.send(413) # too big: don't read it raw_body = self.rfile.read(length) # the bytes as received if not verify(self.headers, raw_body): return self.send(401) try: event = json.loads(raw_body) except ValueError: return self.send(400) # Remember event["event_id"] for 24 hours (a set-if-absent with expiry in your store) and skip repeats. if event.get("type") == "message" and event.get("trigger") == "command": return self.send(200, {"reply": "pong"}) # posted as a reply for you return self.send(200, {}) # ping, or nothing to say
if __name__ == "__main__": ThreadingHTTPServer(("", 3000), Handler).serve_forever()ค่าทดสอบ
หัวข้อที่มีชื่อว่า “ค่าทดสอบ”ใช้เช็กว่าโค้ดของคุณคำนวณลายเซ็นถูก (เป็นค่าตัวอย่าง ไม่ใช่รหัสลับจริง)
| รหัสลับ | 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 OKContent-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