The Pusher protocol explained: connections, channels, auth and webhooks

How the Pusher Channels protocol (version 7) works: the WebSocket handshake, message format, channel types and their signatures, client events, error codes, the HTTP API and webhooks, and how Hibira implements it.

What the Pusher protocol is

The Pusher Channels protocol is the set of WebSocket messages and HTTP calls that pusher-js, Laravel Echo and the official Pusher server libraries use to talk to a realtime service. It is publicly documented and stable: the current version is 7. Hibira is an independent, Pusher-compatible service that implements this protocol, so those libraries work with it once you change the host.

The protocol has three parts:

  • The WebSocket connection. Browsers and apps open one WebSocket, subscribe to channels and receive events on it.
  • The HTTP API. Your server publishes events and asks about channels with signed HTTP requests. Clients never publish through it.
  • Webhooks. The service tells your server when channels become occupied or empty, when presence members join or leave, and when clients send events.

One more call happens between your own client and your own server: before a client may join a private or presence channel, your server signs that request with the app secret, which never leaves your server.

Browser / apppusher-js · EchoHibiraws-hkg.hibira.ioYour serverpusher-http-*WebSocketprotocol 7HTTP APIwebhookschannel authorizationPOST socket_id + channel_name → {"auth": "key:signature"}

Connecting

A client connects to the app's WebSocket URL. The path carries the app key (public, safe to ship in a browser), and the query string says which protocol version and library are talking:

wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7&client=js&version=8.4.0&flash=false

When the WebSocket opens, the server sends pusher:connection_established. Its data holds the socket ID, which identifies this connection in signatures and in the HTTP API, and the activity timeout in seconds:

{"event":"pusher:connection_established","data":"{\"socket_id\":\"123.456\",\"activity_timeout\":120}"}

Note that data is a string holding JSON, not a JSON object. The server encodes the data of every event it sends this way, and the client libraries decode it for you.

ClientHibiraGET wss://ws-hkg.hibira.io/app/APP_KEY?protocol=7Upgrade: websocketpusher:connection_established{"socket_id":"123.456","activity_timeout":120}pusher:subscribe{"channel":"orders"}pusher_internal:subscription_succeeded"channel":"orders"order-shippedyour event, from your server120 s without a messagepusher:pingpusher:pong

Message format

Every message in both directions is one JSON object with an event name, usually a channel, and data. Events whose names start with pusher: or pusher_internal: belong to the protocol; any other name is an application event.

Keeping the connection alive

The connection uses a heartbeat at the protocol level. When the client has received nothing for activity_timeout seconds (120 on Hibira), it sends pusher:ping and the server answers pusher:pong. The server does the same for silent clients: it sends pusher:ping after 120 seconds of silence, and if no message arrives within 30 seconds it closes the connection with 4201, after which the client reconnects. If a proxy or load balancer sits between your users and Hibira, its idle timeout must be longer than 150 seconds.

Channels

The prefix of a channel name decides its type. Names can be up to 164 characters long and use A-Z a-z 0-9 _ - = @ , . ;.

Subscribing to a public channel is one message, answered by pusher_internal:subscription_succeeded:

{"event":"pusher:subscribe","data":{"channel":"orders"}}

Your server then publishes to orders with the HTTP API, and every subscriber receives the event:

{"event":"order-shipped","channel":"orders","data":"{\"id\":1042}"}

Authorizing private and presence channels

A private channel needs proof that your server allows this connection to join. The client library asks your auth endpoint, sending its socket ID and the channel name. Your server checks the user's session and returns a signature: the hex HMAC-SHA256 of socket_id:channel_name, keyed with the app secret, prefixed with the app key.

ClientYour serverHibiraconnectsocket_id "123.456"POST /pusher/authsocket_id, channel_name{"auth":"APP_KEY:HMAC…"}pusher:subscribe{"channel":"private-orders","auth":…}HMAC-SHA256(secret, "123.456:private-orders") matchespusher_internal:subscription_succeeded

The server libraries do this in one call (pusher.authorizeChannel(socketId, channel) in Node, $pusher->authorizeChannel() in PHP, Broadcast::channel() in Laravel). Done by hand it looks like this:

import crypto from 'node:crypto';

// POST /pusher/auth with socket_id and channel_name, after checking the session
function authorize(socketId, channel) {
  const signature = crypto
    .createHmac('sha256', process.env.PUSHER_APP_SECRET)
    .update(`${socketId}:${channel}`)
    .digest('hex');
  return { auth: `${process.env.PUSHER_APP_KEY}:${signature}` };
}

The client sends the result with its subscription:

{"event":"pusher:subscribe","data":{"channel":"private-orders","auth":"APP_KEY:81065a8a0a6e7e1f501edc52ef55314aa125075065593e6e9bed5e538ec3d12f"}}

A wrong signature is refused with pusher:subscription_error of type AuthError; the connection stays open.

Presence channels

Presence channels also say who the user is. The auth response adds channel_data, a JSON string with a user_id and optional user_info, and the signature covers it: the string to sign is socket_id:channel_name:channel_data.

{"auth":"APP_KEY:SIGNATURE","channel_data":"{\"user_id\":\"42\",\"user_info\":{\"name\":\"Mei\"}}"}

