Build a Voxara bot
Everything you need to write a bot for Voxara: creating one in the portal, the JavaScript SDK, the raw WebSocket protocol for any other language, and webhooks for when you only need to post.
Answer commands, greet people, run games. Sign in over a WebSocket and react to events.
Delete messages, time people out, kick, ban, lock threads, move people between voice channels.
CI results, GitHub events, cron jobs. One HTTPS POST to a webhook, no code that stays running.
What a bot is
A bot is a Voxara account you drive with code. It signs in with a token instead of a password, joins spaces like a member, and then talks to the server through exactly the same API the Voxara app uses: JSON frames over a WebSocket. Anything a member can do in a space, a bot with the same roles can do too.
There are two ways to build one:
- A bot keeps a WebSocket open. It receives every message and event in its spaces and can act on them. This is what you want for commands, moderation, or anything that reads messages.
- A webhook is one HTTPS URL bound to one text channel. POST some JSON to it and a message appears. Nothing to keep running, no sign-in, no library. Perfect for notifications. See Webhooks.
Bots carry a BOT tag everywhere they appear, take up a member slot in a space, and are subject to the space's moderation like anyone else.
Quick start
About five minutes, start to first reply.
1. Create the bot
Open the Developer Portal and sign in with your normal Voxara account. Click Create a bot, give it a display name and a username, and copy the token it shows you. It is shown once; you can reset it from the same page whenever you need to.
2. Add it to a space
Open the bot in the portal and use Add to a space. You can add it to any space you manage (you own it, or you hold a role with a management permission). A bot only sees the spaces it has been added to.
3. Get the SDK
The SDK is a single file with one dependency. In an empty folder:
# any Node.js 18 or newer
npm init -y
npm install ws
curl -O https://voxaraspace.com/developers/sdk/voxara-bot.js
Or download voxara-bot.js and drop it next to your script.
4. Write it
Save this as bot.js:
const { Client } = require('./voxara-bot');
const client = new Client();
client.on('ready', () => {
console.log(`Signed in as ${client.user.displayName}`);
});
client.on('messageCreate', async (message) => {
if (message.author.bot) return; // never answer a bot, yourself included
if (message.content === '!ping') await message.reply('pong');
});
client.login(process.env.VOXARA_BOT_TOKEN);
5. Run it
VOXARA_BOT_TOKEN=paste_your_token_here node bot.js
Type !ping in any channel of a space the bot is in. It replies pong. That is a working bot; everything below is what else it can do.
Tokens and safety
- The token is the account. Anyone who has it can post, delete and moderate as your bot, in every space it is in.
- Keep it in an environment variable or a secrets store, never in source code, never in a public repository, never in a screenshot.
- If it leaks, open the portal and Reset token. The old one stops working the same second.
- Bot tokens do not expire. A bot that goes offline can reconnect with the same token, which is why the SDK reconnects on its own.
- You can own up to 25 bots. Deleting a bot removes it from every space and invalidates its token.
Concepts
| Term | Meaning |
|---|---|
Space (guild in the API) | A community with members, roles, categories and channels. The API keeps the older name guild in ids and op names. |
| Channel | Lives inside a space. type is text, voice, forum (every post is a thread) or thread. Only text channels and threads carry messages a bot can post in. |
| Thread | A channel of type thread hanging off a message (text channel) or standing alone as a post (forum). Threads have parentChannelId, can be archived by their creator, and locked by moderators. |
| DM | A private conversation. It has a channel id like anything else. A bot can open a DM with someone who shares a space with it; people cannot DM bots. |
| Role | A named bundle of permissions with a position. A bot's abilities come from the roles a space gives it, exactly like a member. Moderation only works on people whose highest role is below the bot's. |
| Message | Text, attachments, embeds, reactions, a reply pointer, a thread pointer. See Object shapes. |
| Op | One request to the server, named like message:send. Every op gets exactly one reply. |
| Event | Something the server pushes without being asked: a new message, a member joining. Events have no request id. |
The JavaScript SDK
One file, voxara-bot.js, depending only on the ws package. It is a thin, readable wrapper over the protocol: an EventEmitter with a request helper, a few classes, automatic reconnection. If it does not do something you need, the raw protocol is always reachable through client.request(op, data).
npm install ws
const { Client, Message, Channel, Embed } = require('./voxara-bot');
Client
const client = new Client({
reconnect: true, // retry with backoff when the socket drops
rejectUnauthorized: false, // true to insist on a valid certificate chain
});
await client.login(token); // resolves when the bot is ready
Properties (after ready)
| Property | What it holds |
|---|---|
client.user | The bot's own user object. client.user.id is what you compare against message.authorId. |
client.guilds | Map<id, guild> of every space the bot is in, with channels, roles, memberIds, memberRoles. |
client.channels | Map<id, Channel> across all spaces. Each carries guildId, name, type, parentChannelId for threads. |
client.users | Map<id, user> of everyone in the bot's spaces. |
client.mediaToken | A read-only token for fetching attachments and pictures. See Attachments and media. |
Methods
| Method | Does |
|---|---|
send(channelId, content, options?) | Posts a message. Returns a Message. options: replyTo (message id), embed (an Embed or plain object). For an embed-only message pass the options object as the second argument: send(id, { embed }). |
editMessage(channelId, messageId, content) | Edits one of the bot's own messages. |
deleteMessage(channelId, messageId) | Deletes the bot's own message, or anyone's with manageMessages. |
react(channelId, messageId, emoji) | Toggles a reaction. emoji is a unicode glyph or a custom :name:. Calling it again removes it. |
pinMessage(channelId, messageId, pinned = true) | Pins or unpins. manageMessages in a space. |
fetchMessages(channelId, { limit = 50, before }) | Reads history, newest last. Up to 100 per call; pass the oldest id as before to page back. |
startTyping(channelId) | Shows the typing indicator for a few seconds. |
dm(userId, content, options?) | Opens (or reuses) a DM and sends into it. See Direct messages. |
kick(guildId, userId) | kickMembers |
ban(guildId, userId, reason?) / unban(guildId, userId) | banMembers |
timeout(guildId, userId, seconds) | Mutes someone in the space for seconds; 0 lifts it. kickMembers |
kickFromVoice(channelId, userId) | Disconnects someone from a voice channel. moveMembers |
moveToVoice(channelId, userId) | Moves someone into another voice channel of the same space. moveMembers |
lockThread(threadId, locked = true) | Only moderators can post in a locked thread. manageMessages |
setStatus(status) | 'online', 'idle', 'dnd' or 'invisible'. |
setCustomStatus(text) | The short line under the bot's name, up to 80 characters. |
mediaUrl(path) | Turns a /media/... path into a fetchable URL with the media token attached. |
request(op, data) | Any raw operation. Resolves with the reply's data, rejects with an error carrying .code. |
destroy() | Closes the socket and stops reconnecting. |
Events
Subscribe with client.on(name, handler). Handlers may be async; the SDK does not wait for them, so catch your own errors.
| Event | Handler receives | When |
|---|---|---|
ready | client | Signed in and caches filled. Also fires again after every reconnect. |
messageCreate | Message | Someone posted in a channel or DM the bot can see. Includes the bot's own messages. |
messageUpdate | Message | Edited, reactions changed, or an embed's images finished loading. |
messageDelete | { id, channelId } | Deleted by its author or a moderator. |
messagePin | { channelId, messageId, pinned } | Pinned or unpinned. |
typingStart | { channelId, userId } | Someone is typing. |
memberAdd | { guildId, user } | Someone joined a space the bot is in. Welcome bots live here. |
memberRemove | { guildId, userId } | Left, kicked or banned. |
guildCreate | guild | The bot was added to a new space. |
channelDelete | { channelId, guildId } | A channel is gone. |
rateLimit | error | A request was refused with rate_limited. Slow down. |
disconnect / reconnecting | The socket dropped / a retry is scheduled. | |
error | Error | A socket-level error. Always attach a handler. |
raw | { op, data } | Every server event the list above does not cover (threads, presence, voice state, and so on). |
Message
What messageCreate, send() and fetchMessages() hand you.
| Property | Type | Notes |
|---|---|---|
id, channelId, authorId | string | Ids are strings. Never treat them as numbers. |
content | string | The text, with Markdown as typed. Empty for an embed-only or attachment-only message. |
author | user | Always an object. author.bot is true for bots and webhooks. author.uncached marks a stub for someone the cache has not seen. |
channel | Channel | null | Null for a DM (DMs are not in the channel cache) or a channel created after sign-in that no event has announced. |
guild | guild | null | The space, or null in a DM. |
mentions | string[] | User ids mentioned with @username. everyone is true for @everyone / @here. |
attachments | array | { url, name, size, type }. See Attachments and media. |
embeds | array | Rich embeds (kind: 'rich'), link previews and shared Steam games share this array. |
reactions | object | { "👍": ["userId", ...] } |
replyTo, threadId, pinnedAt, editedAt, createdAt | Timestamps are milliseconds since the epoch. | |
raw | object | The message exactly as the server sent it, for fields the class does not surface (polls, forwards). |
Methods: reply(content, options?), edit(content), delete(), react(emoji), pin(), unpin().
Channel
Every channel the bot can see, keyed by id in client.channels. The raw channel fields (id, guildId, name, type, topic, categoryId, slowmode, parentChannelId, locked, archived) are on the object directly, plus send(content, options?), fetchMessages(options?) and startTyping().
const general = [...client.channels.values()]
.find((c) => c.guildId === guildId && c.type === 'text' && c.name === 'general');
await general.send('Good morning.');
Embeds
A message can carry one rich embed: a card with a title, description, side colour, up to ten fields, an image, a thumbnail, an author line, a footer and a timestamp.
const { Embed } = require('./voxara-bot');
const embed = new Embed()
.setTitle('Deploy finished')
.setURL('https://ci.example.com/build/482')
.setDescription('Build **482** passed on `main`.')
.setColor('#35c96a')
.addField('Branch', 'main', true)
.addField('Duration', '48s', true)
.setThumbnail('https://example.com/logo.png')
.setFooter('GitHub Actions')
.setTimestamp();
await client.send(channelId, { embed }); // embed only
await client.send(channelId, 'Done.', { embed }); // text and embed
| Part | Limit |
|---|---|
| title | 256 characters |
| description | 2048 characters, Markdown allowed |
| fields | 10, name 100 / value 500 characters, inline puts short ones side by side |
| author.name | 256 characters |
| footer.text | 200 characters |
| color | #rrggbb only |
| url, author.url | http(s) only |
| image, thumbnail | An http(s) image URL. The server downloads it and re-hosts a copy under its own /media/, so viewers never fetch from your URL. The pictures arrive a moment after the message as a messageUpdate. |
Values over a limit are truncated, not rejected. A message with no text, no attachment and an embed with nothing in it fails with empty_message. Editing an embed is not supported yet: delete and post again.
What your bot says about itself
In the Developer Portal, give the bot a description and tick the scopes it uses (reads messages, posts, sends DMs, moderates, voice, sends data outside Voxara). They appear on the bot's profile card, so a space owner knows what they are adding. They are a promise, not a permission: what the bot can actually do is decided by the roles the space gives it.
Slash commands
Declare commands once and people get them as autocomplete after typing / in any space the bot is in. A use never becomes a message; it arrives at the bot as a command event, and the bot answers however it likes.
client.on('ready', () => client.setCommands([
{ name: 'roll', description: 'Roll some dice', options: [{ name: 'sides', type: 'integer', description: 'How many sides (default 6)' }] },
{ name: 'poll', description: 'Start a quick poll', options: [{ name: 'question', required: true }] },
]));
client.on('command', async (cmd) => {
if (cmd.name === 'roll') {
const sides = cmd.options.sides || 6;
await cmd.reply(`${cmd.user.displayName} rolled a ${1 + Math.floor(Math.random() * sides)}`);
}
});
| On the interaction | Meaning |
|---|---|
name, args, argv | The command, the raw text after it, and that text split on spaces (quotes group words). |
options | argv matched to your declared options in order and converted by type (string, number, integer, boolean, user). |
user, channel, guild | Who ran it and where. |
reply(content, options?) | Posts into that channel, same as send() (embeds welcome). |
Limits: 25 commands per bot, 10 options each, names 1 to 32 characters (letters, digits, -, _). If the bot is offline the person is told so and nothing is sent. Raw protocol: bot:commands to declare, command:invoke event to receive.
Direct messages
client.on('memberAdd', async ({ guildId, user }) => {
if (user.bot) return;
const guild = client.guilds.get(guildId);
try {
await client.dm(user.id, `Welcome to ${guild.name}, ${user.displayName}!`);
} catch (err) {
if (err.code !== 'cannot_dm') throw err;
// their privacy settings say no: greet them in a channel instead
}
});
Rules: a bot can DM someone only if they share a space with it and the person's DM privacy allows it. Otherwise dm() rejects with cannot_dm; treat that as a normal outcome, not a failure. People cannot start a DM with a bot. A DM is a channel like any other once open: its messages come through messageCreate with message.guild === null.
Moderation bots
Give the bot a role in the space with the permissions it needs (space settings, Roles). The server enforces the same rules it applies to people:
- The action needs the permission:
manageMessagesto delete others' messages, pin, and lock threads;kickMembersto kick and time out;banMembersto ban and unban;moveMembersto move or disconnect people in voice. - The target's highest role must be below the bot's highest role. The space owner cannot be moderated by anyone. A refusal comes back as
forbidden.
const BANNED = ['buy followers', 'free nitro'];
client.on('messageCreate', async (message) => {
if (message.author.bot || !message.guild) return;
const text = message.content.toLowerCase();
if (!BANNED.some((w) => text.includes(w))) return;
await message.delete();
await client.timeout(message.guild.id, message.authorId, 600); // ten minutes
await message.channel.send(`${message.author.displayName}, that is not allowed here.`);
});
Voice: client.on('raw', ...) receives voice:state events ({ channelId, users }) whenever someone joins or leaves a voice channel, which is how a bot knows who is where before calling moveToVoice or kickFromVoice.
Attachments and media
Attachment and picture URLs in the API are paths like /media/attachment-....png. They are private: fetching one needs a token in the query string. Use the bot's media token, which can read media and nothing else:
for (const file of message.attachments) {
const res = await fetch(client.mediaUrl(file.url)); // https://host/media/...?t=<mediaToken>
const bytes = Buffer.from(await res.arrayBuffer());
}
Uploading is upload:attachment on the raw protocol: { name, type, data } with data base64, which returns an attachment object you pass in send()'s options.attachments. Up to 4 per message.
Errors and rate limits
Every rejected request is an Error with a .code and a human-readable .message. Branch on the code, show the message.
try {
await message.reply('hi');
} catch (err) {
switch (err.code) {
case 'rate_limited': /* back off */ break;
case 'slowmode': /* the channel's slow mode applies to bots without manageMessages */ break;
case 'forbidden': /* missing permission or role rank */ break;
default: console.error(err.code, err.message);
}
}
| Code | Meaning |
|---|---|
bad_token | The bot token is wrong or was reset. |
unauthorized | You sent a request before signing in. |
forbidden | Not allowed: not in the space, missing permission, or the target outranks the bot. |
not_found | The channel, message, user or space does not exist (or the bot cannot see it). |
rate_limited | Too many requests. The SDK also emits rateLimit. |
slowmode | The channel's slow mode says wait. Moderators are exempt. |
locked | The thread is locked. |
timed_out | The bot itself is timed out in that space. |
cannot_dm | That person cannot be DM'd by this bot. |
empty_message / message_too_long | Nothing to send, or over 4000 characters. |
blocked_word | The space's automod refused the text. |
too_many, too_many_reactions, too_many_pins, too_many_attachments | A ceiling was hit. See Limits. |
invalid_* | A field was malformed: invalid_emoji, invalid_status, invalid_name, and so on. |
internal | The server failed. Retry later. |
Guides for using the app
Not building a bot? These cover the app itself: creating a space, channels, roles and permissions, invite links and posting GitHub events into a channel.
The protocol, for any language
The SDK is optional. A bot is any WebSocket client that speaks the same JSON the app does. Python, Go, Rust, a shell script with websocat: all fine.
Connect and sign in
- Open a WebSocket to
wss://voxaraspace.com. Send the headerOrigin: app://pulse, or no Origin at all. Any other Origin is refused. - Send
{ "id": 1, "op": "auth:bot", "data": { "token": "YOUR_TOKEN" } }. - The reply is the ready payload:
{ "id": 1, "ok": true, "data": { user, guilds, users, mediaToken, ... } }.user.idis the bot's own id.guilds[]holds every space with itschannels[],roles[]andmemberIds[];usersis a map of everyone in those spaces. - From now on the server pushes events, and you send requests whenever you like.
Frames
// request: you choose the id, any string or number, unique per request in flight
{ "id": 7, "op": "message:send", "data": { "channelId": "1788…", "content": "hello" } }
// reply: same id, exactly once
{ "id": 7, "ok": true, "data": { "message": { … } } }
{ "id": 7, "ok": false, "error": { "code": "forbidden", "message": "You cannot send messages in this channel." } }
// event: pushed by the server, no id
{ "op": "message:new", "data": { "message": { … } } }
Send { "op": "ping" } if you want a heartbeat; it is the one op that does not count toward the per-connection frame ceiling. Idle sockets are fine either way.
Operations a bot uses
| Op | Data | Reply data |
|---|---|---|
auth:bot | { token } | ready payload |
message:send | { channelId, content?, replyTo?, embed?, attachments? } | { message } |
message:edit | { channelId, messageId, content } | { message } |
message:delete | { channelId, messageId } | {} |
messages:fetch | { channelId, limit?, before? } | { messages, hasMore } |
messages:pinned | { channelId } | { messages } |
message:pin | { channelId, messageId, pinned } | { message } |
reaction:toggle | { channelId, messageId, emoji } | {} |
typing:start | { channelId } | {} |
upload:attachment | { name, type, data } (base64) | attachment |
dm:open | { userId } | { dm }, then send to dm.id |
thread:create | { channelId, name, messageId?, content?, tags? } | { thread } |
thread:list | { channelId } | { threads } |
thread:archive | { threadId, archived } | { thread } |
thread:lock | { threadId, locked } | { thread } |
guild:kick | { guildId, userId } | {} |
guild:ban / guild:unban | { guildId, userId, reason? } | {} |
guild:bans | { guildId } | { bans } |
member:timeout | { guildId, userId, seconds } | {} |
voice:kick | { channelId, userId } | { ok } |
voice:move | { channelId, userId } | { ok } |
me:update | { status?, customStatus?, displayName?, bio? } | { user } |
bot:commands | { commands: [{ name, description, options }] } | { commands } |
guild:leave | { guildId } | {} |
Permissions on these are the same as for the SDK methods above. Ops the app uses for accounts, friends, billing and space administration exist too but are outside what a bot needs; the server refuses what a bot cannot do.
Server events
| Op | Data |
|---|---|
message:new | { message } |
message:update | { message } (edit, reaction change, embed images ready) |
message:delete | { channelId, messageId } |
message:pinned | { channelId, messageId, pinned } |
command:invoke | { id, channelId, guildId, name, args, argv, user } (a slash command aimed at this bot) |
typing | { channelId, userId } |
member:add | { guildId, user } |
member:remove | { guildId, userId } |
guild:new | { guild } (the bot was added to a space) |
guild:update | { guild } |
channel:new / channel:update / channel:delete | { channel } / { channelId, guildId } |
thread:new / thread:update | { thread } |
presence | { userId, status, customStatus } |
voice:state | { channelId, users } (everyone currently in that voice channel) |
voice:kicked / voice:moved | Only ever sent to the person it happened to. |
error | { message } for a frame the server could not parse |
Object shapes
user
{ "id", "username", "displayName", "bot", "status", "customStatus", "bio",
"avatarColor", "avatarUrl", "bannerUrl", "badges", "steamLinked", … }
message
{ "id", "channelId", "authorId", "content", "createdAt", "editedAt",
"attachments": [{ "url", "name", "size", "type" }],
"embeds": [{ "kind": "rich" | "link" | "steamgame", … }],
"reactions": { "👍": ["userId"] },
"mentions": ["userId"], "everyone": false,
"replyTo": "messageId" | null, "threadId": null, "pinnedAt": null,
"poll"?: { … }, "forwardedFrom"?: { … } }
guild (a space)
{ "id", "name", "description", "ownerId", "iconUrl", "iconColor",
"channels": [{ "id", "name", "type", "topic", "categoryId", "slowmode" }],
"categories": [{ "id", "name" }],
"roles": [{ "id", "name", "color", "position", "permissions": { "manageMessages": true, … } }],
"memberIds": ["userId"], "memberRoles": { "userId": ["roleId"] },
"timeouts": { "userId": untilMs }, "emojis": [{ "name", "url" }] }
Python example
The same ping bot with the websockets package (pip install websockets), no SDK:
import asyncio, json, os, itertools
import websockets
SERVER = "wss://voxaraspace.com"
TOKEN = os.environ["VOXARA_BOT_TOKEN"]
ids = itertools.count(1)
async def main():
async with websockets.connect(SERVER, origin="app://pulse") as ws:
await ws.send(json.dumps({"id": next(ids), "op": "auth:bot", "data": {"token": TOKEN}}))
ready = json.loads(await ws.recv())
assert ready["ok"], ready
me = ready["data"]["user"]["id"]
print("signed in as", ready["data"]["user"]["displayName"])
async for raw in ws:
frame = json.loads(raw)
if frame.get("op") != "message:new":
continue
m = frame["data"]["message"]
if m["authorId"] == me or m["content"] != "!ping":
continue
await ws.send(json.dumps({"id": next(ids), "op": "message:send",
"data": {"channelId": m["channelId"], "content": "pong", "replyTo": m["id"]}}))
asyncio.run(main())
A real bot would match replies to requests by id instead of ignoring them, reconnect when the socket closes, and send a WebSocket ping every 45 seconds or so to keep a quiet connection open. The SDK source is a good reference for all three.
Webhooks
A webhook is one URL that posts into one text channel. No socket, no sign-in, no library. Use it for anything that only needs to announce: CI results, deploys, alerts, forms, cron jobs.
Create one
In the Voxara app, right-click a text channel and choose Webhooks (or Edit channel, then Webhooks). Create one, give it a name, and copy the URL. Like a bot token, the URL is shown once and can be reset. Anyone managing the channel can create up to 10.
https://voxaraspace.com/wh/<id>/<token>
Post to it
curl https://voxaraspace.com/wh/<id>/<token> \
-H 'content-type: application/json' \
-d '{"content": "Deploy finished: v1.40.1 is live."}'
// reply
{ "ok": true, "message": { "id": "1788…", "createdAt": 1788874382726 } }
{ "ok": false, "error": { "code": "rate_limited", "message": "Too many requests." } }
The body is JSON with content (Markdown, up to 4000 characters) and/or an embed in the same shape as above:
{
"embed": {
"title": "Build 482 passed",
"url": "https://ci.example.com/build/482",
"description": "main · 48s",
"color": "#35c96a",
"fields": [{ "name": "Author", "value": "sam", "inline": true }],
"footer": { "text": "GitHub Actions" },
"timestamp": 1788874382726
}
}
From GitHub Actions
- name: Tell Voxara
run: |
curl -sS "$VOXARA_WEBHOOK" -H 'content-type: application/json' \
-d "{\"content\": \"${{ github.repository }}: ${{ job.status }} on ${{ github.ref_name }}\"}"
env:
VOXARA_WEBHOOK: ${{ secrets.VOXARA_WEBHOOK }}
From Python
import requests
requests.post(WEBHOOK_URL, json={"content": "Backup finished, 2.1 GB."}, timeout=10)
Ready-made templates
Point a service's own webhook at the URL with a suffix and Voxara turns its payload into a card; nothing to configure on their side.
| URL | Paste it into | What posts |
|---|---|---|
…/wh/<id>/<token>/github | GitHub repo → Settings → Webhooks (content type JSON) | Pushes (with commit list), pull requests opened/merged/closed, issues, releases, workflow results, stars. |
…/wh/<id>/<token>/gitlab | GitLab project → Settings → Webhooks | Pushes, merge requests, issues, pipeline results. |
…/wh/<id>/<token>/uptime | Uptime Kuma (webhook notification) or any monitor sending { "name", "status", "message" } | Up and down notices. |
Events the template has no card for (pings, labels, comments) are accepted and ignored, so nothing spams the channel.
Rules
- Text channels only (not forums, threads or voice).
- Body up to 16 KB. 30 posts per minute per webhook, 60 per minute from one address.
- Messages appear with the webhook's name and picture and a BOT tag. A webhook cannot read anything back, and it does not appear in the space's member list.
- Resetting a webhook invalidates the old URL immediately. Deleting the channel deletes its webhooks.
- A wrong id or token answers 404, the same as an unknown page, so the URL cannot be probed.
Limits
| What | Limit |
|---|---|
| Messages sent | 25 per 10 seconds per account |
| Other writes (reactions, edits, pins, moderation, uploads) | 90 per minute per account, uploads 20 per minute |
| Frames per connection | 300 per 10 seconds, whatever the op |
| Message length | 4000 characters |
| Attachments | 4 per message, 525 MB each |
| History per fetch | 100 messages |
| Reactions | 20 different emoji per message |
| Pins | 50 per channel |
| Embed | 1 per message; see the field limits |
| Bots owned | 25 per account |
| Webhooks | 10 per channel; 30/min per webhook, 60/min per address, 16 KB body |
| Slow mode | Applies to bots without manageMessages, and inside every thread of a channel that has it |
Hitting a limit is never fatal: the request is refused with a clear code and the bot stays connected.
Good practice
- Ignore bots.
if (message.author.bot) return;at the top of every message handler. Without it two bots can talk to each other forever, and yours can answer itself. - Prefix commands (
!ping,/roll) and match exactly, so ordinary conversation never triggers them. - Handle
rate_limitedby pausing, not retrying in a tight loop. - Reply in place. Use
message.reply()so people see what the bot is answering. - Least privilege. Give the bot's role only the permissions it uses. A greeting bot needs none.
- Log codes, not tokens. Never print the token, the media token, or full frames containing them.
- Keep it running with something that restarts it (systemd, pm2, a container). The SDK reconnects by itself; a crash still needs a supervisor.
- Re-fetch when it matters. The caches are for convenience. Membership and roles change under you.
Troubleshooting
| Symptom | Usually |
|---|---|
bad_token at sign-in | Token pasted with a space, or it was reset in the portal. Copy it again. |
| Signed in, but no messages arrive | The bot is not in any space yet. Add it from the portal; client.guilds.size tells you. |
forbidden on a moderation call | The bot's role lacks the permission, or the target's highest role is at or above the bot's. |
cannot_dm | The person does not share a space with the bot, or their privacy setting blocks DMs. Fall back to a channel. |
| Connection refused with "Origin not allowed" | Send Origin: app://pulse or no Origin header at all. |
| Attachment download returns 401 | Add the media token: client.mediaUrl(path), or ?t=<mediaToken> from the ready payload. |
| The bot answers itself in a loop | You forgot to ignore message.author.bot. |
Something missing here? The SDK source, voxara-bot.js, is 400 readable lines and shows every frame it sends. A complete example bot is at ping-bot.js, and the same thing without the SDK at raw-ping-bot.js.
Voxara