We’re on Product Hunt (opens in a new tab)

Protocol reference

WebSocket Protocol Reference

Build a pubb integration with your runtime’s WebSocket and HTTP clients. This reference describes the messages on the wire, including the exact control event names and payload types.

Connection

WebSocket endpoint
wss://ws.pubb.io/socket?appKey=YOUR_APP_KEY&protocol=2

Open a standard WebSocket connection over TLS. URL-encode the public app key; the app ID and app secret do not belong in this URL. No custom headers or WebSocket subprotocol are required.

After the HTTP upgrade, wait for this connection event before subscribing. Parse data as JSON to read your socket_id and the activity_timeout in seconds.

Server → client: connection established
{
  "event": "pubb:connection_established",
  "data": "{\"socket_id\":\"0123456789abcdef0123456789abcdef\",\"activity_timeout\":60}"
}

Each connection receives a new socket ID. Use it to authorize private or presence subscriptions and, optionally, exclude this connection when publishing.

Message format

Send one JSON object per WebSocket text message. Commands use action; server control messages use event. Published application events arrive in a channel envelope with no top-level event.

Server messagePayload type
pubb:connection_establisheddata is a JSON string with socket_id and activity_timeout.
pubb:subscription_succeededPublic/private: the string "{}". Initial presence subscription: an object with presence.
pubb:member_added / pubb:member_removeddata is an object. See presence examples below.
subscription_error / errordata is a plain error string, not JSON.
Application event (no top-level event)data is a JSON string containing name and data.

Parse the outer message first, then decode its payload according to its type. Use the exact control names above: SDK-facing aliases are not replacements for wire event names. Ignore unknown control events for forward compatibility.

Use protocol=2 in the connection URL to receive pubb:subscription_succeeded. Connections without this parameter retain the legacy acknowledgement for existing SDKs. The server sends exactly one acknowledgement per subscribe command, with the same payload in either mode.

Authentication

The public app key identifies the application. Anyone with that key can subscribe to public channels. Channels beginning with private- or presence- require a signature from your trusted backend. Channel access is isolated by application.

  1. Read socket_id from the connection event.
  2. Send socket_id and channel_name to an authenticated endpoint in your own backend.
  3. Your backend checks the user’s access to that exact channel, then calls the pubb authorization endpoint below or signs locally.
  4. Return auth to the client. For presence, also return channel_data unchanged.
Your backend → pubb: private authorization
POST https://api.pubb.io/api/apps/YOUR_APP_ID/auth
Authorization: Bearer YOUR_APP_SECRET
Content-Type: application/json

{
  "socket_id": "0123456789abcdef0123456789abcdef",
  "channel_name": "private-user-42"
}
HTTP 200: authorization response
{
  "auth": "YOUR_APP_KEY:LOWERCASE_HEX_HMAC_SHA256"
}

For a presence channel, include a nonempty string user_id and optional object user_info in the authorization request. Derive both from the authenticated user on your server.

Presence authorization request body
{
  "socket_id": "0123456789abcdef0123456789abcdef",
  "channel_name": "presence-team-42",
  "user_id": "42",
  "user_info": {
    "name": "Alex"
  }
}
HTTP 200: presence authorization response
{
  "auth": "YOUR_APP_KEY:LOWERCASE_HEX_HMAC_SHA256",
  "channel_data": "{\"user_id\":\"42\",\"user_info\":{\"name\":\"Alex\"}}"
}

Signing without the authorization API

Compute HMAC-SHA256 on your server using the app secret as the key and the UTF-8 string below as input. Encode the digest as lowercase hexadecimal, then return APP_KEY:signature.

Signature inputs
Private:  socket_id:channel_name
Presence: socket_id:channel_name:channel_data

Sign the exact serialized channel_data string that you send. Reformatting its JSON changes the signature. Never expose the app secret to a browser or distributed client.

Subscribe / Unsubscribe

Client → server: public subscription
{
  "action": "subscribe",
  "channel": "notifications"
}
Client → server: private subscription
{
  "action": "subscribe",
  "channel": "private-user-42",
  "auth": "YOUR_APP_KEY:LOWERCASE_HEX_HMAC_SHA256"
}
Client → server: presence subscription
{
  "action": "subscribe",
  "channel": "presence-team-42",
  "auth": "YOUR_APP_KEY:LOWERCASE_HEX_HMAC_SHA256",
  "channel_data": "{\"user_id\":\"42\",\"user_info\":{\"name\":\"Alex\"}}"
}

action and channel are required strings. Restricted subscriptions also require the auth string; presence requires channel_data as a string, not an object. These examples use placeholders for signatures.

Server → client: public/private acknowledgement
{
  "event": "pubb:subscription_succeeded",
  "channel": "notifications",
  "data": "{}"
}

Wait for the acknowledgement before expecting delivery. Repeating a valid subscription does not add another subscription; it returns the same acknowledgement with string "{}", even for an already subscribed presence channel. It does not return a fresh presence snapshot.

Client → server: unsubscribe
{
  "action": "unsubscribe",
  "channel": "notifications"
}

