— for developers

Bot API

A bot is a Voxa account driven by your program. This page has everything you need to write one: how to get a token, which requests exist, how to listen to events and where the limits are.

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 with bot: for example, weather_bot. The word voxa is 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:

Request header
Authorization: Bot vxb_…
  • Tokens start with vxb_, which lets leak scanners recognise them. The Bearer scheme 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.

Checking the token
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.

Bot scopes
ScopeAllows
messages.readReading channel history and receiving message and reaction events
messages.writeSending, editing and deleting its own messages, showing “typing”
messages.manageDeleting other people's messages — where the bot's role may manage messages
reactionsAdding and removing reactions
members.readSeeing the member list and member events
members.manageKicking and banning members, managing them in voice channels — see “Moderation”
roles.manageGiving and taking roles, creating and editing roles below its own
channels.manageCreating, editing and deleting channels and their permissions — see “Managing a server”
server.manageChanging the server's name, icon, banner and description
voicePlaying 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

API requests
Method and pathScopeWhat 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.readMembers 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.readHistory: 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}/messagesmessages.writeSend 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.writeEdit its own message: content, embeds, components — any of them
DELETE /messages/{id}own — messages.write, others' — messages.manageDelete a message
GET /messages/{id}/reactionsmessages.readReactions and the first 30 people behind each
POST /messages/{id}/reactionsreactionsAdd a reaction: emoji (a symbol or a server emoji's :name:) or emoji_id
DELETE /messages/{id}/reactions?emoji= or ?emoji_id=reactionsRemove its own reaction
POST /channels/{id}/typingmessages.writeShow “typing” in a channel
GET /gateway—The WebSocket event gateway — next section
GET /voicevoiceThe voice socket over WebSocket: join a voice channel and play sound — see “Voice”
POST /dms, GET /dmsmessages.write, messages.readOpen 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 …/followupmessages.writeAnswers to presses, commands and forms — “Interactions and responses”
POST /uploadsmessages.writeUpload a file — see “Files”
…/members/{user_id}, …/bans/…, …/roles/{role_id}, …/voicemembers.manage, roles.manageKicks, bans, member roles, voice — see “Moderation”
PATCH /servers/{id}, …/channels, …/overwrites/…, …/rolesserver.manage, channels.manage, roles.manageThe 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:

GET /me
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:

GET /servers
curl -s https://api.voxavoice.ru/api/bot/v1/servers \
  -H "Authorization: Bot $VOXA_TOKEN"

[
  { "id": "c47a0e55-…", "name": "Дом", "icon": null, "owner_id": "91d2b7c4-…" }
]
GET /servers/{id}
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:

GET /servers/{id}/members
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.

GET /channels/{id}/messages
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.

POST /channels/{id}/messages
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.

PATCH /messages/{id}
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, облачно"}'
DELETE /messages/{id}
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:

POST /messages/{id}/reactions
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 }
DELETE /messages/{id}/reactions
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 }
GET /messages/{id}/reactions
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.

POST /messages/{id}/reactions — server emoji
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.

POST /channels/{id}/typing
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.

Connecting from a terminal (websocat)
websocat -H "Authorization: Bot $VOXA_TOKEN" \
  wss://api.voxavoice.ru/api/bot/v1/gateway

Every frame is JSON {"t": type, "d": data}. The first one is ready:

The ready frame
{"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" }
}}
Gateway events
tWhenNeeds
message_createA 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_updateA message was editedmessages.read
message_deleteA message was deleted: {id, channel_id, server_id}messages.read
message_purgeA channel was cleared: {channel_id, server_id, count, ids}; ids: null — re-read the channelmessages.read
message_reactionA reaction was added or removed (for a server emoji — emoji_id, url)messages.read
message_pinA message was pinned or unpinnedmessages.read
member_joinA member joined the servermembers.read
member_leaveA member left; about the bot itself — alwaysmembers.read
member_updateA member's roles changed; about the bot itself — alwaysmembers.read
user_updateA member changed their name or avatarmembers.read
channel_create, channel_update, channel_deleteServer channels—
category_create, category_update, category_deleteCategories—
role_create, role_update, role_deleteRoles—
server_createThe bot was added to a server: {id}—
server_update, server_deleteA server was changed or deleted—
interaction_createSomeone pressed a button, picked from a menu, ran a command or submitted a form — “Interactions and responses”—
dm_consentSomeone allowed or forbade the bot to write to them directly: {user, channel_id, allowed}—
pongReply to your ping—
disconnectThe 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.

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

