A bot is a member you drive with code. It reads and posts where its roles let it, answers direct messages, hears events live, and can be woken by a webhook — and it can never do more than a person with the same roles could.
Every request carries the token in a header:
Authorization: Bot vn_bot_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxEvery response is JSON in the same envelope: { "success": true, "data": … } or { "success": false, "error": { "code", "message" } }. A token that is wrong or has been replaced gets 401. Only a hash of the token is stored, so a lost token cannot be recovered — make a new one.
Bots use the same API as the app. Permissions are checked exactly as for a person, so a 403 means the bot's roles do not allow it.
/api/meThe bot's own account (id, name, handle)./api/workspacesSpaces the bot has been added to./api/presence?ids=a,bOnline / idle / do-not-disturb of people who share a space./api/workspaces/:idOne space, with its boards./api/workspaces/:id/membersWho is in it./api/workspaces/:id/membersAdd someone: { handle } or { email }, role EDITOR | VIEWER. Needs permission./api/workspaces/:id/permissionsWhat the bot itself may do, per channel./api/workspaces/:id/rolesRoles./api/workspaces/:id/channelsChannels the bot can see./api/workspaces/:id/channelsCreate one: { name, type: text | voice | announcement | media, topic? }./api/channels/:id/messages?before=&limit=Newest messages first page; page back with before=<messageId>./api/channels/:id/messagesPost: { content } — up to 4000 characters./api/channels/:id/messages?messageId=Delete a message (its own, or any with Manage Messages)./api/conversationsThe bot's DMs./api/conversationsOpen a DM: { userId } — anyone who shares a space with the bot./api/conversations/:id/messagesMessages in a DM./api/conversations/:id/messagesSend: { content }./api/boards?workspaceId=Boards./api/boardsCreate: { title, workspaceId }./api/boards/:idRename or describe a board./api/uploadUpload a file (multipart: file, purpose=chat). Returns a URL to post in a message./api/search?query=Search boards and spaces the bot can see.GET /api/events with the bot's token is a Server-Sent Events stream. Each event is a line data: {…json…} followed by a blank line; lines starting with : are keep-alives. If it drops, reconnect.
message.created{ workspaceId, channelId, messageId, message } — in a channel the bot can view.
message.deleted{ workspaceId, channelId, messageId }
dm.created{ conversationId, messageId, message } — someone messaged the bot directly.
member.joined{ workspaceId, userId }
hello{ connectionId, userId } — first event on every (re)connection.
A bot never receives its own messages, and only hears channels it is allowed to view.
For bots that sleep between events (serverless functions), set a webhook URL in Settings → Developer. Every event is POSTed there as JSON with these headers:
X-Velsi-Event: message.created
X-Velsi-Timestamp: 1731430000
X-Velsi-Signature: sha256=<HMAC-SHA256 of "<timestamp>.<body>" with your signing secret>Always verify the signature before trusting the body:
import { createHmac, timingSafeEqual } from "node:crypto";
// In your webhook handler, with the RAW request body:
function verify(rawBody, headers, secret) {
const timestamp = headers["x-velsi-timestamp"];
const given = headers["x-velsi-signature"]?.replace("sha256=", "") ?? "";
// Refuse anything older than five minutes: stops replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Webhook URLs must be public https:// addresses; private networks, raw IP addresses and localhost are refused. Delivery is best-effort with a 5-second timeout and no retries — use the event stream if you cannot miss anything.
Retry-After.Replies to !echo hello with hello, in any channel it can see. Run it with VELSI_BOT_TOKEN=… node echo-bot.mjs.
// echo-bot.mjs — Node 18+. No dependencies.
const BASE = "https://velsi.amplaying.com";
const TOKEN = process.env.VELSI_BOT_TOKEN;
const auth = { Authorization: `Bot ${TOKEN}` };
async function listen() {
const res = await fetch(`${BASE}/api/events`, { headers: auth });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const chunk = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
if (line) await handle(JSON.parse(line.slice(6)));
}
}
setTimeout(listen, 3000); // reconnect
}
async function handle(event) {
if (event.type !== "message.created") return;
if (!event.message.content.startsWith("!echo ")) return;
await fetch(`${BASE}/api/channels/${event.channelId}/messages`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ content: event.message.content.slice(6) }),
});
}
listen();