Unsubscribe has no acknowledgement and does not require a signature. Remove the channel from your client’s desired subscriptions and ignore any messages already in flight. Closing a connection removes all its subscriptions.

Presence events

A new presence subscription returns an object containing unique user IDs, a map from user ID to user info, and the member count.

Server → client: presence acknowledgement
{
  "event": "pubb:subscription_succeeded",
  "channel": "presence-team-42",
  "data": {
    "presence": {
      "ids": [
        "42"
      ],
      "hash": {
        "42": {
          "name": "Alex"
        }
      },
      "count": 1
    }
  }
}
Server → other members: member added
{
  "event": "pubb:member_added",
  "channel": "presence-team-42",
  "data": {
    "user_id": "43",
    "user_info": {
      "name": "Sam"
    }
  }
}
Server → remaining members: member removed
{
  "event": "pubb:member_removed",
  "channel": "presence-team-42",
  "data": {
    "user_id": "43"
  }
}

Multiple sockets with the same user ID count as one member. Joining with another socket does not add a second member; removal is emitted when the user’s last socket leaves. Rebuild your member list from the new subscription snapshot after reconnecting.

Publish an event over HTTP

The WebSocket accepts subscription commands only. There is no WebSocket publish action or client-event command. Publish from your trusted backend after checking the caller’s permission.

Your backend → pubb: publish request
POST https://api.pubb.io/api/apps/YOUR_APP_ID/events
Authorization: Bearer YOUR_APP_SECRET
Content-Type: application/json
Idempotency-Key: order-1042-created

{
  "channel": "notifications",
  "name": "order.created",
  "data": { "orderId": "1042" }
}
HTTP 202: accepted publication
{
  "success": true,
  "accepted": true,
  "channels": [
    "notifications"
  ],
  "publicationId": "11111111-2222-4333-8444-555555555555",
  "duplicate": false
}

channel is a string or an array of 1–100 strings. Event and channel names must be nonblank and at most 200 characters; publishing rejects channels beginning with debug- or _. Use ASCII channel names of at most 200 bytes to also fit the WebSocket limit. data can be any JSON value and is limited to 10 KiB of UTF-8 JSON; omitted or null data is sent as null.

Optional socket_id excludes one connection. The optional Idempotency-Key header accepts 1–128 printable, nonspace ASCII characters. Reuse it for retries of the same publication within the 24-hour retention window; a repeat returns duplicate: true and the original publication ID.

Server → subscribed clients: published event
{
  "channel": "notifications",
  "data": "{\"name\":\"order.created\",\"data\":{\"orderId\":\"1042\"}}"
}

Decode the outer data string once to obtain the application event name and payload. The publication ID is not included in this WebSocket envelope. Add your own event ID inside the payload if your application needs to identify events.

HTTP 202 confirms acceptance for processing, not delivery. Publications target subscribers connected at acceptance. There is no client delivery acknowledgement, replay cursor or missed-event history; refresh durable state from your backend after interruptions.

Ping / Pong heartbeat

The server sends a WebSocket Ping control frame approximately every 20 seconds. Your WebSocket implementation must respond with a Pong control frame. The read deadline starts at 60 seconds and is extended by 60 seconds whenever a Pong is received; a missed deadline closes the connection.

Browsers handle these frames automatically; JavaScript does not receive them as message events. In other runtimes, enable your library’s automatic Pong handling or respond to Ping with the same payload. Application messages do not extend the Pong deadline.

There is no JSON heartbeat command. Sending {"action":"ping"} or an event named pubb:ping does not satisfy the heartbeat. The initial activity_timeout: 60 describes the server’s timeout in seconds.

Reconnection

Reconnection is client behavior. A native WebSocket does not reconnect automatically, and the server does not restore an old connection or its subscriptions.

  1. On an unexpected close, schedule a bounded retry with exponential backoff and jitter. For example, start around one second and cap the delay at 30 seconds.
  2. Open a new connection and wait for its connection-established event. Discard the old socket ID and pending authorization requests.
  3. Obtain fresh signatures for every private or presence channel using the new socket ID, then resubscribe to all desired channels.
  4. Wait for each subscription acknowledgement and refresh durable application state from your backend. Spread large batches of subscriptions to stay within 20 commands per second.
  5. Cancel retries and pending authorization work when the user intentionally disconnects or the component is disposed. Avoid duplicate sockets and event handlers.

The bundled JavaScript SDK retries up to five times with delays of 2, 4, 8, 16 and 30 seconds, resets the retry counter after the connection-established event, and resubscribes. Those are SDK defaults, not a server requirement. A custom client should choose a retry policy appropriate to its runtime.

Honor Retry-After when the runtime exposes handshake responses. Fix credentials or invalid subscription data before retrying permanent errors. Browser WebSocket APIs generally expose a generic error/close instead of the rejected HTTP response body, so keep retries bounded and surface connection failure.

Errors and limits

Connection handshake errors

These are HTTP responses before the WebSocket upgrade, with a plain-text body. They are not WebSocket JSON events.

