P

Pusher

Documentation

WebSocket usage guide

How to connect to the realtime gateway with the bundled /pusher.js client, subscribe to channels, receive events and trigger them from a backend.

Status: the gateway speaks a Pusher Channels subset. The bundled client covers public channels and works without any external CDN. Presence membership, client events and signed Pusher server SDK calls are not implemented yet. See Protocol limitations.

1. Before you start

You need an app key (app_…). You do not need a dashboard JWT for the WebSocket connection or for triggering events — those use the app key only.

Create an app from the dashboard (Apps → Create app) or with the API:

# 1. Get a JWT and the active org id (the org claim inside the JWT).
curl -s -X POST http://localhost:8071/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"password123"}'

# 2. Create an app in that org.
curl -s -X POST http://localhost:8071/apps \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"org_id":"'"$ORG_ID"'","name":"My app"}'

The create response contains:

{
  "id": "973adeca-c7d5-4ae4-b65b-b1ad73e389f8",
  "org_id": "c5d4a1a2-4732-4b74-a6b9-02ab6c86eb74",
  "name": "My app",
  "key": "app_f2ae282ba1654320a15125ac9a41f8e5",
  "secret": "pk_0b83ed54dcead8ff1248e110bb5c6389126b12c15d0b59fc60d2c704a4d03f16"
}
  • key is the public app key used by clients and by the trigger endpoint.
  • secret is server-side only. Never ship it to a browser.

The key does not have to look like a Pusher Cloud key. Any string is accepted by the bundled client; only the key stored in apps.key must match.

2. Endpoints and SDK

The dashboard serves the client at /pusher.js. There is no external CDN dependency; the browser only loads files from your own deployment.

ResourceURL
Client SDK/pusher.js
WebSocket (same origin)ws://<host>/app/{app_key}
Direct APIws://localhost:8071/app/{app_key}
Through nginxws://localhost:8074/app/{app_key}
HTTP trigger basehttp://localhost:8071 (direct) or http://localhost:8074 (nginx)
Productionwss://realtime.example.com/app/{app_key}

The client derives the WebSocket URL from window.location by default. That means the simplest integration is same-origin through nginx (port 8074) or in development through the Vite proxy. If the frontend lives on another host, set wsHost/wsPort/forceTLS or wsUrl (see options).

The legacy raw endpoint /ws/{app_key} is still accepted, but the bundled client and the Pusher protocol use /app/{app_key}.

3. Connect

Load the bundled script, then use the familiar Pusher API:

<script src="/pusher.js"></script>
<script>
  Pusher.logToConsole = true;

  const pusher = new Pusher('app_f2ae282ba1654320a15125ac9a41f8e5', {
    cluster: 'ap1', // accepted for familiarity; not used for routing
  });

  pusher.connection.bind('connected', () => {
    console.log('connected as', pusher.connection.socket_id);
  });

  const channel = pusher.subscribe('my-channel');
  channel.bind('my-event', (data) => {
    alert(JSON.stringify(data));
  });
</script>

That is all that is needed when the page is served through nginx (:8074) or the Vite dev server. If the page is opened directly on the static dashboard (:8073), there is no /app proxy on that port — use the API host explicitly:

const pusher = new Pusher('app_f2ae…', {
  cluster: 'ap1',
  wsHost: 'localhost',
  wsPort: 8071,
  forceTLS: false,
});

// Or a full WebSocket base URL:
const other = new Pusher('app_f2ae…', {
  wsUrl: 'ws://localhost:8071',
});

When the frontend is on a different origin, load the script from the deployment that serves the SDK:

<script src="https://realtime.example.com/pusher.js"></script>
<script>
  const pusher = new Pusher('app_f2ae…', {
    wsUrl: 'wss://realtime.example.com',
  });
</script>

You can also copy dashboard/public/pusher.js into your own frontend build if you prefer to self-host the asset.

Connection events

pusher.connection.bind('connected', (handshake) => {
  console.log('socket_id:', pusher.connection.socket_id);
  console.log('activity timeout:', handshake.activity_timeout);
});

pusher.connection.bind('disconnected', () => {
  console.log('socket closed; the client will reconnect automatically');
});

pusher.connection.bind('state_change', ({ previous, current }) => {
  console.log('connection:', previous, '->', current);
});

pusher.connection.bind('error', (error) => {
  console.error('connection error:', error);
});

The client states are initialized, connecting, connected, unavailable, failed and disconnected. Reconnects use exponential backoff and resubscribe all channels after the handshake.

The server validates the app key against apps.key before the upgrade. An unknown key returns HTTP 404 and the socket is not created.

4. Subscribe and use the client

const channel = pusher.subscribe('my-channel');

channel.bind('my-event', (data) => {
  console.log(channel.name, data);
});

// The subscription is confirmed asynchronously.
channel.bind('pusher:subscription_succeeded', () => {
  console.log('subscribed to', channel.name);
});

channel.bind('pusher:subscription_error', (error) => {
  console.error('subscription failed', error);
});

