Client commands¶
Clients send commands over the Gateway WebSocket, with an integer opcode in op and the command data in d. A command can produce a Dispatch, a close frame, or no response.
Except for Heartbeat, Identify, and Resume, every command needs an attached session. Sending one too early closes with 4003 and reason Not authenticated. Heartbeat is accepted in any open state. Identify and Resume are accepted only while no session is attached.
Command index¶
| Opcode | Command | Result |
|---|---|---|
| 1 | Heartbeat | Opcode 11 Heartbeat ACK |
| 2 | Identify | Ready, a close frame, or no response when TEBHOS discards a rate-limited Identify or holds it to retry the session start |
| 3 | Presence Update | No direct response |
| 4 | Voice State Update | Voice State Update and Voice Server Update when state changes |
| 6 | Resume | Replayed Dispatches followed by Resumed, Invalid Session, or a close frame |
| 8 | Request Guild Members | One or more Guild Members Chunk events |
| 14 | Lazy Request | Guild Sync and Guild Member List Update |
| 15 | Request Guild Counts | Guild Counts Update |
| 16 | Request Channel Member Counts | Channel Member Counts Update |
Payload handling¶
A frame that fails the size, decompression, or decoding checks closes the connection. A frame that decodes to something other than an object closes with 4002 and reason Decode failed, and an object with no op closes with 4002 and reason Invalid payload.
TEBHOS charges every command that gets past those checks against the source IP, session, and connection payload budgets before it handles the opcode. Each session has its own session budget, and that budget is skipped while the connection is unauthenticated.
An opcode outside the registry closes with 4001 and reason Unknown opcode once a session is attached, and with 4003 while the connection is unauthenticated. A payload that has op but no d also closes with 4001, except Identify and Resume, which close with 4005.
Most command payloads are permissive
Identify, Resume, Presence Update, and Voice State Update reject a malformed payload with a close. The bounded query commands coerce or discard whatever they cannot parse, so a wrong field type usually produces an empty result.
Heartbeat¶
Opcode 1 carries the last Dispatch sequence the client processed, or null before the first Dispatch.
| Field | Type | Description |
|---|---|---|
| d | ?integer | The last Dispatch sequence the client processed, or null before the first Dispatch |
Before authentication the Gateway accepts any d value and sends Opcode 11. A payload with no d key closes with 4001 and reason Unknown opcode.
Once authenticated, a d that is neither null nor an integer closes with 4007 and reason Invalid sequence. Every integer is accepted, but acknowledgements never move backwards. Dispatches at or below the highest acknowledged sequence are no longer available for Resume.
When the session ends, TEBHOS sends Opcode 9 with d: false, after which heartbeats are acknowledged again. A heartbeat that arrives in the short window between the session ending and that frame closes with 4007.
See Gateway overview for the timing contract.
Identify¶
Opcode 2 authenticates and creates a new session.
| Field | Type | Description |
|---|---|---|
| token | string | The user session token or bot token, with no HTTP authentication prefix. A leading Bot on a bot token is accepted and stripped; Bearer is not |
| properties | identify properties object | The software and capabilities of the connecting client |
| presence? | ?initial presence object | The presence to publish when the session starts. Null and an omitted key both select the account's saved status and saved custom status |
| ignored_events? | ?array[string] | The Dispatch event names this session does not want, at most 256 entries. Names are upper-cased and deduplicated. See Event filtering |
| flags? | integer | A non-negative session flags bitfield (default 0) |
| initial_guild_id? | ?snowflake | One guild the session joins as active without a Lazy Request. A value that is not a valid snowflake string is ignored |
| shard? | ?array[integer] | The [shard_id, shard_count] pair. shard_count is 1 through 16,384, and shard_id is at least 0 and below shard_count |
{
"op": 2,
"d": {
"token": "...",
"properties": {
"os": "Linux",
"browser": "my-client",
"device": "desktop"
},
"presence": {
"status": "online",
"afk": false,
"mobile": false
},
"ignored_events": ["TYPING_START"],
"flags": 2,
"initial_guild_id": "1189375284394692608"
}
}
token and properties are required. Unknown fields are ignored. There is no intents field and no intent system.
The following faults close with 4002 and reason Invalid identify payload:
- A missing
tokenorproperties. propertiesthat is not an object.propertieswhoseos,browser, ordeviceis missing or is not a string.- An
ignored_eventsvalue that is not an array of strings, or one that holds more than 256 entries. - A
flagsvalue that is not a non-negative integer.
A malformed shard closes with 4010 and reason Invalid shard.
A bot session that resolves to more than 2,500 guilds after the shard filter closes with 4011 and reason Sharding required. A user session is never refused for its guild count.
A token the API rejects closes with 4004 and reason Invalid token. A user session token is accepted only while its login session exists, so a signed-out token fails here. A user account that already holds 100 live sessions closes with 4008 and reason Too many sessions. A bot credential is not bounded by that count. An Identify sent on a socket that already has a session attached closes with 4005 and reason Already authenticated, whether or not it has d.
The API also limits session starts to 20 per minute for each token. An Identify over that limit is held and retried silently, like any other temporary failure.
Session flags¶
| Value | Name | Description |
|---|---|---|
| 1 << 1 | DEBOUNCE_MESSAGE_REACTIONS | Merge runs of reaction additions into Message Reaction Add Many |
Every other bit is undefined. An undefined bit is accepted and ignored.
DEBOUNCE_MESSAGE_REACTIONS applies to reactions in direct messages and group direct messages. A reaction in a guild channel is never merged and arrives as its own Message Reaction Add.
Identify properties object¶
Structure¶
| Field | Type | Description |
|---|---|---|
| os | string | The operating system the client runs on |
| browser | string | The client library identifier |
| device | string | The device or application identifier |
| e2ee_capable? | boolean | Whether the client can take part in end-to-end encrypted voice. A session without it is refused from an end-to-end encrypted voice channel with VOICE_E2EE_REQUIRED |
| mobile? | boolean | Whether the session is mobile (default false). Read only when presence is absent or null |
| latitude? | string | The client latitude as a decimal string |
| longitude? | string | The client longitude as a decimal string |
os, browser, and device are required strings. The remaining fields are optional hints, and unrecognised properties are ignored.
latitude and longitude order the Ready rtc_regions array by distance. Send them only as strings: the API rejects a session request that carries a JSON number here, the Gateway treats that as a temporary failure and keeps retrying the held Identify, and Ready never arrives. Both must be sent to have any effect, and a string that does not parse as a number counts as absent. A client with no location omits them.
Initial presence object¶
Structure¶
| Field | Type | Description |
|---|---|---|
| status? | string | The initial status: online, idle, dnd, invisible, or offline |
| afk? | boolean | Whether the session is away (default false) |
| mobile? | boolean | Whether the presence is mobile (default false) |
| custom_status? | ?custom status object | The custom status this session publishes. Read only when the account has no saved custom status |
The account's saved status is used when status is absent or null, when it is the string unknown, and when it is online while the saved status is not online. Otherwise the value is used as sent.
Identify accepts offline as a distinct initial status, and Presence Update normalises offline to invisible. A session whose resolved status is offline or invisible publishes status: "offline" and a null custom_status to other users.
Custom status object¶
Structure¶
| Field | Type | Description |
|---|---|---|
| text? | ?string | The text of the custom status |
| expires_at? | ?ISO8601 timestamp | The time the custom status expires |
| emoji_id? | ?snowflake | The ID of a custom emoji |
| emoji_name? | ?string | A Unicode emoji. Ignored when emoji_id is supplied |
Only these fields are read. Presence Update validates the object through the API before publishing it: an emoji_id that names no emoji rejects the object, and an emoji_id from an account without TEBHOS Pro is dropped from the published object while the rest is kept. The published presence adds emoji_animated, and fills emoji_name from the custom emoji's name.
Resume¶
Opcode 6 restores a retained session on a fresh socket.
| Field | Type | Description |
|---|---|---|
| token | string | The token that owns the retained session |
| session_id | string | The session ID from Ready |
| seq | integer | The last Dispatch sequence the client processed |
All fields are required. A payload that is not an object, a missing field, a non-string token or session_id, or a seq that is not an integer closes with 4002 and reason Invalid resume payload.
An unknown or expired session produces Opcode 9 with d: false and leaves the socket unauthenticated. A token that does not own the session closes with 4004 and reason Invalid token. A seq above the session's current sequence, or below the sequence it has already acknowledged, closes with 4007 and reason Invalid sequence. A session that cannot be reached closes with 4000 and reason Session unavailable. None of those closes ends the retained session that session_id names.
A successful Resume replays every retained Dispatch strictly above seq in order and finishes with Resumed. It also replaces the session's socket, and the displaced socket receives Opcode 7 followed by a close with 4000.
A Resume sent on a socket that already has a session attached closes with 4005 and reason Already authenticated. Resume shares the source IP Identify budget: an over-budget Resume is discarded without a reply, so reconnect and send it again.
Evicted events are not reported
TEBHOS does not track a replay floor. When the replay buffer has already dropped events above your seq, the Resume still succeeds and replays only what remains. If you suspect you fell far behind, Identify again instead. See Replay and backpressure.
Presence Update¶
Opcode 3 replaces the current session presence.
| Field | Type | Description |
|---|---|---|
| status | string | The status to publish: online, idle, dnd, invisible, or offline |
| afk? | boolean | Whether the session is away (default false). Only true sets it |
| mobile? | boolean | Whether the session is mobile (default false). Only true sets it |
| custom_status? | ?custom status object | The custom status that replaces the current one |
{
"op": 3,
"d": {
"status": "idle",
"afk": true,
"mobile": false,
"custom_status": {
"text": "away from keyboard"
}
}
}
status is required. A payload that is not an object, an object with no status key, and a status string outside the accepted set all close with 4002 and reason Invalid presence payload. The empty string resolves to online. Send a string: non-string values are not rejected, and their effect is not part of the contract.
offline is normalised to invisible, so a Presence Update cannot publish a session as offline while it is connected.
custom_status is replaced only when the key is present. Omitting the key keeps the current custom status, and null clears it. An object the API rejects, such as an emoji_id that names no emoji, leaves the current custom status in place. The connection stays open.
The published presence carries a custom status object and no activities.
Presence Update has its own limit of five accepted commands per 20 seconds on one WebSocket. A further update inside that window is discarded without closing the connection, after it has consumed the shared payload budgets.
Voice State Update¶
Opcode 4 joins, moves, updates, or leaves the voice membership associated with the current Gateway session. See Voice for the full placement model.
| Field | Type | Description |
|---|---|---|
| guild_id? | ?snowflake | The guild containing the voice channel. Null selects the direct message and group direct message call context |
| channel_id? | ?snowflake | The channel to join or move to. Null leaves the current membership in that context |
| connection_id? | ?string | The existing voice connection this update applies to. A channel_id with no connection_id opens a new connection |
| self_mute? | boolean | Whether the client has muted its own microphone (default false) |
| self_deaf? | boolean | Whether the client has deafened its own output (default false) |
| self_video? | boolean | Whether the client publishes camera video (default false) |
| self_stream? | boolean | Whether this connection advertises a screen share track (default false). Published as false when the member lacks STREAM in the channel |
| is_mobile? | boolean | Whether this is a mobile voice client (default false) |
| viewer_stream_keys? | ?array[string] | The stream keys this connection is watching. An omitted key keeps the current list |
| latitude? | number or string | The client latitude, used to pick a voice region |
| longitude? | number or string | The client longitude, used to pick a voice region |
{
"op": 4,
"d": {
"guild_id": "1189375284394692608",
"channel_id": "1189375284394692610",
"self_mute": false,
"self_deaf": false,
"self_video": false,
"self_stream": false
}
}
Every field is optional, but d must be an object: any other value closes with 4002 and reason Invalid voice payload. A non-null guild_id or channel_id is a decimal snowflake string. Any other value fails validation, and the update is dropped with no close code and no event.
| Command shape | Result |
|---|---|
channel_id and no connection_id |
Opens a new connection |
channel_id and a connection_id |
Updates or moves that connection |
guild_id, null channel_id, and a connection_id |
Drops that guild connection |
guild_id, null channel_id, and no connection_id |
Refused with VOICE_MISSING_CONNECTION_ID |
Null guild_id, null channel_id, and a connection_id |
Drops that call connection |
Null guild_id, null channel_id, and no connection_id |
Drops every voice membership the session holds |
The command has no session_id field. TEBHOS identifies the voice membership by the Gateway session that sends it.
Joining or replacing a grant produces Voice Server Update with the LiveKit token and endpoint for the media connection, and Voice State Update for every session that can see the channel. A refused update produces no event.
The first two updates in a rolling one-second window take effect immediately. Later updates can be delayed or replaced by newer updates for the same guild_id and connection_id. See command rate limits.
Request Guild Members¶
Opcode 8 requests bounded member chunks.
| Field | Type | Description |
|---|---|---|
| guild_id? | snowflake | The ID of the single guild to query. Read only when guild_ids is absent or empty |
| guild_ids? | array[snowflake] | A non-empty array of the guild IDs to query |
| query? | string | The display name prefix to match (default empty string), case-insensitive, against the guild nickname, then the global name, then the username |
| limit? | integer | The result limit, from 0 through 100 and clamped (default 0). 0 with a non-empty query returns up to 25 results, and 0 with an empty query requests the full member list |
| user_ids? | array[snowflake] | The explicit user IDs to select, at most 100 (default empty). A non-empty array ignores query and limit |
| presences? | boolean | Whether results include presences (default false). Offline and invisible presences are omitted |
| nonce? | string | A value of at most 32 bytes echoed in each chunk |
{
"op": 8,
"d": {
"guild_id": "1189375284394692608",
"query": "ann",
"limit": 50,
"presences": true,
"nonce": "a1b2c3"
}
}
The command never closes the connection. Invalid input is coerced or discarded:
- A guild ID that is not a positive snowflake abandons the whole request.
- Duplicate guild IDs are collapsed.
- A
user_idsarray longer than 100 entries abandons the whole request. Individual entries that are not positive snowflakes are dropped. - A
limitthat is not a non-negative integer becomes0, and a larger value is clamped to100. - Any non-string
querybecomes the empty string. - A
noncethat is not a string of at most 32 bytes becomes null. The nonce is echoed only when the request named exactly one guild.
A guild the session is not connected to is skipped. A bot requests one guild at a time, and a bot request naming two or more guilds is abandoned.
An empty query, a limit of 0, and an empty user_ids together request the complete member list (up to 100,000 entries). A user account requesting the complete list needs MANAGE_ROLES, KICK_MEMBERS, and BAN_MEMBERS in that guild, and a request without them is dropped silently. The guild owner and members with ADMINISTRATOR pass the check.
A bot requesting the complete list is limited to one accepted request per guild every 30 seconds. A request inside that window produces Rate Limited and no member chunk.
Results arrive as Guild Members Chunk in pages of at most 1,000 members, each with chunk_index and chunk_count. Those chunks are delivered live and never retained for Resume.
Only one member request runs at a time on one WebSocket. While one runs, a newer request replaces any earlier pending request and starts when the active one finishes. Bounded requests covers the four-slot limit this command shares with the other three.
Lazy Request¶
Opcode 14 sets the per-guild subscriptions that decide member list, typing, and synchronisation traffic for the session.
| Field | Type | Description |
|---|---|---|
| subscriptions | map[snowflake, guild subscription object] | The subscription options for each guild, keyed by guild ID. TEBHOS keeps each guild's options for the session and applies them again when the session connects to that guild later |
{
"op": 14,
"d": {
"subscriptions": {
"1189375284394692608": {
"active": true,
"typing": true,
"member_list_channels": {
"1189375284394692610": [[0, 99]]
}
}
}
}
}
A key that is not a valid snowflake, a value that is not an object, and a guild the session is not connected to are skipped without affecting the rest of the command. The command never closes the connection.
Guild subscription object¶
Structure¶
| Field | Type | Description |
|---|---|---|
| active? | boolean | Whether the guild is active for this session |
| sync? | boolean | Whether the session requests a Guild Sync for the guild |
| typing? | boolean | Whether the guild delivers Typing Start, overriding the active flag |
| member_list_channels? | map[snowflake, array[array[integer]]] | The member list windows to subscribe to, keyed by channel ID |
| members? | array[snowflake] | The explicit member IDs to subscribe to for presence, at most 1,000 |
TEBHOS applies each option only when its key is present, in the order active, sync, member_list_channels, members, typing. active and sync must be booleans when present: any other value stops processing of the remaining options for that guild and every guild not yet processed, without a close.
Marking a guild active changes how much traffic it produces, as Event filtering specifies. A transition from passive to active, and from active to passive, both imply a sync even when sync is absent.
member_list_channels maps a channel ID to a list of [start, end] ranges. A range needs start at least 0, end at least start, end at most 100,000, and end - start at most 99. Ranges that fail those bounds are dropped, and each channel keeps at most the first 10 that pass. Subscriptions sent within 100 ms of each other may be merged. A channel the session cannot view, or can view without VIEW_CHANNEL_MEMBERS, receives no Guild Member List Update. Subscribing a channel to at least one range drops the session's other member list subscriptions in that guild, so one session holds at most one member list per guild.
members entries that are not snowflakes are dropped, and the first 1,000 that pass are kept. A member the session shares no viewable channel with is dropped as well. Each members array replaces the session's previous presence subscription set for that guild.
typing decides Typing Start delivery for the guild independently of the active flag. Non-boolean values are ignored.
Request Guild Counts¶
Opcode 15 requests the current member and online counts for guilds.
| Field | Type | Description |
|---|---|---|
| guild_ids | array[snowflake] | The guild IDs to query, deduplicated, sorted, and truncated to 100 |
| nonce? | string | A value of 1 through 64 bytes echoed in the result |
Entries that are not positive snowflakes are dropped, and a guild the session is not connected to is skipped. A nonce outside the length bound is omitted from the result. The command never closes the connection.
Results arrive in one Guild Counts Update. The request has a 3,000 ms deadline, and a guild that does not answer within 2,000 ms is omitted.
Request Channel Member Counts¶
Opcode 16 requests the member and online counts for channels in one guild.
| Field | Type | Description |
|---|---|---|
| guild_id | snowflake | The ID of the guild whose channels are queried |
| channel_ids | array[snowflake] | The channel IDs to query, deduplicated, sorted, and truncated to 25 |
| channel_id? | snowflake | A single channel ID, read only when channel_ids is absent or not an array |
| nonce? | string | A value of 1 through 64 bytes echoed in the result |
{
"op": 16,
"d": {
"guild_id": "1189375284394692608",
"channel_ids": ["1189375284394692610"],
"nonce": "channel-counts-1"
}
}
A guild_id that is not a positive snowflake, or a guild the session is not connected to, produces an empty result. Channel entries that are not positive snowflakes are dropped. The command never closes the connection.
Results arrive in one Channel Member Counts Update with a 2,000 ms guild deadline. A channel is counted only when the session holds both VIEW_CHANNEL and VIEW_CHANNEL_MEMBERS there.
Bounded requests¶
One WebSocket processes at most four bounded requests at once across Request Guild Members, Lazy Request, Request Guild Counts, and Request Channel Member Counts. A command that arrives when all four slots are taken is dropped, without a close and without a result. Each request has a 10,000 ms deadline, after which it produces no further events.
Request Guild Members also keeps one replaceable pending request while another member request is active. See Bounded commands.