Strafe.chat Developers Open Strafe

Gateway

The gateway ("Stargate") is the real-time side of the API: one WebSocket connection over which the server pushes every event the connected account can see. Bots and clients use the same protocol.

This instance's gateway: wss://your-instance.example/gateway/events

Connecting#

Open a WebSocket to the gateway URL with the credential in the handshake:

http
GET /gateway/events HTTP/1.1
Host: your-instance.example
Authorization: Bot <bot token>
Upgrade: websocket
  • Bots send Authorization: Bot <token>. Bot tokens are accepted from the header only - never in the URL, where they would end up in logs.
  • The Strafe client uses its session token (Authorization: Bearer, or ?token= because browsers cannot set headers on a WebSocket). OAuth2 access tokens cannot open a gateway connection.

A rejected handshake is an HTTP error with a JSON body: 401 {"op": 8, "d": {"code": 401, "message": "invalid bot token"}}. A bot, unlike a browser page, sends no Origin header, so the instance's allowed-origins list does not apply to it.

The first frame after a successful upgrade is READY (op 4).

Frames#

Every frame is a JSON object with an op and, usually, a d.

Client → server#

opNamed
0Heartbeat-Optional; resets the read timeout. Answered by nothing
1Subscribe{"space_id": "<space or room id>"}Start receiving a space's or a room's events. Bots are subscribed to everything on connect; see Subscriptions. Subscriptions that need authorizing are rate limited per connection (a burst of 60, refilling ten a second; 4029 when exceeded); re-subscribing to a channel you already hold is free. A subscription ends when the membership that allowed it does: being kicked or banned, leaving, or losing View on a room drops it server-side. One account may hold 25 connections per gateway
2Send{"space_id": "<room id>", "type": "typing", "content": {…}}Publish a lightweight event to a room's subscribers (typing). Messages are not sent this way - use REST
5Unsubscribe{"space_id": "…"}Stop receiving a space's or a room's events
6Ping-Answered with op 7
9Activity{"activities": [{"type": "playing", "name": "…", "started_at": 1761000000000}]}Say what the account is doing on this connection; null or [] clears it. At most one entry, rate limited per connection (a burst of 5, then one every 12 s; over budget the latest report is applied when a token frees, without an error). See Activity

Server → client#

opNamed
3Event{"t": "MESSAGE_CREATE", "room_id": "…", "space_id": "…", "user_id": "…", "d": {…}} - see Events
4ReadyThe READY payload
7Pong-
8Error{"code": 4001, "message": "…"}

An event frame nests: the outer d is the envelope, its t is the event type and its inner d the payload:

json
{
  "op": 3,
  "d": {
    "t": "MESSAGE_CREATE",
    "space_id": "2102443061534523392",
    "room_id": "2102443061534523392",
    "d": { "id": "2105141774585434112", "room_id": "2102443061534523392", "sender_id": "…", "plaintext": "ping" }
  }
}

room_id is set for room events (the field space_id repeats it - a historical quirk), user_id for events delivered on the account's own channel.

READY#

json
{
  "op": 4,
  "d": {
    "user": { "id": "2105141069376131072", "username": "Docs_Bot", "display_name": "Docs Bot", "public_flags": 0, "bot": true },
    "session_id": "0",
    "rooms": [ { "id": "…", "type": 1, "recipients": ["…"], "e2ee_enabled": true, "created_at": "…" } ],
    "relationships": [],
    "spaces": [
      {
        "id": "2102226824774811648", "name": "Unread Test Space 2", "icon": "", "owner_id": "…",
        "everyone_role_id": "2102226824779005952",
        "roles": [ { "id": "…", "name": "@everyone", "permissions": 2334751, "position": 0, "color": 0, "hoist": false, "mentionable": false } ],
        "system_room_id": "…", "default_message_notifications": 0, "afk_timeout": 0
      }
    ],
    "space_rooms": {
      "2102226824774811648": [
        { "id": "2102443061534523392", "type": 3, "name": "files-test", "topic": "", "position": 3, "parent_id": "…", "e2ee_enabled": false, "slowmode_seconds": 0,
          "permission_overrides": [ { "role_id": "…", "allow": 0, "deny": 2 } ], "user_overrides": [] }
      ]
    },
    "voice_states": [],
    "calls": []
  }
}
Field
userThe connected account, with its own presence as only you see it: invisible stays invisible, and activities holds what any of your connections reports (see Activity)
session_idThe client session's id; "0" for a bot (a bot has no session row - its token is the credential)
roomsPrivate messages and group PMs (room types 1 and 2) with their recipients
relationshipsFriends and friend requests; a friend's user.presence carries their activities
spacesEvery space the account is in, each with its roles (sorted by position) and settings
space_roomsSpace id → its rooms (text 3, voice 4, section 5), each with e2ee_enabled, slowmode_seconds and its permission overrides
voice_states, callsWho is in which voice room, and ringing PM calls