pusher.subscribe() can be called before the socket finishes connecting. The client queues the channel and sends pusher:subscribe after the handshake. Subscribing twice to the same channel is idempotent; events are delivered once.

Client API

APIDescription
new Pusher(key, options)Create a client and start connecting.
pusher.connectionConnection object: state, socket_id, bind().
pusher.subscribe(name)Subscribe to a channel and return a Channel.
pusher.unsubscribe(name)Leave a channel.
pusher.channel(name)Return an existing channel or null.
pusher.connect()Reconnect after disconnect().
pusher.disconnect()Close the socket and stop reconnecting.
channel.bind(event, fn)Listen for a channel event. Returns the channel.
channel.unbind(event, fn)Remove one listener, or all for that event.
channel.unsubscribe()Leave the channel.
channel.trigger(event, data)Send a client-… event (gateway support is still limited).

Options

OptionDefaultNotes
clustermt1Accepted for compatibility; ignored by this gateway.
wsHostlocation.hostnameGateway hostname when not same-origin.
wsPortlocation port, else 80/443Gateway port.
forceTLSbased on page protocolSet true for wss://, false for ws://.
wsUrlnoneFull WS base, e.g. wss://realtime.example.com. http(s) is converted to ws(s).
wsPath/appWebSocket path prefix.
reconnectDelay1000Initial reconnect delay in milliseconds.
maxReconnectDelay15000Maximum reconnect delay.
handshakeTimeout10000Wait for pusher:connection_established before closing.
pongTimeout10000Reconnect if a heartbeat pong does not arrive.

Pusher.logToConsole = true enables client-side debug logs.

5. Receive events

Application events use the Pusher envelope:

{
  "event": "my-event",
  "channel": "my-channel",
  "data": "{\"msg\":\"hi\"}"
}
FieldTypeMeaning
eventstringEvent name, exactly as sent to the trigger endpoint.
channelstringChannel the event was published to.
datastringOpaque payload. Usually stringified JSON. The bundled client parses it for you.

There is no socket_id in delivered events. If a trigger includes a socket_id that matches the receiving connection, the server skips that connection.

The bundled client automatically parses JSON data:

channel.bind('my-event', (data) => {
  // data is already parsed when it is valid JSON
  console.log(data.msg);
});

Raw WebSocket clients receive the envelope directly:

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.event === 'pusher:pong') return;

  let payload = msg.data;
  try {
    payload = JSON.parse(msg.data);
  } catch {
    // data is not JSON; keep the raw string
  }
  console.log(msg.channel, msg.event, payload);
};

There is no history or replay: only sockets subscribed at publish time receive an event. Ordering is preserved per channel; there is no cross-channel ordering guarantee.

6. Ping / keepalive

The bundled client handles this automatically using activity_timeout from the handshake. It sends a ping, waits for a pong, and reconnects if the pong does not arrive. You do not need to add your own timer.

Raw protocol:

{ "event": "pusher:ping", "data": {} }

Server pong:

{ "event": "pusher:pong", "data": "{}" }

The server does not initiate pings and does not close idle sockets. If you use a raw client, send a ping every 30 seconds and reconnect if no pong arrives within a few seconds.

7. Trigger events from your backend

POST /trigger/{app_key}/events

curl -s -X POST http://localhost:8071/trigger/app_f2ae282ba1654320a15125ac9a41f8e5/events \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "my-event",
    "channel": "my-channel",
    "data": "{\"msg\":\"hi\"}"
  }'

Response:

{ "status": "ok" }

Request body:

FieldTypeRequiredNotes
namestringyesDelivered to clients as the event field.
channelstringyesMust match the client’s subscribed channel exactly.
datastringyesMust be a string, even when it contains JSON.
socket_idstringnoExcludes that socket from receiving the event.

Operational behaviour:

  • Rate limit: 1000 trigger calls per 60 seconds per app key (fixed window in Redis). Exceeding it returns 429 {"error":"rate limited"}. If Redis is unavailable the limiter fails open.
  • Body limit: 1 MiB (413 above that).
  • Usage counters and webhook lookups run asynchronously; webhook HTTP delivery is not implemented yet.
  • Events are published through Redis, so they fan out across every server instance.

Security warning: trigger requests are authenticated only by the app key in the URL. Because clients need that key to connect, anyone who can inspect a client can also trigger events. HMAC request signing (X-Pusher-Signature) is not implemented. Do not treat the current trigger endpoint as a trusted server-to-server boundary.

The official Pusher server SDKs (POST /apps/{app_id}/events with auth_signature) are not supported yet. Use this endpoint from your backend or from the dashboard debug console.

8. Private / presence auth (partial)

An endpoint signs subscription data:

POST /trigger/{app_key}/auth

curl -s -X POST http://localhost:8071/trigger/app_f2ae282ba1654320a15125ac9a41f8e5/auth \
  -H 'Content-Type: application/json' \
  -d '{
    "socket_id": "1234.5678",
    "channel_name": "private-room",
    "channel_data": "{\"user_id\":\"42\"}"
  }'

Response:

{ "auth": "app_f2ae…:<hex-signature>", "channel_data": "{\"user_id\":\"42\"}" }

