Gateway
The gateway is a WebSocket that pushes events to your bot as they happen. Reading and writing data stays on REST.
On this page
Connecting
Open a WebSocket to:
wss://api.palloo.app/v1/gateway
- Send no token in the URL and no
Authorizationheader. You authenticate inside the connection. - Every frame is a JSON text frame. Binary frames are refused, and frames you send may be at most 64 KiB.
- Too many connection attempts from one address answer
429 rate_limited(60 at once, then 1 a second). A server process that is shutting down answers503 gateway_unavailable.
Frames
Every frame has the same envelope:
{"op": "dispatch", "t": "message_create", "d": {}}
| Field | Meaning |
|---|---|
op |
The kind of frame. |
t |
The event name, only when op is dispatch. |
d |
The payload, absent when the op has none. |
| Op | Direction | Payload |
|---|---|---|
hello |
from Palloo | {"heartbeat_interval_ms": 30000, "protocol_version": 2, "ops": [...]}. Always the first frame. |
identify |
from you | {"token": "palloo_bot_..."}. Must be your first frame, within 10 seconds. |
ready |
from Palloo | The session snapshot, once, after a successful identify. |
heartbeat |
from you | No payload. Send one every heartbeat_interval_ms. |
heartbeat_ack |
from Palloo | No payload. The answer to each heartbeat. |
dispatch |
from Palloo | An event, named by t. |
update_presence |
from you | {"status", "afk", "custom_status"}, each optional. |
subscribe_presence |
from you | {"community_ids": [...]}, at most 5. Needs the presence intent. |
request_online_members |
from you | {"community_id", "offset", "limit"}. Needs the presence intent; answered with online_members. |
Ignore ops, events and fields you do not recognize. New ones are added without a version change. Send an op only if the ops list in hello names it: during a deploy you may reach an older server process, and an op it does not know closes the connection with 4001.
The session
- Open the WebSocket. Palloo sends
hello. - Send
identifywith your bot token. - Palloo answers with
ready, or closes the connection with a close code. - Send
heartbeateveryheartbeat_interval_ms(30 seconds). A connection that sends nothing for 1.5 intervals is closed with4005. Ifheartbeat_ackstops arriving, reconnect. - Receive
dispatchframes until the connection closes.
ready holds your bot's user (with kind: "bot"), every server it is in as communities (each with its channels, roles and active threads), its dm_channels, and protocol_version and ops again. If the servers do not fit in about 1 MiB, ready carries the first ones and the rest follow at once as community_create events, before any other event. Treat them exactly like servers listed in ready.
A minimal bot in JavaScript
Node.js 22 or newer has WebSocket built in. This connects, identifies, keeps the heartbeat going, prints messages and reconnects with backoff:
const token = process.env.PALLOO_BOT_TOKEN;
let attempt = 0;
function connect() {
const ws = new WebSocket('wss://api.palloo.app/v1/gateway');
let heartbeat;
ws.addEventListener('message', (event) => {
const frame = JSON.parse(event.data);
if (frame.op === 'hello') {
ws.send(JSON.stringify({ op: 'identify', d: { token } }));
heartbeat = setInterval(() => ws.send(JSON.stringify({ op: 'heartbeat' })), frame.d.heartbeat_interval_ms);
} else if (frame.op === 'ready') {
attempt = 0;
console.log(`Ready as ${frame.d.user.username} in ${frame.d.communities.length} servers`);
} else if (frame.op === 'dispatch' && frame.t === 'message_create') {
console.log(`${frame.d.author.username}: ${frame.d.content}`);
}
});
ws.addEventListener('close', (event) => {
clearInterval(heartbeat);
if (event.code === 4003 || event.code === 4007) {
console.error(`Closed with ${event.code}, not reconnecting.`);
return;
}
const delay = event.code === 4008 ? Math.random() * 2000 : Math.min(60000, 1000 * 2 ** attempt++) * (0.5 + Math.random() / 2);
setTimeout(connect, delay);
});
}
connect();
The same in Python
Python has no WebSocket client in its standard library. This uses the websockets package (pip install websockets):
import asyncio
import json
import os
import websockets
TOKEN = os.environ["PALLOO_BOT_TOKEN"]
async def heartbeat(ws, interval):
while True:
await asyncio.sleep(interval)
await ws.send(json.dumps({"op": "heartbeat"}))
async def main():
async with websockets.connect("wss://api.palloo.app/v1/gateway") as ws:
hello = json.loads(await ws.recv())
await ws.send(json.dumps({"op": "identify", "d": {"token": TOKEN}}))
beat = asyncio.create_task(heartbeat(ws, hello["d"]["heartbeat_interval_ms"] / 1000))
try:
async for raw in ws:
frame = json.loads(raw)
if frame["op"] == "ready":
print("Ready as", frame["d"]["user"]["username"])
elif frame["op"] == "dispatch" and frame["t"] == "message_create":
print(frame["d"]["author"]["username"], frame["d"]["content"])
finally:
beat.cancel()
asyncio.run(main())
Add reconnecting with backoff, as in the JavaScript example, before you run it for real.
Reconnecting and catching up
There is no resume. Every reconnect is a new identify, and any server process can take it. To catch up on what you missed:
- Reconnect with backoff and identify again.
- For each channel in
ready, comparelast_message_idwith the newest message you have. If they differ, page throughGET /channels/{id}/messages?after=<newest id you have>until a page comes back shorter thanlimit. - Replace your servers, channels, roles and DMs with what
readysays, together with thecommunity_createevents that follow it. Anything missing was deleted, or the bot lost access.
Events can arrive that ready already reflects. Deduplicate messages by id, and order them by id, not by arrival: IDs sort in creation order.
Events
Payloads use the REST shapes from the API reference. The events a bot is most likely to use:
| Event | Payload |
|---|---|
message_create |
Message, with nonce if the sender gave one |
message_update |
Message |
message_delete |
{"id", "channel_id", "community_id"} (no community_id in a DM) |
message_delete_bulk |
{"ids", "channel_id", "community_id", "last_message_id"} |
channel_create, channel_update |
Channel, or a DM channel |
channel_delete |
{"id", "community_id"} |
channel_pins_update |
{"channel_id", "community_id", "last_pin_timestamp"} |
community_create |
A server with channels and roles, as in ready. Sent when the bot is added to one |
community_update |
Community |
community_delete |
{"id"}. The server was deleted, or the bot left or was removed |
member_add, member_update |
Member plus community_id |
member_remove |
{"community_id", "user"} |
role_create, role_update |
Role |
role_delete |
{"id", "community_id"} |
role_positions |
{"community_id", "roles": [{"id", "position"}]} |
interaction_create |
InteractionCreate. Only the command's bot receives it; see Slash commands |
thread_create, thread_update |
Channel of kind public_thread or private_thread |
thread_delete |
{"id", "community_id", "parent_id", "kind"} |
typing_start |
{"channel_id", "community_id", "user_id", "started_at"} |
voice_state_update |
VoiceState |
user_update |
The user's public profile after a change |
presence_update |
{"user_id", "status", "custom_status", "community_id"}, only with the presence intent |
Palloo only sends events for channels the bot can view. When a role or overwrite change takes access away, the bot receives channel_delete for that channel; when it gains access, channel_create. Without Read Message History in a channel, message_update and message_delete arrive only for messages sent after the connection loaded the server.
Message content
Unless its owner turned on Read message content for it, a bot receives message_create and message_update in servers with content set to "", except for its own messages and messages that mention it directly. Everything else in the payload (author, mentions, IDs, times) is unchanged, and messages in DMs with the bot always carry their content. The same rule applies to message history over REST. Turning the setting on or off applies to open connections at once.
Presence intent
Bots set their own status with update_presence; custom_status.text is shown as their activity. A bot only receives other people's presence when its owner turned on the presence intent: then every presence_update of the members of its servers arrives without subscribing, ready.presences is filled, subscribe_presence sends presence_list snapshots and request_online_members is answered. Without it, the bot gets no presence at all and those two ops are ignored. A server can also turn off presence for bots altogether (bot_presence_allowed).
There are no other intents. Apart from presence, a bot receives the events a member in its place would, for the servers and channels it can see. Moderation report events only ever go to people.
Close codes
| Code | Meaning | What to do |
|---|---|---|
1000 |
The connection ended normally. | Reconnect if you still want events. |
1009 |
A frame you sent was over 64 KiB. | Fix the bot, then reconnect. |
4000 |
A problem on Palloo's side. | Reconnect with backoff. |
4001 |
Not JSON text, or an op Palloo does not know. | Fix the bot, then reconnect with backoff. |
4002 |
The first frame was not a valid identify, or you sent it twice. |
Fix the bot, then reconnect with backoff. |
4003 |
The token is invalid or was revoked: the token was regenerated or the bot deleted. | Do not reconnect with the same token. |
4004 |
No identify within 10 seconds. |
Reconnect and identify promptly. |
4005 |
Nothing received for 1.5 heartbeat intervals. | Reconnect. |
4006 |
You fell too far behind (256 undelivered events). | Reconnect at once and catch up over REST. |
4007 |
Too many connections: the bots of one owner share 10 per server process, and the oldest was closed. | Do not reconnect automatically; you would close another of your connections. |
4008 |
The server process is shutting down for a deploy. | Reconnect at once, with a little random delay. |
4009 |
update_presence, subscribe_presence or request_online_members had a payload Palloo cannot accept. |
Fix the bot, then reconnect with backoff. |
4010 |
The server process lost the link that carries events between processes, so events may have been missed. | Reconnect with backoff and catch up over REST. |
4011 |
Terms of service not accepted. Never sent to bots. |
Backoff means exponential with jitter, starting around 1 second and capped around 1 minute. Treat a code you do not know as "reconnect with backoff".
Limits
| Limit | Value |
|---|---|
| Time to identify | 10 seconds |
| Heartbeat interval | 30 seconds; closed after 45 seconds of silence |
| Frame size you can send | 64 KiB |
| Undelivered events per connection | 256 |
| Connections of all the bots of one owner | 10 per server process together; the oldest is closed |
ready size |
about 1 MiB; further servers follow as community_create |
| Connection attempts per address | 60 at once, then 1 a second |
update_presence per connection |
5 at once, then 1 every 10 seconds; extra updates are combined and applied later |
subscribe_presence per connection |
5 at once, then 1 every 2 seconds; at most 5 servers |
The server re-checks a connection's token, memberships and channel access about every 2 minutes as a backstop. Changes normally apply at once.