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.
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| Parameter | Meaning |
|---|---|
APP_KEY | The app's key from the dashboard. An unknown key closes the connection with code 4001. |
protocol | Protocol version. Hibira accepts 5, 6 and 7; a missing one closes with 4008, an unsupported one with 4007. |
client, version | The library's name and version, for logs and statistics. |
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.
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.
| Event | Direction | Purpose |
|---|---|---|
pusher:connection_established | server → client | The connection is ready; carries the socket ID. |
pusher:error | server → client | An error with a code and message; see the error codes below. |
pusher:ping / pusher:pong | both ways | Heartbeat. |
pusher:subscribe | client → server | Join a channel, with a signature for private and presence channels. |
pusher:unsubscribe | client → server | Leave a channel. |
pusher_internal:subscription_succeeded | server → client | The subscription worked; for presence channels it carries the member list. |
pusher:subscription_error | server → client | The subscription was refused, for example AuthError. |
pusher_internal:member_added / pusher_internal:member_removed | server → client | A user joined or left a presence channel. |
pusher:signin / pusher:signin_success | both ways | User authentication (sign-in), below. |
pusher:cache_miss | server → client | A cache channel has nothing cached yet. |
pusher_internal:subscription_count | server → client | How many sockets are subscribed to the channel, when subscription counting is on. |
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 _ - = @ , . ;.
| Channel | Prefix | Needs a signature | Client events |
|---|---|---|---|
| Public | none, for example orders | no | no |
| Private | private- | yes | yes, when turned on |
| Presence | presence- | yes, with user data | yes, when turned on |
| Encrypted | private-encrypted- | yes | no |
| Cache | cache-, private-cache-, presence-cache-, private-encrypted-cache- | as its base type | as its base type |
| Server-to-user | #server-to-user-USER_ID | only the signed-in user | no |
| Large presence (Hibira) | presence-large- | yes, with user data | yes, when turned on |
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.
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:error4009. - 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:error4301.
{"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.
| Range | Client behaviour |
|---|---|
| 4000–4099 | Do not reconnect with the same settings. |
| 4100–4199 | Reconnect after backing off. |
| 4200–4299 | Reconnect right away. |
| 4300–4399 | Error only; the connection stays open. |
| Code | When Hibira sends it |
|---|---|
| 4001 | The app key does not exist. |
| 4003 | The app is disabled. |
| 4004 | The account is over its plan's connection limit. |
| 4007 | The protocol version is not supported. |
| 4008 | No protocol version was given. |
| 4009 | Not allowed: the origin is not on the app's list, the connection was terminated, or a client event was not allowed. |
| 4100 | Over capacity, or the client reads too slowly; back off and try again. |
| 4200 | The server is restarting; reconnect now. |
| 4201 | No pong arrived in time. |
| 4301 | Client event rate limit or the daily message quota was reached. |
The HTTP API
Your server publishes and queries with HTTP requests to the same host as the WebSocket, under /apps/APP_ID/:
| Request | Purpose |
|---|---|
POST /apps/APP_ID/events | Publish one event to up to 100 channels. |
POST /apps/APP_ID/batch_events | Publish up to 10 events in one request. |
GET /apps/APP_ID/channels | List occupied channels, optionally by prefix, with subscriber counts. |
GET /apps/APP_ID/channels/CHANNEL | One channel's state. |
GET /apps/APP_ID/channels/CHANNEL/users | The users on a presence channel. |
POST /apps/APP_ID/users/USER_ID/terminate_connections | Close all of a signed-in user's connections. |
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=529798e83b8523846d8314a6b2b6c517import 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"}]}| Event | Sent when |
|---|---|
channel_occupied / channel_vacated | A channel gets its first subscriber, or loses its last. |
member_added / member_removed | A user's first socket joins a presence channel, or their last one leaves. |
client_event | A client sends a client- event. |
cache_miss | A client subscribes to a cache channel with nothing cached. |
subscription_count | A channel's subscription count changes, when counting is on. |
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.
| Cluster | Region | Host |
|---|---|---|
hkg | Hong Kong | ws-hkg.hibira.io (also ws.hibira.io) |
tyo | Tokyo | ws-tyo.hibira.io (planned) |
sin | Singapore | ws-sin.hibira.io (planned) |
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.