Signature format (HMAC-SHA256 with the app secret):

private:   HMAC_SHA256(secret, "{socket_id}:{channel_name}")
presence:  HMAC_SHA256(secret, "{socket_id}:{channel_name}:{channel_data}")

Current limitation: the WebSocket subscribe path ignores this auth value and accepts every channel. The bundled client does not implement private/presence authorization, presence member lists, member_added / member_removed events, or client-event delivery. Use this endpoint only as a signing helper until the gateway verifies signatures.

9. End-to-end example

Browser with the bundled client

<script src="/pusher.js"></script>
<script>
  Pusher.logToConsole = true;

  const pusher = new Pusher('app_f2ae282ba1654320a15125ac9a41f8e5', {
    cluster: 'ap1',
  });

  pusher.connection.bind('connected', () => {
    console.log('socket_id', pusher.connection.socket_id);
  });

  const channel = pusher.subscribe('my-channel');
  channel.bind('my-event', (data) => {
    document.body.dataset.lastEvent = JSON.stringify(data);
    alert(JSON.stringify(data));
  });
</script>

Publish from another terminal:

curl -s -X POST http://localhost:8071/trigger/app_f2ae282ba1654320a15125ac9a41f8e5/events \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-event","channel":"my-channel","data":"{\"msg\":\"hi\"}"}'

The browser receives:

{
  "event": "my-event",
  "channel": "my-channel",
  "data": "{\"msg\":\"hi\"}"
}

Raw wscat

# Terminal 1: connect and subscribe
npx wscat -c ws://localhost:8071/app/app_f2ae282ba1654320a15125ac9a41f8e5
< {"event":"pusher:connection_established","data":"{\"socket_id\":\"1234.5678\",\"activity_timeout\":120}"}
> {"event":"pusher:subscribe","data":{"channel":"my-channel"}}
< {"event":"pusher_internal:subscription_succeeded","channel":"my-channel","data":"{}"}

# Terminal 2: publish
curl -s -X POST http://localhost:8071/trigger/app_f2ae282ba1654320a15125ac9a41f8e5/events \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-event","channel":"my-channel","data":"{\"msg\":\"hi\"}"}'

# Terminal 1 receives
< {"event":"my-event","channel":"my-channel","data":"{\"msg\":\"hi\"}"}

Reconnect and subscribe again after any disconnect; there is no resume or missed message recovery.

10. Protocol limitations

LimitationEffect
No external Pusher CDNLoad /pusher.js from this deployment (or copy it into your frontend).
No cluster routingcluster is accepted by the client for familiarity but ignored; the URL comes from wsHost/wsUrl.
No subscription auth verificationPrivate/presence channels are public in practice.
No presence supportNo member list, member_added, member_removed.
No client eventschannel.trigger('client-…') is sent by the client but ignored by the server.
No signed Pusher server SDK endpointPOST /apps/{app_id}/events with auth_signature is not implemented; use /trigger/{app_key}/events.
No server heartbeatThe client drives keepalive; idle sockets are not closed by the server.
No HMAC on triggerAnyone with the app key can publish.
No replay/historyEvents published while disconnected are lost.
No origin allowlist / connection limitsPlanned hardening.
256-event internal broadcast bufferA badly lagging subscription can stop delivering until it reconnects.

The legacy /ws/{app_key} path remains available for existing raw clients. It uses the same handshake and event envelope as /app/{app_key}.

11. Troubleshooting

SymptomLikely cause
Pusher is not defined/pusher.js was not loaded, or the inline script runs before the script tag.
/pusher.js returns 404The dashboard is not serving the page, or the frontend uses a different host; use the full dashboard URL or copy the file.
Connection opens but never becomes connectedWrong gateway port, old server build, or a proxy without WebSocket upgrade headers.
Only cluster is set while the API is on another hostThe bundled client defaults to same-origin; set wsHost/wsPort or wsUrl.
Handshake fails with 404Wrong app key, or the app belongs to another environment/database.
Socket opens but no eventsNot subscribed, or channel in the trigger differs by case/whitespace.
channel.bind never firesThe trigger sent event/name incorrectly, or the client subscribed to a different channel.
Event has a name field instead of eventOld server build; this gateway sends the Pusher envelope.
Each event arrives twiceTwo sockets/tabs are subscribed, or two channel objects were created for the same name.
Trigger returns 429More than 1000 triggers in 60 seconds for that app key.
Trigger returns 422/400Missing field, or data sent as an object instead of a string.
Private channel works without authExpected today; signatures are not enforced.
Sender also receives its own eventThe trigger omitted socket_id; pass the sender’s socket_id to exclude it.

12. What to check when debugging

  1. GET /healthz on the API returns {"status":"ok"}.
  2. GET /pusher.js returns JavaScript, not the dashboard HTML fallback.
  3. The app key exists in the same database the server uses.
  4. pusher.connection.state is connected and channel.subscribed is true.
  5. The trigger response is {"status":"ok"} (not 404/429).
  6. The channel string is byte-for-byte identical on both sides.
  7. The delivered frame is JSON with event — not name.