ping and pong
→ {"t":"ping"}
← {"t":"pong","d":{}}
Close codes
CodeWhyWhat to do
4003The bot was disabled by the administrationDo not reconnect; the owner got a notification with the reason
4004The token was replaced or revokedDo not reconnect with this token
4005The bot was deletedStop
4006The bot or its owner is blockedStop
4007The owner changed the bot's scopesReconnect at once and re-read GET /me
400875 seconds of silenceReconnect
4009 or a drop without a frameThe bot was not reading events fast enough, a sixth connection pushed out an old one, or the server restartedReconnect 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:

bot.mjs
// 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.

Direct message requests
Method and pathScopeWhat it does
POST /dmsmessages.read and messages.writeOpen 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.readWho 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)
POST /dms
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
}
GET /dms
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.

Direct conversation frames
{"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.

Command requests
Method and pathWhat it does
GET /commandsThe bot's global commands — for all its servers and direct messages
PUT /commandsReplace all global commands with a list (up to 100)
GET /servers/{id}/commandsCommands for this server only
PUT /servers/{id}/commandsReplace 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
Command fields
FieldWhat it is
nameName: lowercase Latin and Cyrillic letters, digits, _ and -, up to 32 characters — weather, погода
descriptionMenu description, 1–100 characters
dmWhether 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_permissionsPermission 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
optionsOptions, up to 25; required ones go before optional ones
Option fields
FieldWhat it is
typestring, integer, number, boolean, user, channel or role
name, descriptionAs for a command; the name is unique within the command
requiredWhether it is required; false by default
choicesUp to 25 {name, value} choices — only for string, integer and number; the person picks from a list
min_value, max_valueNumber bounds, at most 2^53 in absolute value
min_length, max_lengthText length bounds: 0–1000, 0 and 1000 by default
commands.json
[
  {
    "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 }
    ]
  }
]
PUT /commands
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.

Button styles
styleLooks like and when to use
primaryAccent — the main action
secondaryPlain; the default
successGreen — yes, done, count me in
dangerRed — delete, cancel, finish
linkA link: opens url after the person confirms; presses never reach the bot
Button and menu fields
FieldWhat it is
labelButton label, 1–80 characters; a button needs a label or an emoji
emojiOne emoji, up to 16 characters, no letters or digits (a digit only as a keycap emoji: 1️⃣)
custom_id1–100 characters, unique within the message; the bot gets it on a press. Link buttons have none
urlOnly for link: http or https, up to 512 characters
disabledDisabled: visible but cannot be pressed
placeholderMenu label while nothing is picked, up to 150 characters
min_values, max_valuesHow many menu options to pick: 0 to 25 and 1 to 25, 1 and 1 by default
optionsMenu options, 1–25: label 1–100, value 1–100 (unique in the menu), description up to 100, emoji, default — picked in advance
A message with buttons and a menu
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": "Поздний сеанс" }
        ] }
    ]}
  ]
}
EOF

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

Removing the buttons
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.

Embed fields
FieldLimit
titleUp to 256 characters
descriptionUp to 4096; formatting as in messages
urlTitle link: http or https
colorA number 0xRRGGBB from 0 to 16777215; none — a neutral stripe
fieldsUp to 25: name 1–256, value 1–1024, inline — side by side instead of stacked
thumbnail.url, image.urlA thumbnail on the right and an image below the text: https
footertext 1–2048 and icon_url
authorname 1–256, url and icon_url
timestampRFC 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.
A message with an embed
{
  "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:

Fallback text
Погода
Москва, завтра
**+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.

An interaction_create frame (a command)
{"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"
}}
data for a press and for a form
// нажатие кнопки или выбор в меню — "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.

Response types
typeWhen allowedWhat happens
replyAlwaysA 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
deferAlwaysThe person sees “{bot} is thinking…”; the answer itself comes via PATCH …/original. data: ephemeral
updateA press; a form opened by a pressEdits the message with the button, without the edited mark. data: content, embeds, components
defer_updateSame as updateThe button stops waiting; the edit comes later via PATCH …/original
modalA command, a pressOpens a form for the person — see “Forms”
Response requests
Method and pathBodyWhat 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.write scope. 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.
Answering a command with an embed
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:

update and followup
# человек нажал «Иду» под сообщением $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}'
defer and PATCH original
# сначала «думаю» — у человека строка «Погода думает…»
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, дождь после обеда"}'
DELETE original
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 }
bot.mjs — answers
// 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.

