Developers
Building a bot
A tutorial that takes you from a fresh bot account to a working webhook-driven reply bot using the REST API.
This walks you through the smallest useful bot: it watches a channel for new messages via webhook, and replies through the REST API. The same pattern — verify a signed event, call back with a token — covers notification bots, moderation bots and integrations too.
What you’ll build
A bot account with its own API key. When someone posts in a channel, your
server receives a signed message.created webhook, checks it isn’t looking
at its own message, and posts a reply in the same thread. No polling.
Create the bot and its key
- Create the bot account
From the admin panel’s Users page, create a user and flag it as a bot — see Bots & API keys. Bot accounts can’t log in with a password; an API key is the only way in.
- Issue an API key for it
Still on that page, issue a key scoped to
profile:read(so your bot can look up its own user id) andmessages:write(so it can post). The plaintextpat_…token is shown once — save it somewhere your bot process can read.
curl -H "Authorization: Bearer pat_..." https://chat.example.com/api/v1/me
Note the numeric id in the response — you’ll need it in a moment to stop
your bot from replying to itself.
Receive events
Register a webhook pointed at your bot’s server, subscribed to the
message.created trigger — see Webhooks for
the admin-panel steps and the full signature-verification algorithm. Every
delivery arrives as a POST with three headers (X-Tyto-Event,
X-Tyto-Delivery, X-Tyto-Signature) and this JSON body:
{
"event": "message.created",
"message": { "id": "0192b1e0-...", "text": "hey team", "authorId": 42, "authorName": "Sam" },
"channelId": 7,
"communityId": 3,
"createdAt": 1784450000
}
Your bot’s own replies also fire message.created. Always compare message.authorId against your bot’s own numeric id (from GET /api/v1/me) and drop the event when they match — skipping this turns your bot into an infinite reply loop.
A minimal receiver, verifying the signature and ignoring the bot’s own messages before doing anything else:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const BOT_TOKEN = process.env.BOT_TOKEN;
const API_BASE = "https://chat.example.com/api/v1";
const me = await fetch(`${API_BASE}/me`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
}).then((r) => r.json());
const BOT_USER_ID = me.id;
function verify(header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", WEBHOOK_SECRET).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
if (!verify(req.headers["x-tyto-signature"], raw)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end();
const payload = JSON.parse(raw);
if (payload.event !== "message.created") return;
if (payload.message.authorId === BOT_USER_ID) return;
reply(payload.message.id, `Echo: ${payload.message.text}`);
});
}).listen(3000);
function reply(messageId, text) {
return fetch(`${API_BASE}/messages/${messageId}/replies`, {
method: "POST",
headers: {
Authorization: `Bearer ${BOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
}
Reply to the message
Posting into the thread only needs the message id the webhook already gave you — no need to resolve channel or community identifiers first. The endpoint is a thread reply:
curl -X POST https://chat.example.com/api/v1/messages/$MESSAGE_ID/replies \
-H "Authorization: Bearer pat_..." \
-H "Content-Type: application/json" \
-d '{"text": "Echo: hey team"}'
text is the only required field (max 10000 characters); the response is
the created message. The API accepts plain application/json as shown, or
application/ld+json if you want the full JSON-LD shape with @id/@type.
To post a fresh top-level message instead of a thread reply — a
notification bot announcing to a channel rather than answering one — the
endpoint is POST /api/v1/communities/{community}/channels/{channel}/messages
with the same {"text": "..."} body, keyed by the channel’s and community’s
human-readable identifiers (the slugs in their URLs, not a numeric id).
Going further
Everything else your bot can do rides the same bearer token and the same
JSON body shape — reacting to a message, joining threads, setting its own
presence. The full request and response shapes, including fields not
covered here, are in the REST API reference and your server’s
own interactive Swagger UI at /api.