Gateway
The gateway is a WebSocket that pushes events to your bot. Keep one connection open per bot process.
You will need
Node 22 or newer has WebSocket built in. Any WebSocket client works. You need your bot token (YOUR_BOT_TOKEN).
Connect
wss://events.zrzur.com/?version=1&format=json| Query parameter | Values | Default |
|---|---|---|
version | 1 | 1 |
format | json, msgpack | json |
token | your bot token, instead of Authenticate | none |
intents | the sum of your intents, instead of the field in Authenticate | see below |
ready | fields to include in Ready, e.g. ready=servers&ready=channels | everything |
With format=msgpack both sides send binary frames.
Prefer Authenticate to ?token=: addresses end up in logs. Send it within 15 seconds:
{ "type": "Authenticate", "token": "YOUR_BOT_TOKEN", "intents": 33 }The server answers, in this order:
Authenticated: the token is good.Ready: your starting data. Events flow only after this one.
Anything else you send is limited to 1 MiB per message.
When the gateway refuses you
| What you receive | Cause | What to do |
|---|---|---|
{ "type": "Error", "data": { "type": "InvalidSession" } }, closes | Wrong or reset token, suspended bot, or no Authenticate in 15 seconds | Stop. Do not reconnect in a loop |
{ "type": "Error", "data": { "type": "MissingIntent", "intent": "SERVER_MEMBERS" } }, closes with code 4014 | A privileged intent that is off. The close reason names it, for example Disallowed intents: MESSAGE_CONTENT (switched on: none) | Switch it on in the Developer Portal and connect again, or connect without it |
{ "type": "Error", "data": { "type": "InternalError" } }, closes with code 1013 | The server could not read the bot's settings just then | Connect again, with the wait from Reconnect |
Stay connected
Send a Ping every 20 seconds:
{ "type": "Ping", "data": 1730000000000 }The server answers Pong with the same data. data is a number, or a list of bytes.
The Zrzur gateway itself never closes a quiet bot connection, but proxies and home routers do, and a dead line gives no sign. So the ping is how you find out: when no Pong came for 60 seconds, close the socket and connect again.
Reconnect
A connection can end at any time: a deployment on our side, a network problem, your laptop sleeping. Plan for it.
- When the socket closes, wait 1 second, and double the wait after each failure, up to 30 seconds. Add a little randomness so many bots do not return at once.
- Connect and send
Resumeif the session was resumable (see Resume after a drop),Authenticateotherwise. AfterAuthenticateyou get a newReady. - Reset the wait after
ReadyorResumed. - Do not retry after an
Errorevent of typeInvalidSession, or after code4014: the same request fails again. Stop and tell a person. After code1013do retry: the server could not read the bot's settings just then.
Resume after a drop
A session can pick up where it stopped, so a short drop loses nothing. Ask for it when you authenticate:
{
"type": "Authenticate",
"token": "YOUR_BOT_TOKEN",
"intents": 33,
"resumable": true
}Ready then carries session_id. Every packet after Ready carries seq, counted per session from 1: keep the last one you handled. A Bulk packet has one seq and its items have none. Authenticated, Ready, Pong, Error, InvalidSession and Resumed have no seq.
After a drop the gateway keeps the session for 2 minutes, with at most its last 1000 events and 4 MiB of them. Open a new socket and send this as the first message, instead of Authenticate:
{
"type": "Resume",
"token": "YOUR_BOT_TOKEN",
"session_id": "YOUR_SESSION_ID",
"seq": 41
}| What you receive | Meaning | What to do |
|---|---|---|
The missed events in order, then Resumed | The session goes on. There is no new Ready | Carry on, and keep counting seq |
{ "type": "InvalidSession", "resumable": false } | The session is gone, or what you missed no longer fits in what is kept | Send Authenticate on the same socket, then catch up over REST |
A replay can have gaps in seq: typing events are not kept, and events from places the bot can no longer see are left out. The gateway keeps at most 3 dropped sessions per bot. A second refused Resume on the same socket closes it.
Without "resumable": true nothing is kept, and events sent while you were away are gone. After a new Ready, fetch over REST what your bot cannot miss, such as recent messages of a channel (GET /channels/{target}/messages with after and sort=Oldest, see Messages). A command used while your bot was away fails for the person who used it, and they can try again.
Sharding is not supported
A connection receives everything your intents cover. There is no way to split servers between connections. A bot process that grows too large cannot be divided yet; keep the work you do per event small, and move slow work to a queue. See Running in production.
Two connections with the same token both work, and both receive everything. An InteractionCreate then reaches both, so only run two during a restart.
Bulk packets
Several events can arrive in one packet:
{
"type": "Bulk",
"v": [{ "type": "ServerMemberUpdate" }, { "type": "InteractionCreate" }]
}Always unpack Bulk and handle each event. A bot that ignores it misses events, including command uses.
What you can send
| Message | Fields | |
|---|---|---|
Authenticate | token, intents, resumable | Your first message. token is the bot token, intents the sum of the intents you want. With resumable: true the session can be resumed after a drop, and Ready carries session_id. |
Resume | token, session_id, seq | The first message on a new socket after a drop, instead of Authenticate. The events after seq that you may still see arrive in order, then Resumed. If the session is gone you get InvalidSession. |
BeginTyping | channel | Shows the bot as typing in channel. Send it again every few seconds to keep it up. |
EndTyping | channel | Stops the typing display in channel. |
Subscribe | server_id | Ask for presence and member updates of server_id. Bots need SERVER_MEMBERS or PRESENCE to see those updates. |
Ping | data, responded | Keeps the line tested. data is a number of your choice and comes back in Pong. |
Intents
Intents say what a connection receives. The gateway applies them, so a bot never gets what it did not ask for. Add the numbers of the ones you want and send the sum. Without intents the bot gets all free intents, 63.
| Intent | Value | Receives |
|---|---|---|
SERVERS | 1 | Server, server channel, role, emoji and webhook events; servers and their channels in Ready. Direct messages and groups arrive either way, in Ready and as ChannelCreate |
SERVER_MESSAGES | 2 | Messages and typing in server channels |
DIRECT_MESSAGES | 4 | Messages and typing in direct messages and groups |
REACTIONS | 8 | Reactions being added and removed |
VOICE_STATES | 16 | People joining and leaving voice channels, calls, stage changes, soundboard plays, voice states in Ready |
INTERACTIONS | 32 | InteractionCreate |
MESSAGE_CONTENT | 1024 | Privileged. Text, embeds, files and components of messages |
SERVER_MEMBERS | 2048 | Privileged. Members joining, leaving and changing; the member list |
PRESENCE | 4096 | Privileged. Who is online, and their status |
A bot that only answers commands needs SERVERS + INTERACTIONS = 33. Without INTERACTIONS your bot cannot hear commands.
Privileged intents are switched on for the bot in the Developer Portal. Asking for one that is off closes the connection with code 4014. Past 100 servers, only the privileged intents Zrzur approved stay on: the others are left out of the connection without closing it, and the REST rules below apply.
The same rules hold over REST:
- Without
MESSAGE_CONTENTa message has no text, embeds, files or components, unless the bot wrote it, is mentioned in it, or it is a direct message with the bot. - Without
SERVER_MEMBERSthe member list and member search answerMissingIntent. One member (GET /servers/{target}/members/{member}) still works. - Without
PRESENCEevery user shows as offline, without a status. The bot still sees itself.
A change of a bot's intents takes effect on its next connection.
When a privileged intent is off
A switch in the Developer Portal is saved the moment you flip it, and the portal says so. The gateway reads the switches when a connection starts, so an open connection keeps the intents it started with: close the socket and connect again. The token stays the same. REST reads them on every request, so REST answers follow a switch at once.
MissingIntent names the intents that are off. If your bot can work without one, connect again without it instead of stopping:
const MESSAGE_CONTENT = 1024;
// SERVERS + SERVER_MESSAGES + DIRECT_MESSAGES + INTERACTIONS + MESSAGE_CONTENT
let intents = 1 + 2 + 4 + 32 + MESSAGE_CONTENT;
function connect() {
const socket = new WebSocket("wss://events.zrzur.com/?version=1&format=json");
socket.addEventListener("open", () =>
socket.send(
JSON.stringify({
type: "Authenticate",
token: process.env.ZRZUR_BOT_TOKEN,
intents,
}),
),
);
socket.addEventListener("message", ({ data }) => {
const event = JSON.parse(data);
if (event.type === "Error" && event.data.type === "MissingIntent")
console.warn(`Switched off in the Developer Portal: ${event.data.intent}`);
});
socket.addEventListener("close", ({ code }) => {
if (code === 4014 && intents & MESSAGE_CONTENT) {
// go on without message text: mentions and direct messages keep theirs
intents &= ~MESSAGE_CONTENT;
connect();
}
});
}
connect();Without MESSAGE_CONTENT your bot still reads messages that mention it, its own messages and direct messages with it. Add the reconnect loop from Reconnect for every other close.
Machine-readable schema
GET /gateway/schema on the API returns every event (server) and everything a client can send (client) as JSON Schema, with the intents. It is generated from the types the gateway uses.
Full example
The complete bot has all of this: intents, ping, watchdog, Bulk, backoff and the two cases where it stops.