Member lists are not in READY - fetch GET /spaces/:id/members when you need one and keep it current from the member events.

Heartbeat#

The server sends a WebSocket ping frame every 54 seconds and closes the connection if nothing (a pong, or any frame) arrives within 60 seconds. Every mainstream WebSocket library answers pings automatically, so a bot has nothing to do. Sending {"op": 6} yourself is fine and gets {"op": 7} back - useful as a liveness check through proxies.

There is no resume: after a disconnect, reconnect and read READY again. To recover messages missed while offline, page GET /rooms/:id/messages (newest first, before= to go back) until you reach the last id you saw.

Subscriptions#

Events are published on channels, one per space (space-wide events: roles, members, settings) and one per room (messages, reactions, typing). Both are subscribed with op 1 and the id in space_id:

json
{ "op": 1, "d": { "space_id": "2102226824774811648" } }   // the space itself
{ "op": 1, "d": { "space_id": "2102443061534523392" } }   // a room in it

A subscription is refused (silently) when the account cannot access that room or is not in that space.

  • Bots are auto-subscribed on connect to every space in READY.spaces, every room in READY.space_rooms, and every PM in READY.rooms. A bot only subscribes by hand to a space it joins after connecting (announced by SPACE_CREATE) and that space's rooms, or a room created while it is connected (SPACE_ROOM_CREATE).
  • The account's own channel (user_id events: relationship requests, new PMs, SPACE_CREATE, SESSION_REVOKED, read receipts) is always subscribed.
  • Clients manage their subscriptions themselves and use op 5 when they leave a space.

Sending over the gateway#

op 2 publishes a small event to a room's subscribers without going through REST. It is used for typing indicators:

json
{ "op": 2, "d": { "space_id": "2102443061534523392", "type": "typing" } }

It is rate limited per connection (a burst of 20, refilling one every 250 ms) and requires the room subscription. Everything that is stored - messages, reactions, membership changes - goes through REST, and the gateway reports it back as an event.

Activity#

op 9 sets what the account is doing - in this version, the game it is playing. The Strafe desktop app sends it when someone turns on sharing in its settings, and a bot can use it to show "Playing …" too:

json
{ "op": 9, "d": { "activities": [ { "type": "playing", "name": "Factory Builder", "started_at": 1761000000000 } ] } }
{ "op": 9, "d": null }   // stopped playing
  • One entry at most. type must be "playing". name is cleaned before use - control characters, line and paragraph separators and bidi controls are removed, runs of whitespace become one space, the ends are trimmed - and must then hold 1 to 128 characters (counted in code points), at least one of them visible. started_at is unix milliseconds: 0 or absent means now, a time in the future becomes now, and one more than 7 days ago becomes 7 days ago.
  • A malformed report (a second entry, another type, a name that is empty or too long) is answered with error 4007 and changes nothing. The connection stays open.
  • Rate limit: a burst of 5 reports per connection, then one every 12 seconds. A report over the budget is not refused: it waits, a newer one replaces it, and the latest is applied when the next token frees, without an error frame. Reporting the same activity again changes nothing and costs nothing.
  • It belongs to the connection. It is never written to the database: the instance holds it in its short-lived cache and forgets it when the connection closes (after a short grace period, so a quick reconnect does not flicker). A new connection starts with none, so report again after every READY. When several of an account's connections report, the most recent report wins.
  • Who sees it: the account itself, its friends and the members of its spaces - in PRESENCE_UPDATE, on member objects and on friends' relationship entries, as presence.activities. Nobody else sees it while the account appears offline or invisible; its own connections still do. People on other instances see it the way they see the account's status.
  • Show the name as plain text - never as Markdown, a link, emoji or a mention - and isolate its direction (<bdi>, dir="auto"), since it is someone else's text.

Events#

Room events reach every subscriber of the room; space events reach every subscriber of the space; account events reach only that account. Payloads are the objects described on the REST page unless noted.

Messages (room)#

td
MESSAGE_CREATEThe message. System notices (joins, leaves) have sender_id: "0" and a system_type
MESSAGE_UPDATEThe edited message
MESSAGE_DELETE{"room_id", "message_id"}
MESSAGE_REACTION_ADD{"room_id", "message_id", "user_id", "emoji"} - emoji is a unicode string or "custom:<id>"
MESSAGE_REACTION_REMOVEsame
ROOM_PINS_UPDATE{"room_id", "message_id", "user_id", "pinned", "pinned_at", "last_pin_timestamp"} - a message was pinned (pinned: true, with pinned_at) or unpinned; last_pin_timestamp is when the room's newest remaining pin was made, null when none
TYPING_START{"room_id", "user_id", "timestamp"} (unix seconds)

