What a bot is
A bot reads and writes in the text channels of the servers it was added to, adds reactions, shows buttons, menus, embeds and forms, answers “/” commands and hears about server events — just like a member with the same permissions. When the bot owner and the server allow it, a bot also moderates: kicks, bans, gives roles, manages channels and server settings. A bot always carries a BOT tag next to its name, and its card shows who owns it.
A bot writes to direct messages only of people who allowed it themselves — see “Direct messages”. Bots never take part in groups or calls, do not make friends and do not join servers through invites; in servers' voice channels a bot can play sound — see “Voice”. A bot is added to a server by someone who can manage that server.
This is the first version of the API. Breaking changes are announced on this page in advance.
Creating a bot
- In the Voxa app open Settings → Bots and press Create bot.
- The bot's username uses Latin letters, digits and
_, 3 to 24 characters, and must end withbot: for example,weather_bot. The wordvoxais reserved for official bots. - Pick what the bot can do (scopes, below) and accept the Developer Terms.
- You can create bots once your account is a day old and your email is confirmed. One person can have up to five bots.
- Right after creation the app shows the token — once. Copy it: we will not show it again.
The “Anyone can add” switch makes a bot public: anyone who can manage a server can add it there. Otherwise only you can add the bot. Bots are added in the server settings, on the Bots tab, by searching for their @username.
Token
Every API request is signed with the bot token — in the Authorization header, prefixed with the word Bot:
Authorization: Bot vxb_…- Tokens start with
vxb_, which lets leak scanners recognise them. TheBearerscheme does not work for bots: such a request gets 401. - New token on the bot card: the old token stops working at once and the program is disconnected from the gateway with code 4004.
- Revoke token: the bot cannot sign in until you issue a new one.
- The server keeps a fingerprint of the token, not the token itself, so a lost token cannot be shown again — only replaced.
- Keep the token like a password: in an environment variable or a secret store, not in code other people can see.
API address
All paths below are relative to https://api.voxavoice.ru/api/bot/v1. Requests and responses are UTF-8 JSON, times are RFC 3339 in UTC, identifiers are UUID strings. Error texts come in Russian.
curl -s https://api.voxavoice.ru/api/bot/v1/me \
-H "Authorization: Bot $VOXA_TOKEN"In the examples $VOXA_TOKEN is the bot token, and $SERVER, $CHANNEL and $MESSAGE are server, channel and message ids — shell variables you set yourself.
Scopes and server permissions
What a bot can do depends on two things at once: scopes — what the program can do at all, chosen by the bot owner — and the permissions of the bot's role on a given server, chosen by whoever added the bot. Both are required.
| Scope | Allows |
|---|---|
messages.read | Reading channel history and receiving message and reaction events |
messages.write | Sending, editing and deleting its own messages, showing “typing” |
messages.manage | Deleting other people's messages — where the bot's role may manage messages |
reactions | Adding and removing reactions |
members.read | Seeing the member list and member events |
members.manage | Kicking and banning members, managing them in voice channels — see “Moderation” |
roles.manage | Giving and taking roles, creating and editing roles below its own |
channels.manage | Creating, editing and deleting channels and their permissions — see “Managing a server” |
server.manage | Changing the server's name, icon, banner and description |
voice | Playing sound in servers' voice channels — see “Voice” |
The bot role. When a bot is added to a server it gets its own role — at the very bottom of the role list, with the permissions the adder ticked. Only permissions the adder has can be granted. The bot role is edited on the Roles tab like any other, and channel overwrites apply to it as usual. The permissions of @everyone are added on top.
A bot is never an administrator. Bots are never granted the administrator permission, a bot holds no role but its own, and its role cannot be given to people. So a bot cannot be added to a server where @everyone is an administrator, and @everyone cannot be made one while bots are on the server.
When a bot is removed, kicked or banned from a server, its role is deleted and its messages stay. After being added to a server the bot receives a server_create event.
Requests
| Method and path | Scope | What it does |
|---|---|---|
GET /me | — | The bot itself, its owner, scopes and gateway address |
GET /servers | — | Servers the bot is on |
GET /servers/{id} | — | A server: channels visible to the bot, categories, roles and the bot's permissions (my_permissions) |
GET /servers/{id}/members?after=&limit= | members.read | Members in ascending id order, 1 to 200 at a time (100 by default); next page — after=<last id> |
GET /servers/{id}/emojis | — | The server's emoji and stickers: id, name, url, animated, available |
GET /channels/{id}/messages?before=&limit= | messages.read | History: messages with seq below before, in ascending seq; limit 1 to 100, 50 by default. Instead of before — around=<seq>: a window around a message, or after=<seq>: everything after it (see “Catching up”) |
POST /channels/{id}/messages | messages.write | Send a message: content up to 4000 characters, reply_to — id of the message being answered, embeds — embeds, components — buttons and menus, attachments — files |
PATCH /messages/{id} | messages.write | Edit its own message: content, embeds, components — any of them |
DELETE /messages/{id} | own — messages.write, others' — messages.manage | Delete a message |
GET /messages/{id}/reactions | messages.read | Reactions and the first 30 people behind each |
POST /messages/{id}/reactions | reactions | Add a reaction: emoji (a symbol or a server emoji's :name:) or emoji_id |
DELETE /messages/{id}/reactions?emoji= or ?emoji_id= | reactions | Remove its own reaction |
POST /channels/{id}/typing | messages.write | Show “typing” in a channel |
GET /gateway | — | The WebSocket event gateway — next section |
GET /voice | voice | The voice socket over WebSocket: join a voice channel and play sound — see “Voice” |
POST /dms, GET /dms | messages.write, messages.read | Open a direct conversation and see who allowed the bot to write — “Direct messages” |
GET, PUT /commands; GET, PUT /servers/{id}/commands; DELETE /commands/{id} | — | “/” commands — “Slash commands” |
POST /interactions/{id}/callback, PATCH and DELETE …/original, POST …/followup | messages.write | Answers to presses, commands and forms — “Interactions and responses” |
POST /uploads | messages.write | Upload a file — see “Files” |
…/members/{user_id}, …/bans/…, …/roles/{role_id}, …/voice | members.manage, roles.manage | Kicks, bans, member roles, voice — see “Moderation” |
PATCH /servers/{id}, …/channels, …/overwrites/…, …/roles | server.manage, channels.manage, roles.manage | The server, channels, their permissions and roles — see “Managing a server” |
Responses in the examples are shortened: objects have other fields too (for example, profile styling on user), and new ones may appear. Do not rely on their absence and do not fail on unknown ones.
Who am I. The bot, its owner, scopes and the gateway address:
curl -s https://api.voxavoice.ru/api/bot/v1/me \
-H "Authorization: Bot $VOXA_TOKEN"
{
"user": {
"id": "3f6c9e1a-…",
"username": "weather_bot",
"display_name": "Погода",
"avatar": null,
"bot": true
},
"owner_id": "91d2b7c4-…",
"scopes": ["messages.read", "messages.write", "members.read"],
"gateway": { "path": "/api/bot/v1/gateway", "heartbeat_secs": 30 }
}The bot's servers and one server in full — only what the bot can see:
curl -s https://api.voxavoice.ru/api/bot/v1/servers \
-H "Authorization: Bot $VOXA_TOKEN"
[
{ "id": "c47a0e55-…", "name": "Дом", "icon": null, "owner_id": "91d2b7c4-…" }
]curl -s https://api.voxavoice.ru/api/bot/v1/servers/$SERVER \
-H "Authorization: Bot $VOXA_TOKEN"
{
"id": "c47a0e55-…",
"name": "Дом",
"icon": null,
"owner_id": "91d2b7c4-…",
"banner": null,
"description": null,
"channels": [
{
"id": "e5b81f07-…",
"server_id": "c47a0e55-…",
"name": "общий",
"kind": "text",
"topic": null,
"position": 0,
"category_id": null,
"user_limit": null,
"is_news": false,
"overwrites": []
}
],
"categories": [],
"roles": [
{
"id": "7a3d…",
"server_id": "c47a0e55-…",
"name": "Погода",
"color": "#9aa3b2",
"permissions": 16512,
"position": 7,
"is_default": false,
"bot_user_id": "3f6c9e1a-…"
}
],
"my_permissions": 16512,
"last_seqs": { "e5b81f07-…": 1043 }
}Members — needs the members.read scope:
curl -s "https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members?limit=100" \
-H "Authorization: Bot $VOXA_TOKEN"
[
{
"user": {
"id": "91d2b7c4-…",
"username": "kirill",
"display_name": "Кирилл",
"avatar": "/uploads/….png"
},
"nickname": null,
"joined_at": "2026-09-01T18:20:00Z",
"role_ids": []
}
]Channel history. A page comes in ascending seq — the message number. It is shared by all channels and only grows, so within one channel the numbers have gaps (see “Catching up”). To page back, pass before=<seq of the first>; combining before, around and after is a 400.
curl -s "https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages?limit=50" \
-H "Authorization: Bot $VOXA_TOKEN"
[
{
"id": "0d9f6a2b-…",
"seq": 1043,
"channel_id": "e5b81f07-…",
"content": "!погода Москва",
"attachments": [],
"reply_to": null,
"reply_preview": null,
"created_at": "2026-09-24T12:00:00Z",
"edited_at": null,
"author": {
"id": "91d2b7c4-…",
"username": "kirill",
"display_name": "Кирилл",
"avatar": null
}
}
]Sending a message. Files go into the attachments field — see “Files”. A field that is not described here is a 400, both when sending and when editing: otherwise a typo like embed instead of embeds would quietly go out as a message without the embed.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "В Москве +12, облачно", "reply_to": "0d9f6a2b-…"}'
{
"id": "5c2e90d1-…",
"seq": 1044,
"channel_id": "e5b81f07-…",
"content": "В Москве +12, облачно",
"attachments": [],
"reply_to": "0d9f6a2b-…",
"reply_preview": { "author_name": "Кирилл", "content": "!погода Москва", "has_attachments": false },
"created_at": "2026-09-24T12:00:01Z",
"edited_at": null,
"author": {
"id": "3f6c9e1a-…",
"username": "weather_bot",
"display_name": "Погода",
"avatar": null,
"bot": true
}
}Editing and deleting. Only its own messages can be edited; the response is the whole message, with edited_at.
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "В Москве +13, облачно"}'curl -s -X DELETE https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE \
-H "Authorization: Bot $VOXA_TOKEN"
{ "ok": true }Reactions. The emoji in the removal URL is URL-encoded:
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE/reactions \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"emoji": "👍"}'
{ "emoji": "👍", "count": 3, "mine": true }curl -s -G -X DELETE https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE/reactions \
-H "Authorization: Bot $VOXA_TOKEN" \
--data-urlencode "emoji=👍"
{ "emoji": "👍", "count": 2, "mine": false }curl -s https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE/reactions \
-H "Authorization: Bot $VOXA_TOKEN"
[
{
"emoji": "👍",
"count": 2,
"mine": false,
"users": [
{ "id": "91d2b7c4-…", "username": "kirill", "display_name": "Кирилл", "avatar": null }
]
}
]Server emoji. Write :name: in a message — the server links it to the channel server's emoji and people see the picture (the link lives in meta.emojis). A bot does not use other servers' emoji. React with {"emoji": ":party_cat:"} or {"emoji_id": "…"}; the response and the message_reaction event then carry emoji_id, url and animated. Tell reactions apart by emoji_id when present: emoji can be renamed.
The list is GET /servers/{id}/emojis. An emoji with available: false does not fit the server level's slots right now: its :name: stays plain text, and it cannot be added as a new reaction — joining one that is already there still works.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE/reactions \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"emoji": ":party_cat:"}'
{ "emoji": ":party_cat:", "count": 1, "mine": true, "emoji_id": "0b7c…", "url": "/uploads/5e1a….webp", "animated": false }Typing. Shows only briefly; repeat it while a long answer is being prepared.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/typing \
-H "Authorization: Bot $VOXA_TOKEN"
{ "ok": true }Event gateway
Events arrive over WebSocket: wss://api.voxavoice.ru/api/bot/v1/gateway. The token goes in the same Authorization: Bot vxb_… header. If your library cannot set headers (the browser WebSocket), pass it as ?token=vxb_…, but the header is safer: a URL with the token may end up in someone's logs along the way.
A refusal is a plain HTTP response, without switching to WebSocket, with the same body as requests: 401 — the token is wrong or revoked, 403 — the bot is disabled by the administration or blocked.
websocat -H "Authorization: Bot $VOXA_TOKEN" \
wss://api.voxavoice.ru/api/bot/v1/gatewayEvery frame is JSON {"t": type, "d": data}. The first one is ready:
{"t":"ready","d":{
"user": { "id": "3f6c9e1a-…", "username": "weather_bot", "display_name": "Погода", "avatar": null, "bot": true },
"servers": [ { "id": "c47a0e55-…", "name": "Дом", "icon": null, "owner_id": "91d2b7c4-…" } ],
"scopes": ["messages.read", "messages.write", "members.read"],
"heartbeat_secs": 30,
"gateway_version": 1,
"resume": { "last_seq": 1043, "at": "2026-09-24T12:00:00Z" }
}}| t | When | Needs |
|---|---|---|
message_create | A new message in a channel the bot can see; in a direct conversation — only with someone who allowed the bot to write (server_id: null) | messages.read |
message_update | A message was edited | messages.read |
message_delete | A message was deleted: {id, channel_id, server_id} | messages.read |
message_purge | A channel was cleared: {channel_id, server_id, count, ids}; ids: null — re-read the channel | messages.read |
message_reaction | A reaction was added or removed (for a server emoji — emoji_id, url) | messages.read |
message_pin | A message was pinned or unpinned | messages.read |
member_join | A member joined the server | members.read |
member_leave | A member left; about the bot itself — always | members.read |
member_update | A member's roles changed; about the bot itself — always | members.read |
user_update | A member changed their name or avatar | members.read |
channel_create, channel_update, channel_delete | Server channels | — |
category_create, category_update, category_delete | Categories | — |
role_create, role_update, role_delete | Roles | — |
server_create | The bot was added to a server: {id} | — |
server_update, server_delete | A server was changed or deleted | — |
interaction_create | Someone pressed a button, picked from a menu, ran a command or submitted a form — “Interactions and responses” | — |
dm_consent | Someone allowed or forbade the bot to write to them directly: {user, channel_id, allowed} | — |
pong | Reply to your ping | — |
disconnect | The server is closing the connection: {code, reason} | — |
Data shapes are the same as in request responses: message_create and message_update carry the whole message, member_join carries member. Direct messages come only from conversations with people who allowed the bot to write. Presence, typing, voice, calls, groups, friends and notifications never reach a bot.
{"t":"message_create","d":{"server_id":"c47a0e55-…","channel_kind":"text","message":{"id":"0d9f6a2b-…","seq":1043,"channel_id":"e5b81f07-…","content":"!погода Москва","attachments":[],"reply_to":null,"reply_preview":null,"created_at":"2026-09-24T12:00:00Z","edited_at":null,"author":{"id":"91d2b7c4-…","username":"kirill","display_name":"Кирилл","avatar":null}}}}
{"t":"message_reaction","d":{"server_id":"c47a0e55-…","channel_id":"e5b81f07-…","message_id":"5c2e90d1-…","emoji":"👍","user_id":"91d2b7c4-…","added":true,"count":3}}
{"t":"message_reaction","d":{"server_id":"c47a0e55-…","channel_id":"e5b81f07-…","message_id":"5c2e90d1-…","emoji":":party_cat:","user_id":"91d2b7c4-…","added":true,"count":1,"emoji_id":"0b7c…","url":"/uploads/5e1a….webp","animated":false}}
{"t":"member_join","d":{"server_id":"c47a0e55-…","member":{"user":{"id":"a18e…","username":"lena","display_name":"Лена","avatar":null},"nickname":null,"joined_at":"2026-09-24T12:05:00Z","role_ids":[]}}}
{"t":"server_create","d":{"id":"5e07…"}}
{"t":"disconnect","d":{"code":4004,"reason":"token_rotated"}}Liveness. The server sends a WebSocket Ping every 30 seconds, and libraries answer it on their own. Any frame from you keeps the connection alive; after 75 seconds of silence the server closes it with code 4008. You can also check the link with a ping frame. Other incoming frames are ignored; a frame over 4 KB closes the connection with code 1009.
→ {"t":"ping"}
← {"t":"pong","d":{}}| Code | Why | What to do |
|---|---|---|
| 4003 | The bot was disabled by the administration | Do not reconnect; the owner got a notification with the reason |
| 4004 | The token was replaced or revoked | Do not reconnect with this token |
| 4005 | The bot was deleted | Stop |
| 4006 | The bot or its owner is blocked | Stop |
| 4007 | The owner changed the bot's scopes | Reconnect at once and re-read GET /me |
| 4008 | 75 seconds of silence | Reconnect |
| 4009 or a drop without a frame | The bot was not reading events fast enough, a sixth connection pushed out an old one, or the server restarted | Reconnect after a pause and catch up on history |
After reconnecting, missed events are not replayed — read them back from history with after=<last seq>, as described in “Catching up”. Reconnect with a growing pause — from a second to a minute, with random jitter, so that clients do not hit the server all at once.
A Node.js bot that answers !ping with pong:
// npm i ws — Node 18+; fetch is built in
import WebSocket from "ws";
const TOKEN = process.env.VOXA_TOKEN;
const API = "https://api.voxavoice.ru/api/bot/v1";
const auth = { Authorization: `Bot ${TOKEN}` };
const ws = new WebSocket("wss://api.voxavoice.ru/api/bot/v1/gateway", { headers: auth });
ws.on("message", async (raw) => {
const { t, d } = JSON.parse(raw.toString());
if (t !== "message_create") return;
const m = d.message;
if (m.author.bot) return; // never answer bots — ourselves included
if (m.content.trim() !== "!ping") return;
await fetch(`${API}/channels/${m.channel_id}/messages`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ content: "pong", reply_to: m.id }),
});
});
ws.on("close", (code) => console.log("gateway closed", code));Direct messages
A bot can write to a person directly — but only to someone who allowed it. Permission is given by the person, and only by an explicit action: the “Allow to message me” or “Message” button in the bot's profile, their own message to the bot, a command or a button press in the conversation with the bot. They can forbid it at any time — in the bot's profile or in the chat menu; blocking the bot forbids it too. There is no separate scope for direct messages: they are available to a bot with messages.read and messages.write.
A person can always write to a bot — no friendship needed. Without permission a bot can neither open a conversation nor answer in it: the request gets a 403. Groups stay closed to bots.
| Method and path | Scope | What it does |
|---|---|---|
POST /dms | messages.read and messages.write | Open a conversation with someone who allowed it: {"user_id"}. The response has channel_id, user and created (new or already existing) |
GET /dms?after=&limit= | messages.read | Who allowed the bot to write: in ascending id order, 1 to 200 at a time (100 by default), with channel_id (null — no conversation yet), granted_at and last_seq — the seq of the conversation's last message (null — no messages) |
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/dms \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": "91d2b7c4-…"}'
{
"channel_id": "b3a1c2d4-…",
"user": { "id": "91d2b7c4-…", "username": "kirill", "display_name": "Кирилл", "avatar": null },
"created": true
}curl -s "https://api.voxavoice.ru/api/bot/v1/dms?limit=100" \
-H "Authorization: Bot $VOXA_TOKEN"
[
{
"user": { "id": "91d2b7c4-…", "username": "kirill", "display_name": "Кирилл", "avatar": null },
"channel_id": "b3a1c2d4-…",
"granted_at": "2026-09-24T18:00:00Z"
}
]From there on it is the usual requests: POST /channels/{channel_id}/messages, PATCH /messages/{id}, POST /channels/{id}/typing. The gateway delivers events only from your own conversations and only with people who allowed it: message_create, message_update, message_delete, message_reaction with server_id: null. When a person allows or forbids, you get dm_consent, addressed to you alone. The bot never learns why; after a refusal the conversation stays with the person, and the bot stops seeing it.
{"t":"dm_consent","d":{"user":{"id":"91d2b7c4-…","username":"kirill","display_name":"Кирилл","avatar":null},"channel_id":"b3a1c2d4-…","allowed":true}}
{"t":"message_create","d":{"server_id":null,"message":{"id":"7e0c5a11-…","seq":12,"channel_id":"b3a1c2d4-…","content":"какая завтра погода?","attachments":[],"reply_to":null,"reply_preview":null,"created_at":"2026-09-24T18:00:05Z","edited_at":null,"author":{"id":"91d2b7c4-…","username":"kirill","display_name":"Кирилл","avatar":null}}}}
{"t":"dm_consent","d":{"user":{"id":"91d2b7c4-…","username":"kirill","display_name":"Кирилл","avatar":null},"channel_id":"b3a1c2d4-…","allowed":false}}Conversations started by the bot. If the person has not written or pressed anything in the conversation for more than 24 hours, the bot's message counts as starting a new conversation and falls under a strict limit: no more than 60 an hour per bot and no more than 10 a day to one person. New conversations the bot opens itself (POST /dms) — no more than 30 an hour. Answers to what the person has just written or pressed are not limited this way. Over the limit — a 429.
Permission is not an invitation to broadcast. Write what the person asked for: an answer, a reminder, a result. Advertising and mailings nobody asked for are forbidden by the Developer Terms, and bots get disabled for them.
Slash commands
A person types / in the message box — the app shows the commands of this channel's bots with descriptions and option hints, and a picker for a member, channel or role. The call reaches the bot as interaction_create with type: "command" (see “Interactions and responses”). Above the bot's answer the feed shows who ran which command: “Kirill · /weather”.
Registration. The program sends its whole list of commands at once — usually on every start. Commands with the same names keep their id, the ones missing from the list are deleted, all in one transaction. The same list is not rewritten and not announced to people, so registering on every start is safe. Commands need no scope.
| Method and path | What it does |
|---|---|
GET /commands | The bot's global commands — for all its servers and direct messages |
PUT /commands | Replace all global commands with a list (up to 100) |
GET /servers/{id}/commands | Commands for this server only |
PUT /servers/{id}/commands | Replace this server's commands with a list (up to 100); the bot must be on the server |
DELETE /commands/{id} | Delete one of its commands — global or per-server |
| Field | What it is |
|---|---|
name | Name: lowercase Latin and Cyrillic letters, digits, _ and -, up to 32 characters — weather, погода |
description | Menu description, 1–100 characters |
dm | Whether to offer the command in direct messages with the bot. Global commands only. true by default, and false for a command with required_permissions: there is no need to write dm: false next to permissions |
required_permissions | Permission bits (for example, 2 — manage the server): without them a person neither sees nor runs the command. null — everyone who can write in the channel. There are no permissions in direct messages, so such a command is not offered there, and an explicit dm: true together with permissions is a 400 |
options | Options, up to 25; required ones go before optional ones |
| Field | What it is |
|---|---|
type | string, integer, number, boolean, user, channel or role |
name, description | As for a command; the name is unique within the command |
required | Whether it is required; false by default |
choices | Up to 25 {name, value} choices — only for string, integer and number; the person picks from a list |
min_value, max_value | Number bounds, at most 2^53 in absolute value |
min_length, max_length | Text length bounds: 0–1000, 0 and 1000 by default |
[
{
"name": "погода",
"description": "Погода в городе",
"options": [
{ "type": "string", "name": "город", "description": "Какой город",
"required": true, "min_length": 2, "max_length": 100 },
{ "type": "string", "name": "когда", "description": "Сегодня или завтра",
"choices": [ { "name": "Сегодня", "value": "today" }, { "name": "Завтра", "value": "tomorrow" } ] }
]
},
{
"name": "настроить",
"description": "Куда присылать прогноз на этом сервере",
"dm": false,
"required_permissions": 2,
"options": [
{ "type": "channel", "name": "канал", "description": "Канал для прогноза", "required": true }
]
}
]curl -s -X PUT https://api.voxavoice.ru/api/bot/v1/commands \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d @commands.json
[
{
"id": "0a9e41c3-…",
"bot_id": "3f6c9e1a-…",
"server_id": null,
"name": "погода",
"description": "Погода в городе",
"dm": true,
"required_permissions": null,
"options": [ … ],
"created_at": "2026-09-24T18:00:00Z",
"updated_at": "2026-09-24T18:00:00Z"
},
…
]- A per-server command overrides a global command with the same name on its server.
- A bot's commands are shown only to people who can write in the channel, and only if the bot itself sees the channel and may write there.
- A mistake in the list is a 400 that names the place (the text is in Russian).
- People's menus refresh on their own: they get an event after the list changes. The list can be changed at most 20 times an hour.
Buttons and menus
Buttons and select menus go under a bot message through the components field when sending or editing. Up to 5 rows; a row holds up to 5 buttons or one menu; up to 25 elements per message.
| style | Looks like and when to use |
|---|---|
primary | Accent — the main action |
secondary | Plain; the default |
success | Green — yes, done, count me in |
danger | Red — delete, cancel, finish |
link | A link: opens url after the person confirms; presses never reach the bot |
| Field | What it is |
|---|---|
label | Button label, 1–80 characters; a button needs a label or an emoji |
emoji | One emoji, up to 16 characters, no letters or digits (a digit only as a keycap emoji: 1️⃣) |
custom_id | 1–100 characters, unique within the message; the bot gets it on a press. Link buttons have none |
url | Only for link: http or https, up to 512 characters |
disabled | Disabled: visible but cannot be pressed |
placeholder | Menu label while nothing is picked, up to 150 characters |
min_values, max_values | How many menu options to pick: 0 to 25 and 1 to 25, 1 and 1 by default |
options | Menu options, 1–25: label 1–100, value 1–100 (unique in the menu), description up to 100, emoji, default — picked in advance |
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"content": "Идём в кино в пятницу?",
"components": [
{ "type": "row", "components": [
{ "type": "button", "style": "success", "label": "Иду", "emoji": "🎬", "custom_id": "movie:yes" },
{ "type": "button", "style": "secondary", "label": "Не смогу", "custom_id": "movie:no" },
{ "type": "button", "style": "link", "label": "Афиша", "url": "https://example.com/afisha" }
]},
{ "type": "row", "components": [
{ "type": "select", "custom_id": "movie:time", "placeholder": "Во сколько удобно?",
"min_values": 1, "max_values": 2,
"options": [
{ "label": "18:00", "value": "18" },
{ "label": "20:00", "value": "20" },
{ "label": "22:00", "value": "22", "description": "Поздний сеанс" }
] }
]}
]
}
EOFEditing. PATCH /messages/{id} takes content, embeds and components, each optional: a missing field stays as it was, [] removes it. If only the buttons changed (disabled, recoloured), the message is not marked as edited: the words are the same.
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/messages/$MESSAGE \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"components": []}'Everyone who sees the message sees its custom_id. Do not put secrets there and do not treat it as a pass: check the permissions of whoever pressed by member in the event. If the bot punishes someone or gives roles at a person's request, check that person's rank too (see “Moderation”).
A message with buttons only, without text or embeds, is refused (400): old app versions do not show buttons and would draw an empty message.
Embeds
An embed is a styled block inside a message: a linked title, text with formatting, fields, images, a footer and a coloured stripe on the left. Up to 10 embeds per message, in the embeds field.
| Field | Limit |
|---|---|
title | Up to 256 characters |
description | Up to 4096; formatting as in messages |
url | Title link: http or https |
color | A number 0xRRGGBB from 0 to 16777215; none — a neutral stripe |
fields | Up to 25: name 1–256, value 1–1024, inline — side by side instead of stacked |
thumbnail.url, image.url | A thumbnail on the right and an image below the text: https |
footer | text 1–2048 and icon_url |
author | name 1–256, url and icon_url |
timestamp | RFC 3339 time — shown in the footer |
- All embed text in one message — no more than 6000 characters. An embed with no title, text, fields, images or author is a 400.
- Links — http and https only, with no login or password in the address.
- Images — https only or a file on this same server
/uploads/…: the bot's own file, an open Voxa image (an avatar, an icon, an emoji) or an attachment from a channel the bot can see; otherwise a 400. A person sees an external image after pressing “Show” or if they turned on “always show”: otherwise every embed would report to the bot owner the address of anyone who scrolled past it. - Formatting is the same as in messages: bold,
code, links like[text](address). HTML does not work.
{
"embeds": [
{
"author": { "name": "Погода", "icon_url": "https://example.com/sun.png" },
"title": "Москва, завтра",
"url": "https://example.com/moscow",
"description": "**+12°**, дождь после обеда",
"color": 5793266,
"fields": [
{ "name": "Ветер", "value": "5 м/с", "inline": true },
{ "name": "Влажность", "value": "80%", "inline": true }
],
"thumbnail": { "url": "https://example.com/rain.png" },
"footer": { "text": "Данные обновляются раз в час" },
"timestamp": "2026-09-24T18:00:00Z"
}
]
}Text for old versions. If a message has no text but has embeds, the server builds content from them: author, title, description, fields as “name: value”, footer; for a picture-only embed — the picture link (or the word «Картинка» if it is one of your uploads). Notifications, search and old app versions show this text; new versions do not draw it — meta.fallback is true. For the example above:
Погода
Москва, завтра
**+12°**, дождь после обеда
Ветер: 5 м/с
Влажность: 80%
Данные обновляются раз в часInteractions and responses
A button press, a menu pick, a command and a form submission reach the bot as interaction_create — addressed to it, no scope needed. It carries who (user, and on a server also member with roles and permissions in this channel), where (server_id, channel_id), what (data), the message with the button (message), the bot's own permissions in the channel (app_permissions) and a one-time response token.
{"t":"interaction_create","d":{
"id": "5b1f7c2e-…",
"token": "vxi_…",
"type": "command",
"server_id": "c47a0e55-…",
"channel_id": "e5b81f07-…",
"user": { "id": "91d2b7c4-…", "username": "kirill", "display_name": "Кирилл", "avatar": null },
"member": { "nickname": null, "role_ids": [], "permissions": 16512 },
"app_permissions": 16512,
"data": {
"command_id": "0a9e41c3-…",
"name": "погода",
"options": [ { "name": "город", "type": "string", "value": "Москва" } ],
"resolved": { "users": {}, "members": {}, "channels": {}, "roles": {} }
},
"message": null,
"expires_at": "2026-09-24T18:15:00Z",
"respond_by": "2026-09-24T18:00:03Z"
}}// нажатие кнопки или выбор в меню — "type": "component", в "message" — сообщение с кнопкой
"data": { "custom_id": "movie:time", "component_type": "select", "values": ["18", "20"] }
// отправка формы — "type": "modal_submit"
"data": { "custom_id": "feedback", "fields": [ { "custom_id": "text", "value": "Всё понравилось" } ] }Three seconds. The bot has 3 seconds for its first answer (respond_by). Miss it and the person sees “The bot did not answer”, while a late answer gets a 409. If the work takes long, say “thinking” right away (defer) and fill in the answer later: the token lives for 15 minutes (expires_at). If the bot is offline, the person is told so at once and no event is sent.
| type | When allowed | What happens |
|---|---|---|
reply | Always | A bot message in the channel (an answer to a command carries a “who ran it” line) or, with "ephemeral": true, “only you can see this”. data: content, embeds, components, ephemeral |
defer | Always | The person sees “{bot} is thinking…”; the answer itself comes via PATCH …/original. data: ephemeral |
update | A press; a form opened by a press | Edits the message with the button, without the edited mark. data: content, embeds, components |
defer_update | Same as update | The button stops waiting; the edit comes later via PATCH …/original |
modal | A command, a press | Opens a form for the person — see “Forms” |
| Method and path | Body | What it does |
|---|---|---|
POST /interactions/{id}/callback | {"token", "type", "data"} | The first answer — exactly one; a second gets a 409 |
PATCH /interactions/{id}/original | {"token", "content", "embeds", "components"} | After defer — create the answer, after reply or update — edit it |
POST /interactions/{id}/followup | {"token", "content", "embeds", "components", "ephemeral"} | One more answer — up to 5 per interaction |
DELETE /interactions/{id}/original | {"token"} | Remove the original answer once it has served its purpose (“Cancelled”, “Done”): a channel message is deleted, an “only you can see this” answer disappears for the person. For update the original answer is the message with the button itself. Extra answers are still possible afterwards; editing the original is not |
- Needs the
messages.writescope. Without it presses and commands never reach the bot: the person is told at once that the bot cannot answer. The token goes in the request body, not in the URL: URLs end up in logs. - An answer in the channel passes the same checks as a normal send: the bot's permissions in the channel, blocked words, the per-channel message limit.
- Answers to interactions are counted apart from writes: 600 a minute per bot, so a poll with fifty voters does not hit the limit.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/callback \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"token": "vxi_…", "type": "reply", "data": {"embeds": [{"title": "Москва", "description": "**+12°**, облачно", "color": 5793266}]}}'
{ "ok": true, "message": { "id": "8d2f…", "content": "Москва\n**+12°**, облачно", "meta": { "kind": "bot", "v": 1, … }, … } }The “pressed → updated” loop. A person pressed a button under a message — the bot edits that same message and separately answers only them:
# человек нажал «Иду» под сообщением $MESSAGE — отмечаем и выключаем кнопку
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/callback \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"token": "vxi_…",
"type": "update",
"data": {
"content": "Идём в кино в пятницу? Уже идут: 3",
"components": [
{ "type": "row", "components": [
{ "type": "button", "style": "success", "label": "Иду", "emoji": "🎬", "custom_id": "movie:yes" },
{ "type": "button", "style": "secondary", "label": "Не смогу", "custom_id": "movie:no" }
]}
]
}
}
EOF
# и отдельно — только нажавшему
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/followup \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"token": "vxi_…", "content": "Вы в списке!", "ephemeral": true}'# сначала «думаю» — у человека строка «Погода думает…»
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/callback \
-H "Authorization: Bot $VOXA_TOKEN" -H "Content-Type: application/json" \
-d '{"token": "vxi_…", "type": "defer"}'
# потом, в пределах 15 минут, — сам ответ
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/original \
-H "Authorization: Bot $VOXA_TOKEN" -H "Content-Type: application/json" \
-d '{"token": "vxi_…", "content": "В Москве завтра +12, дождь после обеда"}'curl -s -X DELETE https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/original \
-H "Authorization: Bot $VOXA_TOKEN" -H "Content-Type: application/json" \
-d '{"token": "vxi_…"}'
{ "ok": true }// the same bot.mjs: answering commands and buttons
ws.on("message", async (raw) => {
const { t, d } = JSON.parse(raw.toString());
if (t !== "interaction_create") return;
// 3 seconds for the first answer. Slow work — send {"type": "defer"} first.
const answer =
d.type === "command" && d.data.name === "weather"
? { type: "reply", data: { content: "Moscow: +12, cloudy" } }
: { type: "reply", data: { content: "This button no longer works", ephemeral: true } };
await fetch(`${API}/interactions/${d.id}/callback`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, ...answer }),
});
});Forms
A form opens for the person as an answer to a press or a command (type: "modal"). Its submission reaches the bot as a new interaction_create with type: "modal_submit"; a form submission cannot be answered with another form.
| Field | Limit |
|---|---|
custom_id | 1–100 characters — comes back with the submission |
title | Title, 1–45 characters |
fields | 1 to 5 input fields |
fields[].custom_id, label | 1–100 (unique in the form) and a label of 1–45 |
fields[].style | short — one line, paragraph — a paragraph; short by default |
fields[].required | Whether it is required; true by default |
fields[].placeholder | Hint in an empty field, up to 100 characters |
fields[].min_length, max_length | 0–4000 and 1–4000, 0 and 4000 by default |
fields[].value | Prefilled value, no longer than max_length |
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/interactions/$INTERACTION/callback \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"token": "vxi_…",
"type": "modal",
"data": {
"custom_id": "feedback",
"title": "Отзыв о сеансе",
"fields": [
{ "custom_id": "text", "label": "Что понравилось?", "style": "paragraph",
"placeholder": "Пара слов", "max_length": 1000 },
{ "custom_id": "score", "label": "Оценка от 1 до 5", "style": "short",
"required": false, "max_length": 1 }
]
}
}
EOFThe server checks a submission against the form it issued: exactly those fields, required ones filled, lengths within bounds. Values arrive trimmed, an empty optional field as an empty string. A form is single-use, issued to one person and lives for 15 minutes.
Only you can see this
An answer with "ephemeral": true (in reply, defer or followup) is seen only by whoever pressed or ran the command. It is not stored, does not reach history, search, notifications or unread counts, and disappears after the app restarts. Such an answer can have buttons too: their presses arrive with message.ephemeral: true, update edits the answer itself, and DELETE …/original removes it for the person.
Use “only you can see this” for confirmations, errors, personal lists and settings — the channel stays clean. Buttons and commands exist only in new app versions, so such an answer always reaches the person who pressed.
Moderation
A bot can kick and ban members, give and take roles, and mute, disconnect and move people in voice channels — when both its scopes and its role's permissions on the server allow it. The rules are those of a person with the same role, checked by the same code: a bot gets no extra leeway and no extra bans.
| Method and path | Scope and role permission | What it does |
|---|---|---|
DELETE /servers/{id}/members/{user_id}?reason= | members.manage, “Kick members” | Kick a member |
GET /servers/{id}/bans | members.manage, “Ban members” | The server's ban list |
PUT /servers/{id}/bans/{user_id} | members.manage, “Ban members” | Ban; the {"reason"} body is optional. A ban also kicks a member; you can ban someone who is not on the server too |
DELETE /servers/{id}/bans/{user_id}?reason= | members.manage, “Ban members” | Lift a ban |
PUT /servers/{id}/members/{user_id}/roles/{role_id}?reason= | roles.manage, “Manage roles” | Give one role. role_ids in the answer lists all of the member's roles after the change |
DELETE /servers/{id}/members/{user_id}/roles/{role_id}?reason= | roles.manage, “Manage roles” | Take one role |
PATCH /servers/{id}/members/{user_id}/voice | members.manage, “Mute in voice” | A member's voice: mute, deafen, channel_id (a channel id — move, null — disconnect), reason |
The bot role's place decides everything. A bot kicks, bans, gives and takes roles only for people whose highest role is below the bot's highest role, and gives only roles below its own that carry no permission it lacks. The bot role appears at the very bottom of the list: until an administrator moves it up in “Server settings” → “Roles”, the bot cannot act on anyone who has at least one role. The refusal is a 403 with the same text a person gets, for example “you can only give and take roles below your highest”. Nobody can kick or ban the server owner.
- The reason (
reason, up to 500 characters) goes to the server's audit log. Every action of the bot is logged under its name, with the BOT tag: administrators see who did it and why. - A bot acts with its own role, not the role of whoever asked. When a kick, ban or role comes from a command or a button, the server checks the hierarchy against the bot's role, which is usually above the moderators'. Check both the permission and the rank of whoever ran it yourself: their highest role (
member.role_idsin the event, positions fromGET /servers/{id}, a lower position ranks higher) must be strictly above the target's highest role (data.resolved.members), and leave the server owner alone. Otherwise a junior moderator can punish a senior one through the bot. SeemayPunishin the library's starter bot. - Roles one at a time. A bot cannot replace all of a member's roles at once, so a “take a role” button never races a person who is editing that member's roles at the same second. Giving a role the member already has, or taking one they lack, is not an error: the answer is the same and no new log entry appears.
- Voice goes step by step:
mutefirst, thendeafen, thenchannel_id. If a step fails, the following ones are skipped and the finished ones stay. At least one field is required. The member must be in a voice channel of this server, otherwise 409. “Mute in voice” is needed in the member's channel; to move someone, the bot must also see the destination channel and be allowed to connect to it. - In voice, an equal role is no obstacle. A bot can mute, deafen, undo either, move and disconnect a member whose highest role sits level with the bot's role. Not one whose role is higher (a 403 refusal), and never the server owner. Kicking and banning still work only on members strictly below. For roles the server checks the role itself, not the member: a bot can give or take only roles below its own highest role, whatever the rank of the member receiving it. Human moderators follow the same rules.
- A ban does not delete old messages — people cannot do that either.
$MEMBERand$ROLEin the examples are a member id and a role id (see “Finding IDs”).
curl -s -G -X DELETE https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members/$MEMBER \
-H "Authorization: Bot $VOXA_TOKEN" \
--data-urlencode "reason=реклама в личных"
{ "ok": true }curl -s -X PUT https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/bans/$MEMBER \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "спам ссылками"}'
{ "ok": true }
# снять бан — причина параметром, как у исключения
curl -s -G -X DELETE https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/bans/$MEMBER \
-H "Authorization: Bot $VOXA_TOKEN" \
--data-urlencode "reason=договорились"curl -s https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/bans \
-H "Authorization: Bot $VOXA_TOKEN"
[
{
"user": { "id": "a18e…", "username": "spammer", "display_name": "Спамер", "avatar": null },
"reason": "спам ссылками",
"banned_by": "3f6c9e1a-…",
"created_at": "2026-09-25T12:00:00Z",
"expires_at": null
}
]curl -s -X PUT https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members/$MEMBER/roles/$ROLE \
-H "Authorization: Bot $VOXA_TOKEN"
{ "ok": true, "role_ids": ["7b1c…", "e40a…"] }
# снять — DELETE по тому же адресу
curl -s -X DELETE https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members/$MEMBER/roles/$ROLE \
-H "Authorization: Bot $VOXA_TOKEN"
{ "ok": true, "role_ids": ["e40a…"] }# заглушить для всех и перенести в другой голосовой канал
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members/$MEMBER/voice \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mute": true, "channel_id": "9c0d…", "reason": "шум в микрофоне"}'
{ "ok": true }
# отключить от голоса
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/members/$MEMBER/voice \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel_id": null}'Managing a server
With the channels.manage, roles.manage and server.manage scopes a bot creates and edits channels, their permissions and roles, as well as the server's name, icon, banner and description. As with moderation, the role permissions are needed too: “Manage channels”, “Manage roles”, “Manage server”.
| Method and path | Scope | Body and answer |
|---|---|---|
PATCH /servers/{id} | server.manage | name, icon, banner, description — any of them; "" clears. The icon and banner are a /uploads/… link to a file the bot uploaded (the address from the upload answer works as it is; see “Files”). Answer — the server |
POST /servers/{id}/channels | channels.manage | name, kind (text or voice), topic, category_id. Answer — the channel |
PATCH /channels/{id} | channels.manage | name, topic, user_limit, is_news — any of them. Answer — the channel |
DELETE /channels/{id} | channels.manage | Delete a channel together with its messages |
PUT /channels/{id}/overwrites/{kind}/{target_id} | channels.manage | Channel permissions for one role (kind: role) or one person (user): the {"allow", "deny"} body holds permission bits |
DELETE /channels/{id}/overwrites/{kind}/{target_id} | channels.manage | Remove the channel permissions of that role or person |
POST /servers/{id}/roles | roles.manage | name, color, permissions, color2, gradient_anim. Answer — the role |
PATCH /roles/{id} | roles.manage | The same fields — any of them. Answer — the role |
DELETE /roles/{id} | roles.manage | Delete a role |
- Channel permissions — one role or one person at a time. A bot cannot replace them all at once: it only sees role entries and its own, and a full replacement would wipe the personal settings of people it cannot see. Channel permissions for another person work only for someone whose role is below the bot's, and never for the server owner. A bot can grant or take only permissions it has in that channel.
- Roles — only below its own. A new role lands at the very bottom of the list and can be given right away. A bot does not edit its own role or @everyone. A role's permissions cannot exceed the bot's own; the administrator permission is never granted.
- A bot sees channels and roles in
GET /servers/{id}; @everyone hasis_default: truethere — that is$EVERYONEin the example below. - Categories and the order of channels and roles are not managed by bots yet.
curl -s -X PATCH https://api.voxavoice.ru/api/bot/v1/servers/$SERVER \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Клуб настолок", "description": "Играем по пятницам"}'
{ "id": "c47a0e55-…", "name": "Клуб настолок", "icon": null, "banner": null, "description": "Играем по пятницам", "owner_id": "91d2b7c4-…" }curl -s -X POST https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/channels \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "объявления", "kind": "text", "topic": "Только важное"}'# в «объявлениях» @everyone читает, но не пишет: 128 — «Отправка сообщений»
curl -s -X PUT https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/overwrites/role/$EVERYONE \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"allow": 0, "deny": 128}'
{ "ok": true }
# убрать это переопределение
curl -s -X DELETE https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/overwrites/role/$EVERYONE \
-H "Authorization: Bot $VOXA_TOKEN"curl -s -X POST https://api.voxavoice.ru/api/bot/v1/servers/$SERVER/roles \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Игрок", "color": "#3ba55d"}'
{ "id": "e40a…", "server_id": "c47a0e55-…", "name": "Игрок", "color": "#3ba55d", "permissions": 0, "position": 8, "is_default": false, … }Role buttons
There is no special mechanism: it is a button plus giving a role. A command posts a message with custom_id: "role:<role id>" buttons. On a press the bot checks the id against its own list and gives the role (PUT …/roles/{role_id}), or takes it (DELETE) if the person already has it. The answer is seen only by whoever pressed.
- You need the
roles.managescope, the “Manage roles” permission and a bot role above the roles it hands out. - Hand out roles without special permissions: “Reader”, “Player”, a name colour. The server will not let the bot give a role with a permission the bot lacks.
- Everyone who sees the message sees the button and its
custom_id, so always check the role id against your own list. - Whether the person already has the role is in
member.role_idsof the event — no extra request needed.
// the same bot.mjs: /roles posts buttons, a press gives or takes the role
const ROLES = { "7b1c…": "Reader", "e40a…": "Player" }; // YOUR list: IDs via “Copy ID”
async function api(method, path, body) {
const r = await fetch(`${API}${path}`, {
method,
headers: { ...auth, ...(body ? { "Content-Type": "application/json" } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const data = await r.json();
if (!r.ok) throw new Error(data.error);
return data;
}
ws.on("message", async (raw) => {
const { t, d } = JSON.parse(raw.toString());
if (t !== "interaction_create") return;
const answer = (data) => api("POST", `/interactions/${d.id}/callback`, { token: d.token, type: "reply", data });
if (d.type === "command" && d.data.name === "roles") {
const buttons = Object.entries(ROLES).map(([id, label]) => ({ type: "button", label, custom_id: `role:${id}` }));
return answer({ content: "Press to take a role or drop it:", components: [{ type: "row", components: buttons }] });
}
if (d.type === "component" && d.data.custom_id.startsWith("role:")) {
const roleId = d.data.custom_id.slice("role:".length);
// everyone can see custom_id — check it against your own list, don't trust the button
if (!ROLES[roleId] || !d.member) return answer({ content: "This button no longer works", ephemeral: true });
const has = d.member.role_ids.includes(roleId);
const path = `/servers/${d.server_id}/members/${d.user.id}/roles/${roleId}?reason=${encodeURIComponent("role button")}`;
try {
await api(has ? "DELETE" : "PUT", path);
await answer({ content: has ? "Role removed" : "Role given", ephemeral: true });
} catch (e) {
// the server's refusal, e.g. the role is above the bot's own
await answer({ content: e.message, ephemeral: true });
}
}
});Files
A file is uploaded first and attached to a message after. Upload — POST /uploads: multipart/form-data with a file field, scope messages.write. The answer describes the file — put it as it is into attachments when sending (POST /channels/{id}/messages), up to 10 files per message. A message may consist of files only.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/uploads \
-H "Authorization: Bot $VOXA_TOKEN" \
-F "file=@chart.png;type=image/png"
{
"url": "/uploads/5e1a9c….png?e=1790524800&s=q4…",
"name": "chart.png",
"size": 48213,
"mime": "image/png",
"width": 1200,
"height": 630,
"thumb": "/uploads/5e1a9c….t.jpg?e=1790524800&s=Hk…"
}- The rules are those for people: the same bans and the same image checks. A file is up to 50 MB, or up to 10 MB while the bot owner's account is less than a week old; the owner's Voxa+ does not raise the bot's limits. A bot uploads at most 2 GB and 300 files a day. If the owner is barred from uploading files, their bots cannot upload either.
- To attach a file, the bot's role needs “Attach files” in the channel. A bot can attach its own file or one that is already an attachment in a channel the bot can see.
- A file that never gets attached is deleted after about a day.
- Upload refusals come as a 403 with a text you can show a person (in Russian): the file failed the automatic check (the text carries a case code); the file was sent for review by a person and is not published yet, so it cannot be attached; the owner is barred from uploading files.
- Editing a message does not change its files. Answers to interactions and commands carry no files yet: answer the interaction, then send the file as a normal message.
- File addresses are temporary. In the upload answer, in messages (
GET /channels/{id}/messages) and in gateway events an attachment's address looks like/uploads/<name>?e=…&s=…and works for no longer than a day. Download a file by a fresh address; if the server answers 404, re-read the message (GET /channels/{id}/messages?around=<seq>). Store the message id, not the address. Sending a signed address back is fine — put the upload answer intoattachmentsas it is, and any form of the file's address works for an embed image: the server drops the parameters itself. Permanent addresses without parameters belong only to open images: avatars, server icons and banners, emoji and stickers.
Your own image in an embed. Upload the image, attach it to the same message and point the embed at the same url — in image, thumbnail, footer.icon_url or author.icon_url. The app shows the image in the embed and does not repeat it among the attachments. The file lives as long as the message: delete the message and the file goes too. In the message the embed image comes as a full temporary address (https://api.voxavoice.ru/uploads/…?e=…&s=…) and the attachment as a relative one; compare them by the file name.
curl -s -X POST https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages \
-H "Authorization: Bot $VOXA_TOKEN" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"content": "Продажи за неделю",
"attachments": [
{ "url": "/uploads/5e1a9c….png?e=1790524800&s=q4…", "name": "chart.png", "size": 48213,
"mime": "image/png", "width": 1200, "height": 630,
"thumb": "/uploads/5e1a9c….t.jpg?e=1790524800&s=Hk…" }
],
"embeds": [
{ "title": "Неделя 39", "image": { "url": "/uploads/5e1a9c….png?e=1790524800&s=q4…" }, "color": 3908957 }
]
}
EOFWebhooks
A webhook is an address another program uses to post messages into a channel: a build, monitoring, a form on a website. No bot and no token are needed — a single POST to the address is enough. Only text channels of servers have webhooks; there are no outgoing webhooks.
- Create one: channel settings → the “Webhooks” tab → “Create webhook”, a name and, optionally, an image. You need the “Manage webhooks” permission in that channel.
- The address is shown once, like a bot token — copy it right away. Anyone who has the address can post into the channel as the webhook: keep it in secrets, not in code. If it leaks, press “Change address” — the old one stops working at once.
- The same tab renames and deletes webhooks. Messages already posted stay.
- Up to 10 webhooks per channel and 50 per server. Creating, changing and deleting them shows up in the server's audit log.
curl -s -X POST "$VOXA_WEBHOOK" \
-H "Content-Type: application/json" \
-d '{"content": "Сборка №412 прошла"}'
{ "id": "8d2f…", "channel_id": "e5b81f07-…" }| Field | What it is |
|---|---|
content | Text, up to 4000 characters |
embeds | Embeds — the same schema and limits as for bots (see “Embeds”) |
username | A name for this message, 1–32 characters, without the word Voxa. Missing — the webhook's name |
avatar_url | An image for this message — an open Voxa image (/uploads/…: an avatar, an icon, an emoji). A message attachment does not fit here — the webhook's image is shown instead. Missing — the webhook's image |
curl -s -X POST "$VOXA_WEBHOOK" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"username": "Мониторинг",
"embeds": [
{
"title": "api.example.com не отвечает",
"description": "Три проверки подряд — **таймаут**",
"color": 15548997,
"fields": [ { "name": "С", "value": "12:04", "inline": true } ],
"timestamp": "2026-09-25T12:04:00Z"
}
]
}
EOFcontentorembedsis required. Webhooks have no buttons or menus — nobody would handle a press; a field missing from the table is a 400.- An author image from another website is not allowed on purpose: the app loads it by itself, and that website would learn who reads the channel and when. Embeds may use outside images — people open them with the “Show” button.
- Embed images of a webhook are an https address or an open Voxa image. A webhook has no files of its own, and message attachments do not fit (400).
- The message carries a WEBHOOK tag. The name and image are those at sending time: renaming the webhook does not change old messages. If the image is later replaced or deleted, old messages may lose it.
- A message cannot be edited or deleted through the webhook address; channel moderators can delete it like any message.
- A wrong, changed or deleted address answers the same: 404 “webhook not found — it was deleted or its address changed” (the text is in Russian).
- Limits: 5 messages per 5 seconds per address, 30 a minute per channel from all webhooks, 120 requests a minute to webhook addresses from one IP. The body is up to 256 KB.
# GitHub Actions: адрес — в секретах репозитория, а не в файле
- name: Сообщить в Voxa
if: always()
run: |
curl -s -X POST "${{ secrets.VOXA_WEBHOOK }}" -H "Content-Type: application/json" \
-d "{\"content\": \"Сборка ${{ github.run_number }}: ${{ job.status }}\"}"A bot sees a webhook message as an ordinary message_create: the author has bot: true, and meta.webhook holds the webhook's id, name and image. As with bots, there is usually no need to answer webhooks.
{"t":"message_create","d":{"server_id":"c47a0e55-…","channel_kind":"text","message":{
"id": "8d2f…",
"seq": 1190,
"content": "Сборка №412 прошла",
"author": { "id": "0f3a…", "username": "webhook_0f3a…", "display_name": "Сборки", "avatar": null, "bot": true },
"meta": { "kind": "bot", "v": 1, "webhook": { "id": "2b9e41d0-…", "name": "Сборки", "avatar": null } },
…
}}}Catching up
While a program is offline, events are not queued for it — what it missed is read back from history. Every message has a seq, and it is shared by all channels: a program only needs to remember one number, the largest seq it has seen.
GET /channels/{id}/messages?after=<seq>&limit=100— messages withseqaboveafter, in ascending order. Together withbeforeoraroundit is a 400.- The
readyframe hasresume.last_seq— the last number at connection time. Everything up to it is read from history; everything newer arrives as live events.nullmeans there were no messages yet or the mark could not be read: read the history to the end then. GET /servers/{id}haslast_seqs— the lastseqof every channel visible to the bot that has messages. Channels with nothing new can be skipped.- Direct messages:
GET /dmsgives the conversations'channel_idandlast_seq; read history withafteronly wherelast_seqis above what you remember. - If
resume.last_seqis not above what you remember, nothing was missed anywhere — there is nothing to go through. - A message can arrive both as a live event and from history — drop duplicates by
id. - Edits and deletions made while the program was offline are not restored.
# всё, что пришло в канал после seq 1043, по возрастанию
curl -s "https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages?after=1043&limit=100" \
-H "Authorization: Bot $VOXA_TOKEN"// в ready — отметка: всё, что не новее last_seq, дочитывается историей
"resume": { "last_seq": 1187, "at": "2026-09-25T12:10:00Z" }
// в GET /servers/{id} — последний seq каждого видимого канала, где есть сообщения
"last_seqs": { "e5b81f07-…": 1187, "0c44…": 1102 }The order: connect → receive ready → GET /servers/{id} for every server → read the channels whose last_seqs is above what you remember, page by page with after, up to resume.last_seq → the same for direct messages whose last_seq is above it. Do not rush: the pass spends the same read window (120 a minute) as the program's other requests and the gateway reconnect. The JavaScript library from the next section does all of this for you.
JavaScript library
voxa.js is our library for Node.js and TypeScript, with no dependencies. It connects to the gateway and reconnects by itself, catches up on what was missed, answers interactions in one line, builds buttons, embeds and forms, uploads files and wraps moderation. It needs Node 18 or newer; Node 22+ has everything built in, Node 18–21 also needs the ws package.
It is not in the npm registry — it is an archive on this site: voxa-js.tgz. Inside are the TypeScript sources, ready JavaScript, a detailed README (in Russian) and a starter bot: role buttons, /kick, a report file and catching up after a restart.
npm i https://voxavoice.ru/files/voxa-js.tgz
# Node 18–21: ещё npm i ws и new VoxaClient({ token, WebSocket })// bot.mjs — run: VOXA_TOKEN=vxb_… node bot.mjs (Node 22+)
import { VoxaClient, ui } from "voxa.js";
const bot = new VoxaClient({ token: process.env.VOXA_TOKEN });
bot.on("message", async (msg, { replayed }) => {
if (msg.author.bot || msg.content.trim() !== "!ping") return;
await bot.send(msg.channel_id, { content: replayed ? "pong (late)" : "pong", reply_to: msg.id });
});
bot.on("interaction", async (ix) => {
if (ix.isCommand && ix.commandName === "movie") {
await ix.reply({
content: "Going on Friday?",
components: [ui.row(ui.button("I'm in", "movie:yes", { style: "success" }))],
});
}
if (ix.customId === "movie:yes") await ix.reply("Noted!", { ephemeral: true });
});
await bot.commands.set([{ name: "movie", description: "Invite to the movies" }]);
await bot.connect();- Events:
ready,message(caught-up ones havereplayed: true),messageUpdate,messageDelete,interaction,memberJoin,memberLeave,disconnect,raw,error. - Interaction answers:
reply,defer,update,deferUpdate,modal,editReply,deleteReply,followup— the same as in “Interactions and responses”. - Helpers:
send,edit,upload,kick,ban,unban,addRole,removeRole,voice,createChannel,setOverwrite,createRole,editServerand more; for anything else —bot.rest.get,postand so on. - The catch-up mark can be kept between runs:
new VoxaClient({ token, resume: { load, save } }). Caught-up messages arrive channel by channel — each channel in ascendingseqas soon as it is read. After the first ten requests the pass goes no faster than one request a second, leaving the read window to the program. If it fails (a server or network error), the library retries after 5 s, 30 s, 2 and 10 minutes without waiting for a reconnect. - Codes 4003–4006 stop the gateway, 4007 reconnects at once, anything else reconnects after a pause of 1 to 30 seconds. On a 429 the library waits for
retry_atonce and repeats the request.
Voice
A bot with the voice scope joins voice channels of servers and plays sound there: music, sound effects, announcements. The bot does not hear people — their voice is never passed to it. Bots are not in direct messages, groups or calls, and they have no video or screen sharing. The bot's sound goes through the Voxa server: the bot never connects to people directly.
People see the bot in the channel as a participant with a “BOT” badge and a ring while it sounds, with a caption under its name such as “Playing: …”. Everyone sets the bot's volume for themselves and can turn it off. Moderators mute, disconnect and move the bot with the same menu items as people. The bot is seen and heard in recent versions of the app; older versions do not show it.
Turning it on. Tick “Play sound in voice channels” (voice) for the bot. The bot can join a channel if its role there has “View channel” and “Connect”; to be heard it also needs “Speak”. @everyone has these by default. Without “Speak” the bot joins but stays silent: you get muted with the reason no_speak.
Connecting. Voice is a separate WebSocket: wss://api.voxavoice.ru/api/bot/v1/voice. The token goes only in the Authorization: Bot vxb_… header: an address with ?token= is not accepted here. A socket has at most one session, that is one server; to play on several servers, open a socket for each. Refusals come before connecting, as plain HTTP with a {"error": "…"} body:
| Status | When |
|---|---|
| 401 | No Authorization: Bot … header or the token is not accepted |
| 403 | The token lacks the voice scope; the bot is disabled or blocked; “voice for bots is turned off by Voxa” — not for good: try again every 10 minutes |
| 429 | The bot already has 12 voice sockets — wait until retry_at |
| 503 | The Voxa server is busy right now — retry with a growing pause |
| 400 | A plain request instead of a WebSocket |
Right after connecting the server sends hello with the protocol numbers. The program sends join with a voice channel ID, the server answers joined — or error with a code and a text. A session lasts until the program sends leave, the bot is disconnected or its reason to be in the channel is gone; the socket stays, and you can join again. A join into another channel of the same server moves the same session.
← {"t":"hello","d":{"version":1,"heartbeat_secs":30,"packet_ms":[10,20,40,60],"max_packets":5,"max_packet_bytes":1275,"max_message_bytes":4096,"max_messages_per_sec":25,"lead_ms":400,"bitrate_slack":1.15,"bitrate_burst_secs":4}}
→ {"t":"join","d":{"channel_id":"5d1e…"}}
← {"t":"joined","d":{"server_id":"c47a0e55-…","channel_id":"5d1e…","session":17,"bitrate":48000,"humans":3,"hearing":2,"muted":false,"muted_reason":null,"resumed":false}}
→ {"t":"caption","d":{"text":"Playing: Moonlight Sonata"}}
→ (binary) a sound batch — about every 60 ms
← {"t":"listeners","d":{"humans":4,"hearing":3}}
← {"t":"muted","d":{"on":true,"reason":"moderator"}}
← {"t":"muted","d":{"on":false,"reason":null}}
→ {"t":"leave","d":{}}
← {"t":"left","d":{"reason":"self","channel_id":"5d1e…"}}| Frame | Meaning |
|---|---|
hello | Protocol numbers: packet durations, batch limits, the recommended lead lead_ms, bitrate slack |
joined | The bot is in the channel. bitrate is this session's sound cap in bit/s; humans — people in the channel; hearing — how many of them hear the bot now (the bot never learns who); resumed: true — the previous session continues after a drop |
listeners | humans and hearing changed — at most once a second |
muted | The bot was muted for everyone (on: true, reason: moderator or no_speak — the role lacks “Speak”) or unmuted. The server passes a muted bot's sound to nobody — pause your track |
moved | A moderator moved the bot to another channel of the same server — keep playing |
bitrate | The server level changed; the new cap applies from the next join |
left | The session ended. reason: self, kicked (with retry_after_secs), removed — the bot was taken off the server, no_access — the role lost access to the channel, channel_deleted, server_deleted, alone — no people for 5 minutes, replaced — the bot joined voice on this server from another socket, admin, disabled — voice for bots is turned off |
slow_down | The server dropped sound: reason — too_fast, bitrate or too_many_messages; at most once in 2 seconds |
bad_packet | The server dropped a batch with broken packets: reason — batch, toc, structure or duration; at most once in 5 seconds |
error | A refusal of a program frame: code, message (a text for people, in Russian), retry_after_secs |
pong | The answer to ping |
| Frame | What it does |
|---|---|
{"t":"join","d":{"channel_id":"…"}} | Join a voice channel; the same server — move, another server — the socket's previous session ends |
{"t":"leave","d":{}} | Leave voice |
{"t":"caption","d":{"text":"…"}} | A caption under the bot: one line up to 80 characters; null removes it |
{"t":"ping","d":{}} | A sign of life: after 75 seconds without a single frame the socket is closed with 4008 |
| code | When |
|---|---|
forbidden | The bot's role lacks “View channel” or “Connect” in this channel, or the bot is not on the server |
not_found, not_voice | No such channel; not a voice channel of a server |
disabled | Voice for bots is turned off by Voxa |
limit_channel, limit_server, limit_bot | Two bots are already in the channel; three already play on the server; the bot already plays on ten servers |
busy | The Voxa server is busy right now — try later |
kicked_recently | The bot was disconnected from voice on this server: it may come back after retry_after_secs |
rate_limited | Too often: more than 10 joins a minute on a server or more than 10 text frames a second |
not_joined | Sound or a caption without a session — join first |
bad_request | An unknown frame or a caption longer than 80 characters |
| Code | What happened |
|---|---|
| 4003 | The bot was disabled by the administration |
| 4004 | A new token was issued or the token was revoked |
| 4005 | The bot was deleted |
| 4006 | The bot or its owner is blocked |
| 4007 | The owner changed the bot's scopes — reconnect right away; without voice the socket will not open again |
| 4008 | 75 seconds without a single frame from the program |
| 4009 | Something failed on our side — reconnect after a pause |
| 1009 | A binary message over 4096 bytes or a text over 2048 |
Before the close comes a disconnect frame with the same code and a reason. If the connection dropped, reconnect and send join into the same channel: within 15 seconds the server continues the same session (resumed: true), and people never see the bot leave.
Sound. A binary message is a batch of Opus packets. The server numbers the packets, the program does not send numbers: after a restart just keep going.
offset bytes what
0 1 version = 1
1 1 N — packets in the batch, 1 to 5
2 … N times: packet length (2 bytes, little-endian, 1–1275) and the Opus packet
the whole message is up to 4096 bytes, not a single byte after the N-th packet- Opus at 48 kHz, mono or stereo. A packet lasts 10, 20, 40 or 60 ms (20 is best) and weighs at most 1275 bytes. The server checks every packet against RFC 6716: a broken packet would crash the listeners' decoders.
- A batch holds 1 to 5 packets — best 3 of 20 ms, and no less than 40 ms of sound; the whole message is up to 4096 bytes. The server takes at most 25 messages a second (
too_many_messages). - Pace by real time. Keep at most 400 ms of sound ahead at the server, 200 is better: count time from the start and send a batch when its sound ends no further than that lead. Faster — the server drops the excess (
too_fast). - Loudness — about −16 LUFS. Harsh or deliberately loud sound that disturbs a conversation is forbidden by the Developer terms.
| Server level | The bot's sound cap |
|---|---|
| 0 | 32 kbit/s |
| 1 | 48 kbit/s |
| 2 | 64 kbit/s |
| 3 and the official Voxa server | 96 kbit/s |
The cap comes in joined.bitrate and stays until the session ends. The server lets through up to 1.15 × the cap and bursts of up to 4 seconds of the cap; heavier is dropped (bitrate). bad_packet reasons: batch — the batch itself does not parse; toc — the packet is empty or its header describes an impossible packet; structure — frame lengths do not match the packet size; duration — the packet is valid but lasts other than 10, 20, 40 or 60 ms. dropped in slow_down and bad_packet is how many messages were dropped since the previous report. A correct program never gets these frames.
Getting Opus. ffmpeg converts any file: 20 ms packets, a bitrate no higher than the server's cap. The rights to the music are on the bot's owner: play only your own music or music you have the rightsholder's permission for (Developer terms).
ffmpeg -i song.mp3 -c:a libopus -b:a 48k -vbr constrained -frame_duration 20 song.oggWhere to join. A bot with the voice scope gets member.voice_channel_id in interaction_create — the voice channel on this server of whoever ran the command or pressed the button. null — the person is not in voice or the bot's role cannot see that channel; bots without voice do not get the field at all. The bot cannot learn where a person sits without their command.
"member": {
"nickname": null,
"role_ids": ["7a3d…"],
"permissions": 16512,
"voice_channel_id": "5d1e…"
}- Up to 2 bots in a channel and 3 on a server; one bot plays on at most 10 servers at once and has up to 12 voice sockets.
- Up to 10 joins (
join) a minute on each server; up to 10 text frames a second. - Disconnected from a channel — the bot may come back after a minute; if it happens again within an hour — after 10 minutes, then after an hour.
- A bot alone in a channel for over 5 minutes is taken out by the server (
leftwith the reasonalone). - A caption is one line up to 80 characters, at most once in 5 seconds: if more often, the last one applies.
Example for Node 24 without packages: plays one .ogg file in the CHANNEL channel, pauses the track while the bot is muted and leaves when the file ends.
// music.mjs — run: VOXA_TOKEN=vxb_… CHANNEL=<voice channel ID> node music.mjs song.ogg
// Node 24, no packages: WebSocket with headers is built in.
import { readFileSync } from "node:fs";
import { basename } from "node:path";
const LEAD_MS = 200; // keep at most 200 ms of sound ahead at the server
// 1. Ogg → Opus packets: pages and lacing (255 means “to be continued”).
function opusPackets(file) {
const b = readFileSync(file);
const out = [];
let part = [];
for (let pos = 0; pos < b.length; ) {
if (b.toString("latin1", pos, pos + 4) !== "OggS") throw new Error("not an Ogg file");
const n = b[pos + 26];
let off = pos + 27 + n;
for (const len of b.subarray(pos + 27, pos + 27 + n)) {
part.push(b.subarray(off, off + len));
off += len;
if (len < 255) { out.push(Buffer.concat(part)); part = []; }
}
pos = off;
}
return out.slice(2); // skip OpusHead and OpusTags
}
// Packet duration from its first byte (RFC 6716), ms.
function packetMs(p) {
const c = p[0] >> 3;
const frame = c < 12 ? [10, 20, 40, 60][c % 4] : c < 16 ? [10, 20][c % 2] : [2.5, 5, 10, 20][c % 4];
const code = p[0] & 3;
return frame * (code === 0 ? 1 : code === 3 ? p[1] & 0x3f : 2);
}
// 2. A batch: version 1, packet count, then each packet's length (2 bytes LE) and the packet.
function batch(list) {
const buf = Buffer.alloc(2 + list.reduce((s, p) => s + 2 + p.length, 0));
buf[0] = 1;
buf[1] = list.length;
let o = 2;
for (const p of list) { buf.writeUInt16LE(p.length, o); p.copy(buf, o + 2); o += 2 + p.length; }
return buf;
}
const packets = opusPackets(process.argv[2]);
const ws = new WebSocket("wss://api.voxavoice.ru/api/bot/v1/voice", {
headers: { Authorization: "Bot " + process.env.VOXA_TOKEN }, // header only
});
const send = (t, d = {}) => ws.send(JSON.stringify({ t, d }));
let pos = 0; // next packet not yet sent: resume from it after a pause
let timer = null;
let done = false;
// 3. Pace by the sound clock from the start, so timer errors do not add up.
function play() {
const start = Date.now();
let sent = 0; // ms of sound sent since this start
const tick = () => {
while (pos < packets.length) {
const list = packets.slice(pos, pos + 3); // 3 × 20 ms
const ms = list.reduce((s, p) => s + packetMs(p), 0);
if (start + sent + ms - LEAD_MS > Date.now()) break;
ws.send(batch(list));
pos += list.length;
sent += ms;
}
// the last sound is still ahead of the clock and buffered by listeners — leave once it has played
if (pos >= packets.length) {
timer = setTimeout(() => send("leave"), Math.max(0, start + sent - Date.now()) + 500);
return;
}
timer = setTimeout(tick, 20);
};
tick();
}
const pause = () => clearTimeout(timer);
ws.addEventListener("open", () => send("join", { channel_id: process.env.CHANNEL }));
ws.addEventListener("message", (e) => {
const { t, d } = JSON.parse(e.data);
if (t === "joined") {
console.log("in the channel: cap " + d.bitrate / 1000 + " kbit/s, " + d.hearing + " of " + d.humans + " hear the bot");
send("caption", { text: ("Playing: " + basename(process.argv[2], ".ogg")).slice(0, 80) });
if (!d.muted) play();
}
if (t === "muted") d.on ? pause() : play(); // muted for everyone — pause; unmuted — go on
if (t === "left") { done = true; console.log("left: " + d.reason); ws.close(); }
if (t === "error" || t === "slow_down" || t === "bad_packet") console.warn(t, d);
});
const ping = setInterval(() => send("ping"), 30_000); // otherwise 75 s of silence means 4008
ws.addEventListener("close", (e) => {
pause();
clearInterval(ping);
if (!done) console.warn("connection closed: " + e.code); // 4003–4009 — see the table above
});Finding IDs
Every person, bot, server, channel, message and role in Voxa has a permanent ID. Names and @usernames can change, IDs never do — so a bot meant “for our people only” should check the ID.
- In the Voxa app open “Settings” → “Bots” and turn on Developer mode in the “For developers” section.
- Right-click (on a phone, press and hold) a person, server, channel or message and choose “Copy ID”. On a phone the server ID is in the server menu: tap the server name above the channel list.
- A role's ID — the same gesture on its tag in a person's profile or on its row in the server settings “Roles” tab. @everyone has an ID too, but it never appears in
role_ids: check “any member of the server” byserver_id. Your own bot's ID — the “ID” button on its card in the “Bots” tab.
| What | In `message_create` | In `interaction_create` |
|---|---|---|
| Person | d.message.author.id | d.user.id |
| Server | d.server_id — null in direct messages | d.server_id — null in direct messages |
| Channel | d.message.channel_id | d.channel_id |
| Person's roles | — | d.member.role_ids — member: null in direct messages |
| Message | d.message.id | d.message.id — for a button press |
To answer only your people and only on your server, compare IDs at the top of the handler. message_create carries no roles; decide who may run a command by member.role_ids in interaction_create, and send the refusal so that only the person who pressed sees it:
// IDs come from the app: turn on developer mode and choose “Copy ID”
const HOME_SERVER = "c47a0e55-…"; // where the bot works
const FRIENDS = new Set(["91d2b7c4-…", "a18e…"]); // who it answers
const DJ_ROLE = "7a3d…"; // who may run /music
ws.on("message", async (raw) => {
const { t, d } = JSON.parse(raw.toString());
if (t === "message_create") {
if (d.server_id !== HOME_SERVER) return; // another server or a direct message (null)
if (!FRIENDS.has(d.message.author.id)) return; // not on the list — stay silent
// …then as in the “Event gateway” example
}
if (t === "interaction_create" && d.type === "command" && d.data.name === "music") {
// member exists only on a server; in direct messages it is null
const allowed = d.server_id === HOME_SERVER && d.member?.role_ids.includes(DJ_ROLE);
const answer = allowed
? { type: "reply", data: { content: "Playing" } }
: { type: "reply", data: { content: "This command is for the DJ role only", ephemeral: true } };
await fetch(`${API}/interactions/${d.id}/callback`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, ...answer }),
});
}
});Limits
| What | How much |
|---|---|
Reads (GET, including connecting to the gateway) | 120 a minute per bot |
Writes (POST, PUT, PATCH, DELETE), except answers to interactions — uploads, moderation and management included | 30 a minute per bot |
| Messages into one channel | 5 per 5 seconds |
| Gateway connections | 5 at once; a sixth pushes out the oldest |
| Message length | 4000 characters |
| Bots on one server | 20 |
| Bots per person | 5 |
New direct conversations the bot opens itself (POST /dms) | 30 an hour |
| Direct messages where the person has been silent for over 24 hours | 60 an hour per bot and 10 a day to one person |
Answers to interactions (/interactions/…) | 600 a minute per bot, apart from writes |
| First answer to an interaction | 3 seconds |
Response token, PATCH and DELETE …/original, followup | 15 minutes; up to 5 extra answers |
Re-registering commands (PUT) | 20 an hour; the very same list does not count |
| Commands | up to 100 in a list, 25 options per command, 25 choices per option |
| Buttons and menus | up to 5 rows, a row holds up to 5 buttons or one menu, up to 25 per message |
| Embeds | up to 10 per message, up to 6000 characters of text in them |
| Presses by one person | 30 a minute |
| Moderation: kicks, bans, member roles, voice | 20 a minute per bot on each server |
| Server changes: channels, their permissions, roles, settings | 10 a minute per bot on each server |
| A file | up to 50 MB, up to 10 MB while the owner's account is less than a week old; 2 GB and 300 files a day per bot |
| Files in a message | up to 10 |
| Webhooks | up to 10 per channel and 50 per server |
| Messages to a webhook address | 5 per 5 seconds per address, 30 a minute per channel; 120 requests a minute from one IP |
| Voice: bots in a channel and on a server | 2 and 3; one bot plays on at most 10 servers at once |
| Voice: sockets and joins | 12 voice sockets per bot; 10 joins (join) a minute on each server |
| Voice: sound | up to 25 messages a second; the bitrate cap depends on the server level, see “Voice” |
Over a limit you get a 429 with the time you may continue. Wait until retry_at instead of retrying right away:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{ "error": "слишком много запросов бота, подождите 12 с", "retry_at": "2026-09-24T12:00:12Z" }Errors
An error is an HTTP status and a body {"error": "text"}. The text is written for people (in Russian) and may change: programs should decide by the status.
| Status | When |
|---|---|
| 400 | Bad request: an empty or too long message, a field that is not described, before, around and after together; nothing to change in voice, or the destination is not a voice channel of this server; wrong buttons, embeds, form or commands — the text names the place; an answer of the wrong type: update to something other than a press, a form answering a form, an edit or extra answer after modal (answer the form submission instead) |
| 401 | No token, a wrong or revoked token, a scheme other than Bot |
| 403 | The token lacks a scope or the bot's role lacks a permission; a file failed the check or was sent for review (see “Files”); the member's role or the role being given is not below the bot's role; the channel is hidden from the bot; a direct conversation with someone who did not allow the bot to write, or a group; a block; the bot is disabled |
| 404 | No such server, channel, message or role; the interaction is unknown or its token has expired; the webhook address is wrong, changed or the webhook is deleted |
| 409 | The interaction was already answered or the first answer came too late (3 seconds); a first answer is needed first; there is no original answer yet (after defer) or it has already been deleted; the member is not in a voice channel of this server |
| 429 | A limit was exceeded — wait until retry_at; the “no more than 5 extra answers” cap has no retry_at |
| 5xx | Something failed on our side — retry later, after a pause |
| Voice | Voice socket refusals before connecting, error, slow_down and bad_packet frames and close codes — see “Voice” |
Mentions and good manners
A mention in Voxa is plain text @name in a message. To notice that someone is talking to your bot, look for its @name in content — case-insensitive, as a separate word. To call a person, write their @username in the reply. @everyone in a bot's message stays plain text: a bot does not notify all members.
- Do not answer bots. A bot's message has
author.botset totrue— skip those, or two bots will talk forever. Skip your own messages too. - Answer commands and mentions, not everything. A bot that comments on every message is spam.
- Answer with a reply (
reply_to) so it is clear what you are answering. - Do not mention people for no reason and do not post where nobody asked the bot.
- Answer a press right away. Need time —
defer, not silence: otherwise the person sees “The bot did not answer”. - Confirmations, errors and personal lists — “only you can see this”: the channel stays clean.
- Register commands on every start — the server does not rewrite the same list.
- Say in the bot's description what it does and whether it stores anything.
- Store only what the bot cannot work without, and delete it once the bot is removed from a server or a person forbids it to write to them directly.
What is not allowed
The details are in the Developer Terms. In short: the owner answers for the bot, and bots follow the same rules as people. Do not:
- pass a bot off as a person, as the Voxa administration or as an official bot;
- spam: mass or repeated messages, advertising without the server owner's consent;
- play sound in voice without the rights to it, disturb a conversation with harsh or deliberately loud sound, pass the bot's sound off as a particular person's voice;
- hoard messages and member lists “just in case”, sell or hand over people's data;
- lure out passwords, codes or tokens — including with buttons and forms — or ask for payment details in them;
- obtain permission to write directly by deceit or in exchange for anything; send advertising or mailings nobody asked for to direct messages;
- get around limits — with several bots or copies of the program — or around blocks, for example by creating a new bot instead of a disabled one;
- mass-kick, mass-ban or delete channels without a decision of the server's administration;
- hunt for and use vulnerabilities. Found one — write to legal@voxavoice.ru.
For a violation the administration disables the bot: the program loses access at once (close code 4003), and the owner gets a notification with the reason. A disabled bot can be appealed through a support request.
Documents
- Developer Terms — accepted when creating a bot.
- Privacy Policy — section 7: what bot owners receive.
- Community Rules — they apply to bots too.
The legally binding documents exist in Russian only. Questions about the API — help@voxavoice.ru.