Wire Protocol
The SDKs speak this protocol for you. You need it to write a client in a language without an SDK, or to read what is on the wire while debugging. Everything here was checked against kraken v0.9.0; where kraken's own docs/PROTOCOL.md disagrees, this page follows the code.
Transport
- Endpoint:
ws://<host>:8080/ws, orwss://behind your TLS proxy. - Frames: binary WebSocket frames, each one a MessagePack-encoded map with a
typefield. Strings are packed as MessagePack strings. There is no other framing. - JSON: kraken also accepts a JSON-encoded map in a text frame. Its replies are always MessagePack binary frames.
- Heartbeat: an empty binary frame (zero bytes). After authentication kraken answers each one with an empty frame; before it, kraken ignores them. The SDKs send one every 30 seconds.
- Idle timeout: a connection that sends nothing for 60 seconds is closed with code
1000. - Frame size: a frame larger than 1 MiB closes the connection with code
1009. - Payload size: a publish whose
data, packed as MessagePack, exceeds 921,600 bytes is refused with error42930. - Rate: 50 publishes per second per connection; the rest of that second's publishes are refused with error
42910.
Protocol versions
The client states a protocolVersion in its auth frame, and kraken answers with the lower of that and its own, which is 2. A client that sends none is treated as version 1. The current SDKs send 2.
| Behaviour | Version 1 | Version 2 |
|---|---|---|
| Address outside the actor's grants | Error not_authorized | Error 42940, unknown_topic, with a hint |
| Publish acknowledgement | None | A published frame, when the publish carries a msgRef |
Connection lifecycle
1. Authentication
The first frame after the WebSocket opens:
{
"type": "auth",
"token": "dev-token-bob",
"protocolVersion": 2,
"reconnect": false,
"clientId": "worker-1"
}
| Field | Meaning |
|---|---|
token | Required. A static token, an actor token, or a client token. |
protocolVersion | 2 for current clients |
reconnect | true asks kraken to restore the subscriptions the auth backend lists for the actor (active_subscriptions). The SDKs set it after an unexpected disconnect. Without it, a connection starts with no subscriptions. |
clientId | Optional. Names this client instance, for actors whose auth backend asks for a persistent session (only meaningful with the MQTT broker backend). Without one, only the actor's first live connection gets the persistent session and the others get clean ones; with one, each instance keeps its own. Letters, digits, - and _, up to 64 characters. |
projectId | Optional. Used only in kraken's logs. |
On success:
{
"type": "auth",
"success": true,
"actorTokenId": "bob",
"projectId": "demo-project",
"actorType": "user",
"protocolVersion": 2,
"restoredSubscriptions": []
}
restoredSubscriptions lists what kraken restored, exactly as the auth backend gave it (addresses, or objects with pattern, loadBalance and so on). It is empty on a fresh connect, and on a reconnect when the backend lists nothing.
On failure:
{ "type": "auth", "success": false, "error": "access_denied" }
error | Meaning |
|---|---|
access_denied | The auth backend refused the token |
authentication_failed | The HTTP auth backend answered with a status other than 200 |
connection_failed | The HTTP auth backend could not be reached in time |
token_expired | The token's expiry has passed |
connection_limit_reached | The actor's organization is at its connection limit |
broker_unavailable | kraken could not open its session with the broker backend |
After a failed auth the socket stays open, unauthenticated. Any other frame sent before a successful auth is answered with {"type": "error", "error": "not_authenticated"}. kraken sets no deadline for authenticating beyond the 60 second idle timeout; the SDKs give up after 10 seconds.
2. Heartbeats and revalidation
Heartbeats are where kraken does its periodic work for a connection:
- If the connection authenticated with a token that carries an expiry (a client token) and the expiry has passed, kraken sends
{"type": "disconnect", "reason": "token_expired"}and closes with code4003. - If 10 minutes have passed since the connection was last validated, kraken revalidates the actor with its auth backend. A refusal closes the connection with code
4001; see Authentication.
3. Re-authentication
A connection can swap its token for a fresh one without reconnecting, which is how the JavaScript SDK renews short-lived client tokens:
{ "type": "reauth", "token": "<a fresh token>" }
{ "type": "reauth", "success": true, "authExpiresAt": 1791520271 }
{ "type": "reauth", "success": false, "error": "actor_mismatch" }
The new token must belong to the same actor; one for another actor is refused with actor_mismatch. An expired token is refused with token_expired, and anything else with the same errors as auth. On success the connection's grants, limits and expiry are replaced with the new validation's, and authExpiresAt (unix seconds) is present when the new token has an expiry. On failure the connection carries on under its current token. Subscriptions are untouched either way.
Client frames
subscribe
{
"type": "subscribe",
"topic": "demo/general/messages",
"qos": 1,
"filters": ["user_123", ["region-eu", "priority"]],
"loadBalance": true,
"loadBalanceGroup": "workers"
}
| Field | Meaning |
|---|---|
topic | Required. An address, app/room/topic, that the actor's grants allow it to subscribe to. An actor in an access scope may send app/scope/room/topic, or leave the scope out and kraken inserts it. |
qos | 0, 1 or 2 (default 1; anything else becomes 1). Passed to the broker backend; the default syn broker ignores it. |
filters | Optional, up to 100. A message is delivered when it matches any entry. A string matches a publish with that filter value; a nested array of two or more values matches a publish carrying all of them. Values may not be empty or contain /, #, + or |. |
loadBalance | true makes this a shared subscription: each message goes to exactly one subscriber in the group, not to all of them. |
loadBalanceGroup | The group's name, by default the actor's id. kraken prefixes it with the project and app, so equal names in different apps do not collide. |
kraken answers {"type": "subscribed", "topic": "...", "loadBalance": false}, or an error frame naming the topic. Subscribing again to a topic the connection already holds replaces the earlier subscription, filters and mode included. If the topic has a retained message, the new subscriber receives it straight away. If the app has a hydration webhook, kraken calls it and sends a hydration frame.
With an HTTP auth backend, a subscribe to an address that the connection's cached grants do not cover first triggers the backend's /check-room-access; see HTTP Auth Contract.
unsubscribe
{ "type": "unsubscribe", "topic": "demo/general/messages" }
Always answered with {"type": "unsubscribed", "topic": "..."}, even for a topic the connection was not subscribed to.
publish
{
"type": "publish",
"topic": "demo/general/messages",
"data": { "text": "hello" },
"qos": 1,
"retain": false,
"echo": true,
"filter": "user_123",
"msgRef": "1-k3j9x"
}
| Field | Meaning |
|---|---|
topic, data | Required. data is any MessagePack value. |
qos | As for subscribe |
retain | true keeps this message as the topic's retained message, delivered to each new subscriber. The syn broker keeps the last one per topic for an hour, in memory. |
echo | Default true, which delivers the message to the publishing connection too, if it is subscribed. false skips the publishing connection; other connections of the same actor still receive it. |
filter | One filter value. Or filters, an array of values that a subscriber's AND group must all match; kraken lowercases and sorts them. |
msgRef | Any string. With protocol version 2, kraken answers {"type": "published", "topic": "...", "msgRef": "..."} once it has accepted the message, and any error frame for this publish carries the msgRef. Without it, a successful publish gets no reply. |
kraken checks a publish in this order: the rate limit, the payload size, whether the project is blocked, and the actor's grants. published means kraken accepted the message for delivery; it is not a receipt from any subscriber. If the app has a trigger webhook, kraken calls it after accepting the publish.
setFilters
{ "type": "setFilters", "topic": "demo/general/messages", "filters": ["user_789"] }
Replaces the filters of an existing subscription; an empty array removes them, so the subscription receives everything on the topic. Answered with {"type": "filtersUpdated", "topic": "...", "filters": [...]}, or the error not_subscribed if the connection holds no subscription to the topic. The same filter rules apply as for subscribe.
presence
{ "type": "presence", "roomId": "general", "data": { "status": "online" } }
Sets this connection's presence in a room. roomId is the room's slug, which kraken finds through the actor's grants; a slug that no grant names is ignored without an answer. A connection has presence in one room at a time: setting it in another room leaves the first. A presence frame without roomId is accepted and stored, but nobody is told.
getPresence
{ "type": "getPresence", "roomId": "general" }
Answered with a presenceList for the room (empty for a room the actor's grants do not name).
lobbySubscribe, lobbyUnsubscribe, getLobbyPresence
{ "type": "lobbySubscribe", "lobbyId": "all-depots" }
{ "type": "lobbyUnsubscribe", "lobbyId": "all-depots" }
{ "type": "getLobbyPresence", "lobbyId": "all-depots" }
lobbyId is the lobby's slug, and the actor's grants must include the lobby. Otherwise kraken answers {"type": "error", "error": "not_authorized", "lobbyId": "..."}. See Lobbies.
ack, batchAck
{ "type": "ack", "msgId": "550e8400-e29b-41d4-a716-446655440000" }
{ "type": "batchAck", "msgIds": ["id1", "id2"] }
Acknowledges messages that arrived with requiresAck: true, which happens when kraken is recording messages (RECORD_MESSAGES). kraken marks them delivered in its store and sends no reply. The SDKs acknowledge automatically.
Server frames
message
{
"type": "message",
"topic": "demo/general/messages",
"data": { "text": "hello" },
"msgId": "afdf4044-6fed-4a7c-9235-7c2285ace677",
"requiresAck": true,
"filter": "user_123"
}
topicis the address as this connection subscribed to it.msgIdandrequiresAckare present only when kraken is recording messages.filteris present when the message is delivered through a filtered subscription, and holds the matched value (an AND group arrives joined with|, for examplex|y). On the defaultsynbroker, a subscription without filters receives every message with"filter": "#", whatever the message was published with.- The frame does not say who published the message. Put that in
dataif subscribers need it.
subscribed, unsubscribed, published, filtersUpdated
The answers to subscribe, unsubscribe, publish (with msgRef) and setFilters, described with those frames above.
presence
{
"type": "presence",
"event": "join",
"data": { "actor_token_id": "bob", "presence": { "status": "online" } }
}
Sent to every connection that has presence in the room, the sender's own included. Connections that have not set presence in the room receive no presence events for it. event is:
joinwhen a connection first sets presence in the room, immediately followed by anupdatewith the same data;updatewhen it sets presence again;leavewhen it closes or moves its presence to another room. Aleavecarries onlyactor_token_id.
Note the key spelling: presence events use actor_token_id, presence lists use actorTokenId.
presenceList
{
"type": "presenceList",
"roomId": "general",
"data": [
{ "actorTokenId": "bob", "presence": { "status": "online" }, "joinedAt": 1791519715, "status": "online" }
]
}
joinedAt is in unix seconds.
lobbySubscribed, lobbyUnsubscribed, lobbyPresenceList, lobbyPresence
{
"type": "lobbySubscribed",
"lobbyId": "all-depots",
"presence": {
"north": { "bob": { "status": "online" } }
}
}
{
"type": "lobbyPresence",
"event": "join",
"lobbyId": "all-depots",
"roomId": "north",
"actorId": "alice",
"data": { "status": "online" }
}
lobbySubscribed answers lobbySubscribe with a snapshot keyed by room slug and then by actor; lobbyPresenceList answers getLobbyPresence in the same shape; lobbyUnsubscribed answers lobbyUnsubscribe. lobbyPresence events follow for every join, update and leave in the lobby's rooms.
hydration
{ "type": "hydration", "topic": "messages", "data": { "recent": [] } }
Sent once per subscribe when the app, or the topic, has a hydration webhook. topic is the bare topic name, not the full address, and data is the webhook's JSON response. If the webhook fails, nothing is sent. See Webhooks.
error
{
"type": "error",
"code": 42940,
"error": "unknown_topic",
"topic": "demo/general/messages",
"hint": "room is not configured. Provision it via the control-plane rooms API before use.",
"msgRef": "1-k3j9x"
}
topic names the address the failed request was about, and msgRef is present when the error belongs to a publish that carried one.
| Code | error | When |
|---|---|---|
42910 | rate_limit_exceeded | More than 50 publishes in one second on this connection |
42920 | monthly_quota_exceeded | The project is blocked by a control backend that reported it (see Plugins) |
42930 | message_too_large | The payload exceeds 921,600 bytes packed. Carries maxSizeBytes. |
42940 | unknown_topic | Version 2: no grant lets this actor subscribe to, or publish on, this address. This includes a grant with the wrong permission. The hint text is fixed and is sent whatever the cause. |
not_authorized | Version 1 clients, instead of unknown_topic. Also any lobby frame for a lobby outside the actor's grants (carries lobbyId). | |
not_authenticated | Any frame before a successful auth | |
not_subscribed | setFilters on a topic this connection has not subscribed to | |
invalid_filter_chars (/, #, +, | not allowed) | A filter value is empty or contains a reserved character | |
too_many_filters (max 100) | More than 100 filters on one subscription |
disconnect and close codes
| Close code | Reason | When |
|---|---|---|
4001 | The auth backend's reason, such as token_revoked | Revalidation refused the actor. No frame precedes the close. |
4002 | connection_limit_reached | Revalidation found the actor's organization over its connection limit |
4003 | token_expired | The connection's token expired. A {"type": "disconnect", "reason": "token_expired"} frame comes first. |
1009 | A frame larger than 1 MiB | |
1000 | No frame from the client for 60 seconds |
Addresses
A client addresses a topic as app/room/topic, using the app and room slugs, and an actor in an access scope as app/scope/room/topic. When only the scoped form matches the actor's subscribe grants, kraken inserts the scope for it. kraken checks for the scoped form using subscribe permission, so an actor that may only publish has to send the scoped address itself.
kraken resolves every address through the connection's grants to an internal broker topic, the same way for subscribe and publish, and never creates rooms: an address that no grant covers is refused. How the internal topic is chosen, and why two actors' grants must agree for them to reach each other, is explained in Static Auth File.
Load balancing
A subscription with loadBalance: true joins a shared group, and each message on the topic goes to exactly one member of the group. On the syn broker the member is picked at random for each message: with two members and ten messages we saw a seven and three split, not an even one. With the MQTT backend, the external broker decides. A connection can hold load-balanced and ordinary subscriptions on different topics at once, and subscribing again with a different loadBalance switches the mode.
Use it to spread work, never for replies a specific client is waiting for.
Quality of service
qos is checked to be 0, 1 or 2 and handed to the broker backend. The default syn broker ignores it; with the MQTT backend it applies to the hop between kraken and the external broker. Neither carries it to the WebSocket. The only acknowledgement on the client's side is published, and nothing is resent. Design consumers to cope with a missing or duplicated message. See Quality of Service.
MQTT
kraken v0.9.0 opens an MQTT 3.1.1 listener on port 1883, intended for devices, with the access token as the MQTT password. It accepts no connections in this release: the connection handler fails to start for every client. Use the WebSocket protocol.
Why MessagePack
MessagePack carries the same data as JSON in fewer bytes, has no parsing ambiguity, and carries binary data without Base64. Implementations exist for every major language, so a client of your own needs only a WebSocket library and a MessagePack library.