HTTPBodyHandling
400appKey is requiredInclude the public app key in the URL.
401Invalid application keyCheck the key and application. Keys longer than 64 bytes are rejected.
429Workspace connection limit reachedRetry-After: 5. Wait at least five seconds and use backoff.
503Workspace capacity is temporarily unavailableRetry with backoff.
503Connection unavailableRetry with backoff.

Subscription errors

Server → client: rejected subscription
{
  "event": "subscription_error",
  "channel": "private-user-42",
  "data": "Subscription not authorized"
}

A rejected subscription leaves the connection open. There is no numeric error code in this envelope.

dataMeaning / handling
Channel unavailableEmpty channel, channel longer than 200 UTF-8 bytes, reserved debug- prefix, or unavailable application secret.
Valid presence identity requiredchannel_data must be a JSON string containing a nonempty string user_id.
Subscription not authorizedMissing or invalid signature. Authorize this channel using the current socket_id.
Maximum 100 channels per connectionUnsubscribe from another channel before adding one.
Subscription temporarily unavailableTemporary subscription storage failure. Retry with backoff.

Connection limits and malformed commands

A connection accepts at most 100 channels and client messages of at most 16 KiB. More than 20 commands in a one-second window causes disconnection; the server attempts to send the error below before closing, but it may not reach the client.

Server → client: command rate exceeded
{
  "event": "error",
  "data": "Command rate exceeded (20/s)"
}

Malformed JSON or invalid command field types are ignored without a response. Unknown or missing actions are also ignored. They still consume the command rate allowance. Do not wait indefinitely for an acknowledgement; use a bounded subscription timeout.

Oversized WebSocket messages can produce close code 1009 (message too big). Heartbeat timeouts, slow readers and transport failures may appear as an abnormal close, such as 1006, or a close without a reason. pubb does not define custom application close codes; do not infer a subscription error from a close code alone.

HTTP publishing and authorization errors

API errors use JSON. Publishing validation and capacity errors include error and code; authentication and authorization validation responses may contain only error.

HTTP 429: throughput exceeded
{
  "error": "Workspace throughput is busy. Retry after the indicated delay.",
  "code": "rate_limit_exceeded"
}
HTTPcodeHandling
400invalid_eventSupply an event name and 1–100 valid channel names.
400invalid_socketsocket_id must be a string of at most 100 characters.
400invalid_idempotency_keyUse 1–128 printable, nonspace ASCII characters.
409idempotency_conflictThe key was already used for a different event or publication source.
413payload_too_largeEvent data exceeds 10 KiB of UTF-8 JSON.
413body_too_largeThe entire HTTP request body exceeds 64 KiB.
429rate_limit_exceededWait for Retry-After before retrying with the same idempotency key.
429fanout_too_largeReduce recipients or increase plan capacity; an unchanged retry cannot fix this.
429monthly_quota_exceededWait for the next UTC calendar month or upgrade the workspace.
503capacity_unavailableRetry with backoff and the same idempotency key.

Both endpoints return HTTP 401 with A valid application secret is required for an invalid secret, or HTTP 404 with Application not found for an unknown app. Authorization returns HTTP 400 with one of these error strings:

  • socket_id and channel_name are required
  • Channel does not require authentication
  • user_id is required for presence channels

For transient HTTP failures, retry with backoff. Reuse the publication’s idempotency key after an ambiguous timeout so a retry does not create another publication.

Minimal browser example

This example connects, waits for the handshake, subscribes and decodes application events without an SDK. Publish the sample event from your backend after the subscription acknowledgement.

client.js
const channel = "notifications";
const url = new URL("wss://ws.pubb.io/socket");
url.searchParams.set("appKey", "YOUR_APP_KEY");
url.searchParams.set("protocol", "2");
const socket = new WebSocket(url);

socket.onmessage = ({ data }) => {
  try {
    const packet = JSON.parse(data);
    if (packet.event === "pubb:connection_established") {
      const { socket_id } = JSON.parse(packet.data);
      console.log("Connected", socket_id);
      socket.send(JSON.stringify({ action: "subscribe", channel }));
      return;
    }
    if (packet.event === "error" || packet.event === "subscription_error") {
      console.error(packet.event, packet.channel, packet.data);
      return;
    }
    if (packet.channel !== channel) return;
    if (packet.event === "pubb:subscription_succeeded") {
      console.log("Ready to receive events");
      return;
    }
    if (packet.event) return; // Other control events
    const event = JSON.parse(packet.data);
    console.log(event.name, event.data);
  } catch (error) {
    console.error("Could not read the packet", error);
  }
};

socket.onerror = () => console.error("WebSocket connection failed");
socket.onclose = ({ code, reason }) => console.log("Disconnected", code, reason);

function unsubscribe() {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify({ action: "unsubscribe", channel }));
  }
}

// On component/page disposal, stop any reconnect timers and close the socket:
function dispose() {
  socket.close(1000, "Client disposed");
}

Add the reconnection policy above for production. In React, create the socket in an effect and call dispose() from its cleanup. For private or presence channels, complete the authorization flow before sending the subscription.

For stack-specific helpers, see the SDK guides. Coding assistants can start with the AI integration guide and the downloadable plain-text integration reference.