pusher_internal:subscription_succeeded then carries the members (ids, hash and count), and member_added and member_removed follow as users come and go. A user with several tabs counts once. Hibira allows 100 members per presence channel and 1 KB of user_info, as Pusher does. For bigger audiences, presence-large- channels (a Hibira extension) send the count and a sample of 100 members, with joins and leaves in periodic batches.

Encrypted channels

On private-encrypted- channels the server library encrypts each event's data with a key derived from the channel name and your encryption master key (NaCl secretbox). The auth response also returns that channel's key as shared_secret, so the client can decrypt. Hibira authorizes the subscription like a private channel and relays the ciphertext without being able to read it. Client events are not allowed on encrypted channels.

User authentication

Besides channels, a connection can sign in as a user. The client sends pusher:signin with user_data (a JSON string with at least an id) and an auth signed over socket_id::user::user_data. After pusher:signin_success, your server can send events to that user wherever they are connected (sendToUser, delivered on #server-to-user-USER_ID) and close all of their connections (terminate_connections).

Client events

Clients can send events straight to other subscribers of the same channel, without a round trip through your server: typing hints, cursor positions, game moves. The rules:

  • The event name starts with client-.
  • Only on private and presence channels the client has joined; never on public or encrypted channels.
  • Client events must be turned on in the app's settings; otherwise the server answers with pusher:error 4009.
  • The sender does not receive its own event. On presence channels the receivers get the sender's user_id.
  • At most 10 per second per connection; more are dropped with pusher:error 4301.
{"event":"client-typing","channel":"private-chat-7","data":{"user":"Mei"}}

Your server can follow client events with the client_event webhook.

Cache channels

A cache channel remembers its last event for 30 minutes and sends it to every new subscriber right after subscription_succeeded, so a page shows the current state without asking your server. When nothing is cached the client receives pusher:cache_miss, and your server gets a cache_miss webhook so it can publish the current value.

Error and close codes

Codes from 4000 to 4299 close the connection, and the range tells the client what to do next. Codes from 4300 arrive as pusher:error with the connection left open.

The HTTP API

Your server publishes and queries with HTTP requests to the same host as the WebSocket, under /apps/APP_ID/:

An event body names the event, the channels and the data as a string; socket_id leaves out the connection that caused the event, so the sender does not see its own change twice. Event data can be up to 10 KB.

{"name":"order-shipped","channels":["orders"],"data":"{\"id\":1042}","socket_id":"123.456"}

Signing requests

Every request carries auth_key, auth_timestamp (Unix seconds, within 600 seconds of the server's clock), auth_version=1.0, body_md5 for requests with a body, and auth_signature. The signature is the hex HMAC-SHA256, keyed with the app secret, of the method, the path and the other parameters sorted by name, joined with newlines:

POST
/apps/123/events
auth_key=APP_KEY&auth_timestamp=1792458481&auth_version=1.0&body_md5=529798e83b8523846d8314a6b2b6c517
import crypto from 'node:crypto';

const body = JSON.stringify({ name: 'order-shipped', channels: ['orders'], data: '{"id":1042}' });
const params = {
  auth_key: process.env.PUSHER_APP_KEY,
  auth_timestamp: Math.floor(Date.now() / 1000),
  auth_version: '1.0',
  body_md5: crypto.createHash('md5').update(body).digest('hex'),
};
const query = Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join('&');
const path = `/apps/${process.env.PUSHER_APP_ID}/events`;
const signature = crypto
  .createHmac('sha256', process.env.PUSHER_APP_SECRET)
  .update(`POST\n${path}\n${query}`)
  .digest('hex');

await fetch(`https://${process.env.PUSHER_HOST}${path}?${query}&auth_signature=${signature}`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body,
});

In practice the official server libraries (pusher for Node, pusher/pusher-php-server, pusher for Python, pusher-http-go and the others) sign for you once you set their host option.

Webhooks

Webhooks are POST requests from Hibira to URLs you set on the app page. Each request is signed: X-Pusher-Key holds the app key and X-Pusher-Signature the hex HMAC-SHA256 of the raw body, keyed with the app secret. Events are batched for up to one second, up to 100 per request.

{"time_ms":1792458481958,"events":[{"name":"channel_occupied","channel":"orders"}]}

Check the signature before trusting a webhook, comparing in constant time:

import crypto from 'node:crypto';

function verify(rawBody, signature) {
  const expected = crypto.createHmac('sha256', process.env.PUSHER_APP_SECRET).update(rawBody).digest('hex');
  return signature?.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

How Hibira maps the protocol

Each app lives in one cluster, like Pusher's clusters, and its clients and server connect to that cluster's host. The WebSocket and the HTTP API share it.

The client libraries take the host as an option; the cluster option is then ignored:

const pusher = new Pusher('APP_KEY', {
  cluster: 'hkg',
  wsHost: 'ws-hkg.hibira.io',
  forceTLS: true,
  enabledTransports: ['ws', 'wss'],
});

What works unchanged: pusher-js, Laravel Echo and Laravel Broadcasting, the official server libraries, the official iOS and Android libraries, community clients for Flutter and Unity that let you set the host, and Beams-compatible push notifications. Moving an existing app takes a host change and new credentials; see Migrating from Pusher and the integration guides. To watch the protocol live, open the WebSocket tester, which shows every frame of a connection to your app.