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

  1. 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.

  2. 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) and messages:write (so it can post). The plaintext pat_… 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
}
Loop guard

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.