Rooms#

tChanneld
SPACE_ROOM_CREATEspaceThe new room, with overrides
SPACE_ROOM_UPDATEspaceThe room (name, topic, slowmode, position, encryption, voice limits)
SPACE_ROOM_DELETEspace{"space_id", "room_id"}

Threads#

Sent space-wide when everyone can view the parent channel, otherwise only to the members who can; a private thread's events go to its members alone.

td
THREAD_CREATEThe thread room (type 6, with its thread block and parent_id). A fresh thread's broadcast carries newly_created: true and the creator's joined; the same event goes to one person when they are added to a private thread, with their own joined
THREAD_UPDATEThe thread room after a change (name, archive, lock, timer)
THREAD_DELETE{"space_id", "room_id", "parent_id"}
THREAD_MEMBERS_UPDATE{"room_id", "added_members": [{"user_id", "joined_at"}], "removed_member_ids", "member_count"}
THREAD_MEMBER_UPDATETo one user: {"room_id", "joined"} - their own membership changed
SPACE_ROOM_OVERRIDE_UPDATE / _DELETEspace
SPACE_ROOM_USER_OVERRIDE_UPDATE / _DELETEspace
ROOM_CREATEaccount
ROOM_UPDATEroom
ROOM_LEAVEaccount
MESSAGE_ACKaccount
MESSAGE_MENTION_UPDATEaccount
ROOM_NOTIFY_SETTINGS_UPDATEaccount

Spaces and members#

tChanneld
SPACE_CREATEaccountYou created a space or were added to one (an invite redeemed elsewhere, a bot install, spaces.join). The space with its roles; rooms are not included
SPACE_UPDATEspaceThe space's settings changed. May carry roles (the full list) after a change that reordered them, such as a bot install
SPACE_DELETEspace{"space_id"} - the space is gone
SPACE_LEAVEaccount{"space_id"} - you were kicked, banned, or left from another session
SPACE_MEMBER_ADDspaceThe member's profile plus space_id, role_ids, joined_at, bot, presence (with their activities), and their space profile: nick, space_avatar, space_banner, space_about_me, space_bio, space_pronouns (each a string, or null for none)
SPACE_MEMBER_REMOVEspace{"space_id", "user_id"}
SPACE_MEMBER_UPDATEspace{"user_id", "role_ids", "nick", "space_avatar", "space_banner", "space_about_me", "space_bio", "space_pronouns"} - roles or the space profile changed; always the full state, so replace all seven fields
SPACE_ROLE_CREATE / SPACE_ROLE_UPDATEspaceThe role
SPACE_ROLE_DELETEspace{"role_id"}

Account#

td
PRESENCE_UPDATE{"user_id", "presence": {"status": "online" | "offline" | …, "custom_status"?, "activities"?}} for people you share a space or PM with. activities is what they are doing now (see Activity), for friends and space members only; absent means nothing, so replace the stored value rather than merging. Sent again when the status, the custom status or the activity changes
USER_UPDATEA profile changed (display name, avatar, pronouns, …) - {"user_id", "display_name", "avatar", "pronouns", …}
RELATIONSHIP_REQUEST / RELATIONSHIP_ADD / RELATIONSHIP_REMOVEFriend requests and friendships (people only)
SESSION_REVOKED{"reason"} - this credential is dead: the account was banned from the instance, signed out everywhere, or (for a bot) the token was reset. The socket is closed right after
VOICE_STATE_UPDATE, CALL_RING, CALL_DECLINE, CALL_ENDVoice and video (see the client)
TO_DEVICE, DEVICE_REVOKED, SESSION_ROTATEEnd-to-end encryption key traffic between devices. A bot can ignore these
KEY_BACKUP_UPDATE{"action": "created" | "deleted", "version"} - the account's key backup version was created or deleted (for deleted, the version that went). A device pinned to a version that is no longer current stops uploading to it and asks the user to confirm the new one with the recovery code, without waiting for its next upload to fail

Errors#

Errors after the handshake arrive as {"op": 8, "d": {"code", "message"}} and never close the connection.

CodeMeaning
401 (handshake)Missing, malformed or unknown credential
4000The frame was not valid JSON
4001Unknown op
4002Invalid subscribe payload
4003Invalid send payload
4004op 2 without a space_id
4005op 2 to a channel you have not subscribed to
4007Invalid activity (op 9): more than one entry, a type other than playing, or a name that is empty or longer than 128 characters once cleaned - see Activity
4403A subscribe to a space or room you cannot access

An op 2 frame that exceeds the send bucket is dropped without an error. An op 9 report over its budget is not dropped: the latest one is applied when the next token frees, also without an error.