Skip to content

Gateway overview

The TEBHOS Gateway is a persistent WebSocket. It sends account and guild events and accepts a small set of bounded client commands. Every other read or mutation is an HTTP API operation.

Each surface has its own framing and authentication

The Gateway, the HTTP API, and the media proxy each define their own. Nothing negotiated on one applies to another.

Connecting

A client opens the socket, waits for Hello, sends Identify, and then heartbeats for the life of the connection.

  1. Read endpoints.gateway from the instance discovery document (wss://tebhos.com/ws on the public instance) and open a WebSocket to it with ?v=1&encoding=json.
  2. Read Opcode 10 Hello and take heartbeat_interval from it.
  3. Send Opcode 2 Identify with the token and properties.
  4. Read Ready and keep its session_id for a later Resume.
  5. Send Opcode 1 Heartbeat with the last Dispatch sequence every heartbeat_interval milliseconds.
{
  "op": 2,
  "d": {
    "token": "...",
    "properties": {
      "os": "Linux",
      "browser": "my-client",
      "device": "desktop"
    }
  }
}

Only token and properties are required. Send the raw user session token or bot token ({application_id}.{secret}) without an HTTP authentication prefix. A Bot prefix on a bot token is tolerated and stripped; Bearer is not. See Identify for optional fields. Account and guild updates arrive as Dispatches, with the event name in t and its data in d.

const ws = new WebSocket("wss://tebhos.com/ws?v=1&encoding=json");
let seq = null;
let timer = null;

ws.onmessage = (frame) => {
  const payload = JSON.parse(frame.data);
  if (payload.s != null) seq = payload.s;

  switch (payload.op) {
    case 10: // Hello
      timer = setInterval(
        () => ws.send(JSON.stringify({ op: 1, d: seq })),
        payload.d.heartbeat_interval,
      );
      ws.send(JSON.stringify({
        op: 2,
        d: {
          token: process.env.TEBHOS_TOKEN,
          properties: { os: "linux", browser: "my-bot", device: "my-bot" },
        },
      }));
      break;
    case 1: // Server heartbeat request
      ws.send(JSON.stringify({ op: 1, d: seq }));
      break;
    case 0: // Dispatch
      console.log(payload.t, payload.d);
      break;
  }
};
ws.onclose = (event) => {
  clearInterval(timer);
  console.log("closed", event.code, event.reason);
};
import asyncio, json, os
import websockets

async def main():
    async with websockets.connect("wss://tebhos.com/ws?v=1&encoding=json") as ws:
        hello = json.loads(await ws.recv())
        interval = hello["d"]["heartbeat_interval"] / 1000
        seq = None

        async def heartbeat():
            while True:
                await asyncio.sleep(interval)
                await ws.send(json.dumps({"op": 1, "d": seq}))

        asyncio.create_task(heartbeat())
        await ws.send(json.dumps({
            "op": 2,
            "d": {
                "token": os.environ["TEBHOS_TOKEN"],
                "properties": {"os": "linux", "browser": "my-bot", "device": "my-bot"},
            },
        }))
        async for raw in ws:
            payload = json.loads(raw)
            if payload.get("s") is not None:
                seq = payload["s"]
            if payload["op"] == 1:
                await ws.send(json.dumps({"op": 1, "d": seq}))
            elif payload["op"] == 0:
                print(payload["t"])

asyncio.run(main())

Protocol version

Gateway version 1 is the only version. The v=1 connection parameter selects it. The Gateway closes with 4012 and reason Invalid API version for any other value and for an omitted one, before it sends Hello.

Discovering the endpoint

Read endpoints.gateway from the instance discovery document (GET https://tebhos.com/api/v1/.well-known/tebhos). A bot can instead call GET /gateway/bot, which returns the same WebSocket URL together with a fixed recommended shard count and a fixed session start limit object. That route rejects any credential other than a bot token with 401.

Connection parameters

Append the connection parameters to the discovered URL.

Field Type Description
v integer The Gateway protocol version, which is 1
encoding? string The payload encoding. Only json is implemented, and every value, including an absent one, selects JSON
compress? string The compression stream, zstd-stream or none. Any other value selects no compression
stream? string The flag that enables the compression stream, 1 or true. compress=zstd-stream selects compression only when this flag is also set

Unknown parameters are ignored. An unrecognised compress or stream value selects no compression and the connection stays open. A client MUST read whether the connection is compressed from the frame type it receives. On a zstd-stream connection every server frame is a binary frame.

The TEBHOS web and desktop clients connect with ?v=1&encoding=json&compress=zstd-stream&stream=1.

A connection can close before Hello

The Gateway validates the version and applies the per-IP concurrent connection limit before sending Hello, so a rejected connection can close with no payload.

Gateway payload

Every logical message uses the Gateway payload.

Field Type Description
op integer Gateway opcode
d? any The opcode payload. Required in every client-to-server payload. Heartbeat ACK and Reconnect have no d, and Invalid Session has the boolean false
s? integer The non-negative session sequence, present only on Dispatch
t? string The event name, present only on Dispatch

A decoded payload that is not a JSON object closes with 4002 and reason Decode failed. An object with no op closes with 4002 and reason Invalid payload. An object with op but no d closes with 4001 and reason Unknown opcode, except Identify and Resume, which close with 4005.

A Dispatch is a server-to-client event payload. Every live Dispatch advances the session sequence by one. A session starts at sequence 0, so the Ready sequence is 1.

A replayed Dispatch keeps its original sequence, and a replayed run can have gaps, because Guild Sync, Guild Member List Update, and Guild Members Chunk are delivered live and never retained. Resumed carries the current sequence and does not advance it. The next live Dispatch after Resumed has that sequence plus one. The sequence is local to one Gateway session and has no meaning across sessions or shards.

Framing

One inbound WebSocket message is limited to 4,096 bytes on the wire, and a compressed inbound message is limited to a further 4,096 bytes after decompression. A message past either bound closes with 4002 and reason Payload too large. An inbound message that cannot be decompressed closes with 4002 and reason Decompression failed.

JSON payloads are UTF-8 objects. An uncompressed client payload is sent in a text frame, and a payload the client compressed with the negotiated zstd stream is sent in a binary frame.

The Gateway does not inspect the inbound frame type. It decodes every inbound message with the compression the connection negotiated. A client MUST send every payload in the negotiated representation: uncompressed on a connection with no compression, and through the same compression stream, in order, on a zstd-stream connection.

JSON integer representation

Snowflakes are decimal strings. See Snowflakes for the identifier contract. Sequences, counts, versions, and bitfields are JSON numbers.

Compression

zstd-stream is a continuous stream in both directions. A client MUST feed every server frame to the same decompressor in arrival order and produce every client frame from the same compressor. One WebSocket message carries exactly one Gateway payload.

Hello is already compressed on a connection that negotiated zstd-stream, so the first frame such a connection receives is a binary frame. When the server cannot load its zstd implementation, the connection closes with 4002 and reason Compression failed: zstd-stream as it tries to send that first frame.

Compression is negotiated once

A connection cannot change its compression after the upgrade. Changing it requires a new WebSocket, which means a new Hello and either a new Identify or a Resume.

Signalling state machine

A connection moves through these states: Opening, Unauthenticated, Starting, Replaying, and Ready. Heartbeat is accepted in every open state, and Closed is terminal for that WebSocket.

Opening

Event and condition Action Next state
WebSocket accepted, v=1, and connection capacity available Send Hello Unauthenticated
v is absent or is not 1 Close with 4012 and reason Invalid API version Closed
The source IP already holds 256 connections Close with 4008 and reason Too many connections Closed

Unauthenticated

Event and condition Action Next state
Valid Identify and Identify capacity available Begin session creation Starting
Identify while the node drains, the node is at capacity, session starts are paused, or the account is outside the session rollout Hold the payload and retry it in the background Unauthenticated
Valid Resume Look up the retained session named by session_id Starting
Identify or Resume after the source IP Identify budget is exhausted Discard the payload without a reply Unauthenticated
Any command other than Heartbeat, Identify, or Resume Close with 4003 and reason Not authenticated Closed

Starting

Event and condition Action Next state
Session creation succeeds Send Ready Ready
Session creation fails permanently: invalid token, invalid shard, sharding required, or too many sessions Close with the code listed in Hello and session creation Closed
Session creation fails with an error the Gateway does not classify Close with 4000 and reason Failed to start session Closed
Session creation fails temporarily: draining, at capacity, a failed or rate-limited session RPC, a timeout, or the rollout Hold the Identify and retry it in the background Unauthenticated
Resume succeeds Replay retained Dispatches Replaying
Resume names an unknown or expired session Send Invalid Session with d: false Unauthenticated

Replaying

Event and condition Action Next state
All retained Dispatches were sent Send Resumed Ready
Authenticated command Process the command Replaying
Identify or Resume Close with 4005 and reason Already authenticated Closed

Ready

Event and condition Action Next state
Authenticated command that is valid in the session Process the command Ready
Identify or Resume Close with 4005 and reason Already authenticated Closed

Replaying or Ready

Event and condition Action Next state
The session process ends while this socket holds it Send Invalid Session with d: false Unauthenticated
Gateway drain or session transfer to another node Send Reconnect and close with 4000 Closed
Another socket attached this session with Resume Send Reconnect and close with 4000 Closed

Any open state

Event and condition Action Next state
Heartbeat with no session attached, a null payload, or a sequence the session accepts Send Heartbeat ACK Same state
The Gateway is awaiting an acknowledgement and more than 45,000 ms have passed since the last one Close with 4009 and reason Heartbeat timeout Closed
Size, decompression, or decoding validation fails Close with 4002 and the matching reason from Gateway payload or Framing Closed
Transport ends while a session exists Retain the session for 60,000 ms Closed

An opcode outside the registry, and a server opcode sent by a client, close with 4001 once a session is attached and with 4003 while the connection is unauthenticated.

Hello and session creation

The server sends Opcode 10 Hello while accepting the WebSocket.

{
  "op": 10,
  "d": {
    "heartbeat_interval": 41250
  }
}

The interval is in milliseconds and is authoritative for the connection.

Opcode 2 Identify creates a session. The Gateway validates the token against the TEBHOS API: a user session token must belong to a live login session, and a bot token must match a bot application. A successful Identify sends Ready, whose session_id identifies the retained session. TEBHOS publishes no separate resume URL, so a Resume reconnects to the same Gateway endpoint the client discovered.

An invalid token closes with 4004 and reason Invalid token. 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 maximum. A malformed shard pair closes with 4010 and reason Invalid shard. A bot session whose shard resolves to more than 2,500 guilds closes with 4011 and reason Sharding required.

A held Identify sends nothing back

A draining node, a node at capacity, paused session starts, an account outside the rollout percentage, and a temporarily failing session lookup all keep the payload and retry it every 1 to 2 seconds. Keep heartbeating while you wait for Ready.

An over-budget Identify is dropped

Once the source IP Identify budget (300 per 60 seconds) is exhausted, the Gateway keeps no copy of the payload. The client sends its next Identify itself.

Heartbeats

Opcode 1 is accepted before and after authentication. Before a session exists its payload is ignored and the server still acknowledges. Once a session exists, send the most recently processed Dispatch sequence, or null before any Dispatch.

{
  "op": 1,
  "d": 42
}

The server answers with Opcode 11 Heartbeat ACK, which has no d. Once a session is attached, a d that is neither null nor an integer closes with 4007 and reason Invalid sequence. When the Gateway cannot confirm the sequence with the session within 5,000 ms, the connection closes with the same code and reason.

The Gateway checks the heartbeat state every 13,750 ms. Once 90 per cent of the interval has passed since the last acknowledgement, it sends Opcode 1 with d: null. Answer it immediately with your own Opcode 1, and keep sending heartbeats at the advertised interval. A connection that misses the deadline closes with 4009 and reason Heartbeat timeout.

A heartbeat with a sequence permanently trims every retained Dispatch at or below that sequence from the replay buffer and records it as the acknowledged sequence. A client MUST send only a sequence it has processed, because a later Resume from a lower sequence closes with 4007.

Resuming a session

Opcode 6 supplies the original token, the Ready session_id, and the last processed Dispatch sequence.

{
  "op": 6,
  "d": {
    "token": "...",
    "session_id": "6F1D0B7C9A2E4F83B5C1D9E7A4F20B13",
    "seq": 42
  }
}

A successful Resume replays every retained Dispatch above seq in order and ends with Resumed.

All fields are required. A missing field, a token or session_id that is not a string, or a seq that is not an integer closes with 4002 and reason Invalid resume payload.

The Gateway retains a disconnected session for 60,000 ms. An accepted seq is no greater than the session's current sequence and no less than the sequence the session has already acknowledged. A seq outside either bound closes with 4007 and reason Invalid sequence.

An unknown or expired session sends Opcode 9 with d: false and leaves the socket unauthenticated. A token that does not own the named session closes with 4004 and reason Invalid token. A session that cannot be reached closes with 4000 and reason Session unavailable. None of those failures destroys a separately retained session.

A successful Resume replaces the session's socket and restores the presence status the session last selected. A session whose socket dropped is published as offline after 5,000 ms, so a Resume later than that republishes the restored status.

Send Resume only on a fresh socket: a Resume on a socket that already has a session attached closes with 4005 and reason Already authenticated. Resume also shares the source IP Identify budget, and an over-budget Resume is discarded without a reply. When the resumed session was still attached to a different socket, that socket receives Opcode 7 Reconnect and then closes with 4000.

No replay floor

TEBHOS does not track which events the replay buffer has evicted. A seq inside both bounds always resumes, even when events above it were already dropped, and those events are simply missing from the replay. A client that was disconnected for long or received a lot of traffic should Identify again instead.

Retention covers reconnection recovery only

Limits and rate limits states the exact bounds. Guild Sync, Guild Member List Update, and Guild Members Chunk are never retained.

Reconnect

Opcode 7 Reconnect asks the client to open a new WebSocket. The Gateway sends it when the node drains the session, when the node transfers the session to another node, and to the socket a successful Resume displaces.

{
  "op": 7
}

The current socket then closes with 4000 and reason Session drain requested; reconnect to continue. A Resume sent within 60,000 ms of the close can recover the session, subject to the sequence bounds in Resuming a session.

Invalid session

Opcode 9 with d: false means the named session cannot be resumed. The Gateway sends it when Resume names an unknown or expired session, and when the session process attached to a live connection ends.

{
  "op": 9,
  "d": false
}

The socket returns to the unauthenticated state, so it stays open and can Identify again. TEBHOS never sends d: true.

Sessions

A session is created by Identify and identified by the session_id in Ready, a random 32-character upper-case hexadecimal string. It lives on the Gateway independently of the socket: when the transport ends, the session stays resumable for 60,000 ms. One user account can hold at most 100 live sessions at once, and the Sessions Replace event and the Ready sessions array report the account's live sessions to each of them.

Revoking a login session over the HTTP API, and resetting or deleting a bot, terminates the matching Gateway sessions.

Sharding

In Identify, a client MAY supply shard as a [shard_id, shard_count] pair. shard_id is a non-negative integer below shard_count, and shard_count is from 1 through 16,384. An omitted or null value applies no sharding. A malformed pair closes with 4010 and reason Invalid shard.

A guild belongs to shard (guild_id >> 22) % shard_count, computed on the integer value of the guild's snowflake.

At Identify, the Gateway filters the account's guild list to the guilds the shard owns, and the session connects only to those. The filtered set is also the Ready guilds array. On a bot session each of those guilds starts as an unavailable guild, and one Guild Create or Guild Delete per guild follows Ready. Ready echoes the accepted pair back as shard.

Only a bot session is checked against the guild ceiling. A bot whose shard owns more than 2,500 guilds closes with 4011 and reason Sharding required. A bot that supplies no pair is checked against its whole guild list. A user session is bounded by the 100-session limit alone, whatever its guild count.

Shard 0 also receives account-level traffic

A session with a non-zero shard_id drops every Dispatch that names no guild, apart from Rate Limited, Guild Counts Update, and Channel Member Counts Update. Account-level Dispatches (direct messages, relationships, calls, settings) name no guild, so handle them on shard 0. See Event filtering.

TEBHOS has no large bot tier, no shard-count alignment requirement, and no Identify concurrency buckets. GET /gateway/bot returns a fixed recommendation of one shard.

Intents

TEBHOS has no intents field, no intent bits, and no privileged-intent approval. The Gateway ignores an intents key in Identify. A client shapes its traffic with Lazy Request subscriptions and the Identify ignored_events list, as Event filtering describes.

Ordering

Dispatch ordering applies within one Gateway session. It creates no total order across shards, HTTP responses, or media proxy operations.

Guild Create and Guild Sync carry the complete roles, channels, emojis, stickers, and voice states for the guild they name, and a client replaces its stored lists with them. Every other Dispatch for that guild changes part of that stored state.