Form fields
FieldLimit
custom_id1–100 characters — comes back with the submission
titleTitle, 1–45 characters
fields1 to 5 input fields
fields[].custom_id, label1–100 (unique in the form) and a label of 1–45
fields[].styleshort — one line, paragraph — a paragraph; short by default
fields[].requiredWhether it is required; true by default
fields[].placeholderHint in an empty field, up to 100 characters
fields[].min_length, max_length0–4000 and 1–4000, 0 and 4000 by default
fields[].valuePrefilled value, no longer than max_length
Opening a form
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 }
    ]
  }
}
EOF

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

Moderation requests
Method and pathScope and role permissionWhat it does
DELETE /servers/{id}/members/{user_id}?reason=members.manage, “Kick members”Kick a member
GET /servers/{id}/bansmembers.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}/voicemembers.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_ids in the event, positions from GET /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. See mayPunish in 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: mute first, then deafen, then channel_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.
  • $MEMBER and $ROLE in the examples are a member id and a role id (see “Finding IDs”).
Kick with a reason
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 }
Ban and lift a ban
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=договорились"
GET /servers/{id}/bans
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
  }
]
Give and take a role
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…"] }
PATCH …/voice
# заглушить для всех и перенести в другой голосовой канал
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”.

Management requests
Method and pathScopeBody and answer
PATCH /servers/{id}server.managename, 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}/channelschannels.managename, kind (text or voice), topic, category_id. Answer — the channel
PATCH /channels/{id}channels.managename, topic, user_limit, is_news — any of them. Answer — the channel
DELETE /channels/{id}channels.manageDelete a channel together with its messages
PUT /channels/{id}/overwrites/{kind}/{target_id}channels.manageChannel 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.manageRemove the channel permissions of that role or person
POST /servers/{id}/rolesroles.managename, color, permissions, color2, gradient_anim. Answer — the role
PATCH /roles/{id}roles.manageThe same fields — any of them. Answer — the role
DELETE /roles/{id}roles.manageDelete 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 has is_default: true there — that is $EVERYONE in the example below.
  • Categories and the order of channels and roles are not managed by bots yet.
PATCH /servers/{id}
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-…" }
POST /servers/{id}/channels
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": "Только важное"}'
Channel permissions for one role
# в «объявлениях» @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"
POST /servers/{id}/roles
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.manage scope, 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_ids of the event — no extra request needed.
bot.mjs — role buttons
// 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.

POST /uploads
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 into attachments as 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.

A message with a file and an 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 @- <<'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 }
  ]
}
EOF

Webhooks

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.
Post a message
curl -s -X POST "$VOXA_WEBHOOK" \
  -H "Content-Type: application/json" \
  -d '{"content": "Сборка №412 прошла"}'

{ "id": "8d2f…", "channel_id": "e5b81f07-…" }
Request body
FieldWhat it is
contentText, up to 4000 characters
embedsEmbeds — the same schema and limits as for bots (see “Embeds”)
usernameA name for this message, 1–32 characters, without the word Voxa. Missing — the webhook's name
avatar_urlAn 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
An embed under another name
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"
    }
  ]
}
EOF
  • content or embeds is 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.
From a build
# 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.

