Skip to content
Preview: The API is still being rolled out and details may change before launch. Follow progress in the changelog.

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.

  • 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.com or sukson.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).

{
"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.

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

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 ) )
  1. 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.
  2. Use the webhook secret exactly as shown as the key (don’t base64-decode it).
  3. Compare in constant time (timingSafeEqual, hmac.Equal, hmac.compare_digest).
  4. Reject events whose X-Sukson-Timestamp is more than 5 minutes away from now, so nobody can replay an old event. Keep your server clock right with NTP.
  5. Remember the event_ids you handled for at least 24 hours: if you answer slowly or fail, the same event may come again.
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);

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

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 OK
Content-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 reply is text only.
  • For slow work, answer 200 first, then call POST /v1/chats/{chat_id}/messages with the reply_token (within 60 seconds), or push later.
  • Use one way per event, so the bot doesn’t answer twice.
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.