Webhooks
A webhook is a URL on your server that Sukson sends data to. When a message calls the bot, Sukson sends a JSON POST to that URL: one event for each message in each chat.
Sukson reads the text from its database just before sending, so a message deleted in the meantime is never sent.
URL requirements
Section titled “URL requirements”https://on port 443 or 8443 only.- A domain name: no IP address hosts, no user name or password in the URL, and nothing under
sukson.comorsukson.dev. - The domain must point to public IP addresses. The address is checked again on every connection, and redirects are not followed.
- At most 2,048 characters.
A URL that can’t be used gets 422 INVALID_WEBHOOK_URL with details.reason (webhook API).
Events
Section titled “Events”message
Section titled “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": "Hiking club" }, "message": { "id": 812345, "kind": "text", "text": "@weather_bot will it rain tomorrow?", "reply_to_message_id": null, "sent_at": "2026-11-06T03:15:00.000Z" }, "sender": { "id": "9f2c4e7a1b3d5f60718293a4b5c6d7e8", "name": "Somchai", "is_visitor": false }}| Field | Description |
|---|---|
event_id |
The event’s id, same as the X-Sukson-Event-Id header. Use it to tell whether you already handled this event. |
trigger |
Why the bot got this message: mention, reply, command or all (overview). |
reply_token |
Answers this message once, within 60 seconds (reply token). |
bot |
The bot receiving the event (id as in GET /v1/me). |
chat.id |
The chat’s id, used as chat_id in the API. One bot can be in several chats, so use this to tell which chat a message came from. chat.kind is room, group or channel. |
message.id |
The message’s id, usable as reply_to_message_id. |
message.kind |
The message kind. For images or files the bot gets the kind only, not the file (message types). |
sender.id |
A pseudonymous id for the sender, specific to this bot. Not a real account id, and it changes when you set a new webhook URL (new secret). |
sender.is_visitor |
true for visitors who aren’t signed in, in public rooms. |
Sukson sends a ping when you set a new URL and when you call POST /v1/me/webhook/test. Your server must answer 2xx within 10 seconds.
{ "event_id": "5d1e0c4b-2f3a-4b5c-9d6e-7f8091a2b3c4", "type": "ping" }More event types (join, leave, memberJoined, memberLeft, postback, unsend) are coming. When your code sees a type it doesn’t know, always answer 200 and skip it.
Existing events can also gain new fields at any time, such as reply_token, and chat.kind can gain new values (today: room, group, channel). Don’t reject an event because of a field or value you don’t know: read the fields you use and ignore the rest. A 4xx answer counts as a failed delivery, and enough failures in a row pause the bot automatically.
HTTP headers
Section titled “HTTP headers”| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
SuksonBot/1.0 |
X-Sukson-Event-Id |
The event’s id (equals event_id) |
X-Sukson-Timestamp |
When it was signed, in Unix seconds |
X-Sukson-Signature |
v1= followed by a lowercase hex HMAC-SHA256 |
Verify the signature
Section titled “Verify the signature”The signature tells you an event really came from Sukson and wasn’t changed on the way. It’s computed like this:
X-Sukson-Signature = "v1=" + hex( HMAC-SHA256( key = webhook secret, data = timestamp + "." + raw body ) )- Read the raw body (the bytes as received) before parsing the JSON. Don’t parse it and turn it back into text: the bytes would no longer match.
- Use the webhook secret exactly as shown as the key (don’t base64-decode it).
- Compare in constant time (
timingSafeEqual,hmac.Equal,hmac.compare_digest). - Reject events whose
X-Sukson-Timestampis more than 5 minutes away from now, so nobody can replay an old event. Keep your server clock right with NTP. - Remember the
event_ids you handled for at least 24 hours: if you answer slowly or fail, the same event may come again.
// 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()Test vector
Section titled “Test vector”Use it to check that your code computes the right signature (an example only, not a real secret).
| Secret | example-webhook-secret |
X-Sukson-Timestamp |
1762400100 |
| Body | {"event_id":"5d1e0c4b-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"ping"} |
X-Sukson-Signature |
v1=b79371bab1a082deb81e7ccfb17a3a53fcdd56166e78c0a23a93d411e7b8a14c |
Inline reply
Section titled “Inline reply”The simplest way to answer: respond HTTP 200 with a JSON object holding reply within 10 seconds, and Sukson posts that text as a reply to the message that called the bot.
HTTP/1.1 200 OKContent-Type: application/json
{ "reply": "Tomorrow: 70% chance of rain in Chiang Mai" }- Any other answer, or an empty body, means the bot stays quiet.
- The whole answer must be at most 64 KB, and
replyis text only. - For slow work, answer 200 first, then call
POST /v1/chats/{chat_id}/messageswith thereply_token(within 60 seconds), or push later. - Use one way per event, so the bot doesn’t answer twice.
Retries and automatic pause
Section titled “Retries and automatic pause”| Your server answers | What Sukson does |
|---|---|
| 2xx | Delivered. |
| 3xx, 4xx (except 429) | Not retried; counts as one failure. |
| 429 | Waits for Retry-After (at most 5 minutes), then retries, at most 4 times. |
| 5xx, a 10-second timeout, or no connection | Retries after 30 seconds, 2 minutes and 10 minutes. |
- Redirects are not followed (a 3xx counts as a failure), and at most 64 KB of the answer is read.
- An event still undelivered after 15 minutes is dropped.
- At most 2 events per bot are in flight at once, so a slow bot doesn’t hold up others, and your events may arrive out of order.
- After 20 failures in a row over at least 30 minutes, the bot pauses itself and its owner gets an email and a notification. Fix the server, then press “Turn the bot back on” on the bot’s page.
- 3xx and 4xx answers count as failures too. While you change secrets, answering 401 to failed signatures for a long time can pause the bot.
- The current failure streak is in
GET /v1/me/webhook.