A webhook message in the gateway
{"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 with seq above after, in ascending order. Together with before or around it is a 400.
  • The ready frame has resume.last_seq — the last number at connection time. Everything up to it is read from history; everything newer arrives as live events. null means there were no messages yet or the mark could not be read: read the history to the end then.
  • GET /servers/{id} has last_seqs — the last seq of every channel visible to the bot that has messages. Channels with nothing new can be skipped.
  • Direct messages: GET /dms gives the conversations' channel_id and last_seq; read history with after only where last_seq is above what you remember.
  • If resume.last_seq is 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.
History after a seq
# всё, что пришло в канал после seq 1043, по возрастанию
curl -s "https://api.voxavoice.ru/api/bot/v1/channels/$CHANNEL/messages?after=1043&limit=100" \
  -H "Authorization: Bot $VOXA_TOKEN"
resume and last_seqs
// в 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.

Installing
npm i https://voxavoice.ru/files/voxa-js.tgz
# Node 18–21: ещё npm i ws и new VoxaClient({ token, WebSocket })
bot.mjs with voxa.js
// 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 have replayed: 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, editServer and more; for anything else — bot.rest.get, post and 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 ascending seq as 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_at once 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:

Voice socket refusals
StatusWhen
401No Authorization: Bot … header or the token is not accepted
403The 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
429The bot already has 12 voice sockets — wait until retry_at
503The Voxa server is busy right now — retry with a growing pause
400A 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.

Voice socket frames
← {"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…"}}
Server frames
FrameMeaning
helloProtocol numbers: packet durations, batch limits, the recommended lead lead_ms, bitrate slack
joinedThe 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
listenershumans and hearing changed — at most once a second
mutedThe 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
movedA moderator moved the bot to another channel of the same server — keep playing
bitrateThe server level changed; the new cap applies from the next join
leftThe 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_downThe server dropped sound: reason — too_fast, bitrate or too_many_messages; at most once in 2 seconds
bad_packetThe server dropped a batch with broken packets: reason — batch, toc, structure or duration; at most once in 5 seconds
errorA refusal of a program frame: code, message (a text for people, in Russian), retry_after_secs
pongThe answer to ping
Program frames
FrameWhat 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
error codes
codeWhen
forbiddenThe bot's role lacks “View channel” or “Connect” in this channel, or the bot is not on the server
not_found, not_voiceNo such channel; not a voice channel of a server
disabledVoice for bots is turned off by Voxa
limit_channel, limit_server, limit_botTwo bots are already in the channel; three already play on the server; the bot already plays on ten servers
busyThe Voxa server is busy right now — try later
kicked_recentlyThe bot was disconnected from voice on this server: it may come back after retry_after_secs
rate_limitedToo often: more than 10 joins a minute on a server or more than 10 text frames a second
not_joinedSound or a caption without a session — join first
bad_requestAn unknown frame or a caption longer than 80 characters
Voice socket close codes
CodeWhat happened
4003The bot was disabled by the administration
4004A new token was issued or the token was revoked
4005The bot was deleted
4006The bot or its owner is blocked
4007The owner changed the bot's scopes — reconnect right away; without voice the socket will not open again
400875 seconds without a single frame from the program
4009Something failed on our side — reconnect after a pause
1009A 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.

A sound batch (binary)
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.
Bitrate cap by server level
Server levelThe bot's sound cap
032 kbit/s
148 kbit/s
264 kbit/s
3 and the official Voxa server96 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).

A file for the bot
ffmpeg -i song.mp3 -c:a libopus -b:a 48k -vbr constrained -frame_duration 20 song.ogg

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

interaction_create — member
"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 (left with the reason alone).
  • 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
// 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” by server_id. Your own bot's ID — the “ID” button on its card in the “Bots” tab.
Where IDs are in events
WhatIn `message_create`In `interaction_create`
Persond.message.author.idd.user.id
Serverd.server_id — null in direct messagesd.server_id — null in direct messages
Channeld.message.channel_idd.channel_id
Person's roles—d.member.role_ids — member: null in direct messages
Messaged.message.idd.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:

bot.mjs — for our people only
// 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

API limits
WhatHow 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 included30 a minute per bot
Messages into one channel5 per 5 seconds
Gateway connections5 at once; a sixth pushes out the oldest
Message length4000 characters
Bots on one server20
Bots per person5
New direct conversations the bot opens itself (POST /dms)30 an hour
Direct messages where the person has been silent for over 24 hours60 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 interaction3 seconds
Response token, PATCH and DELETE …/original, followup15 minutes; up to 5 extra answers
Re-registering commands (PUT)20 an hour; the very same list does not count
Commandsup to 100 in a list, 25 options per command, 25 choices per option
Buttons and menusup to 5 rows, a row holds up to 5 buttons or one menu, up to 25 per message
Embedsup to 10 per message, up to 6000 characters of text in them
Presses by one person30 a minute
Moderation: kicks, bans, member roles, voice20 a minute per bot on each server
Server changes: channels, their permissions, roles, settings10 a minute per bot on each server
A fileup 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 messageup to 10
Webhooksup to 10 per channel and 50 per server
Messages to a webhook address5 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 server2 and 3; one bot plays on at most 10 servers at once
Voice: sockets and joins12 voice sockets per bot; 10 joins (join) a minute on each server
Voice: soundup 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:

A 429 response
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.

Error statuses
StatusWhen
400Bad 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)
401No token, a wrong or revoked token, a scheme other than Bot
403The 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
404No 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
409The 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
429A limit was exceeded — wait until retry_at; the “no more than 5 extra answers” cap has no retry_at
5xxSomething failed on our side — retry later, after a pause
VoiceVoice 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.bot set to true — 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

The legally binding documents exist in Russian only. Questions about the API — help@voxavoice.ru.