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"
}
keyis the public app key used by clients and by the trigger endpoint.secretis 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.
| Resource | URL |
|---|---|
| Client SDK | /pusher.js |
| WebSocket (same origin) | ws://<host>/app/{app_key} |
| Direct API | ws://localhost:8071/app/{app_key} |
| Through nginx | ws://localhost:8074/app/{app_key} |
| HTTP trigger base | http://localhost:8071 (direct) or http://localhost:8074 (nginx) |
| Production | wss://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
| API | Description |
|---|---|
new Pusher(key, options) | Create a client and start connecting. |
pusher.connection | Connection 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
| Option | Default | Notes |
|---|---|---|
cluster | mt1 | Accepted for compatibility; ignored by this gateway. |
wsHost | location.hostname | Gateway hostname when not same-origin. |
wsPort | location port, else 80/443 | Gateway port. |
forceTLS | based on page protocol | Set true for wss://, false for ws://. |
wsUrl | none | Full WS base, e.g. wss://realtime.example.com. http(s) is converted to ws(s). |
wsPath | /app | WebSocket path prefix. |
reconnectDelay | 1000 | Initial reconnect delay in milliseconds. |
maxReconnectDelay | 15000 | Maximum reconnect delay. |
handshakeTimeout | 10000 | Wait for pusher:connection_established before closing. |
pongTimeout | 10000 | Reconnect 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\"}"
}
| Field | Type | Meaning |
|---|---|---|
event | string | Event name, exactly as sent to the trigger endpoint. |
channel | string | Channel the event was published to. |
data | string | Opaque 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:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Delivered to clients as the event field. |
channel | string | yes | Must match the client’s subscribed channel exactly. |
data | string | yes | Must be a string, even when it contains JSON. |
socket_id | string | no | Excludes 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 (
413above 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
| Limitation | Effect |
|---|---|
| No external Pusher CDN | Load /pusher.js from this deployment (or copy it into your frontend). |
| No cluster routing | cluster is accepted by the client for familiarity but ignored; the URL comes from wsHost/wsUrl. |
| No subscription auth verification | Private/presence channels are public in practice. |
| No presence support | No member list, member_added, member_removed. |
| No client events | channel.trigger('client-…') is sent by the client but ignored by the server. |
| No signed Pusher server SDK endpoint | POST /apps/{app_id}/events with auth_signature is not implemented; use /trigger/{app_key}/events. |
| No server heartbeat | The client drives keepalive; idle sockets are not closed by the server. |
| No HMAC on trigger | Anyone with the app key can publish. |
| No replay/history | Events published while disconnected are lost. |
| No origin allowlist / connection limits | Planned hardening. |
| 256-event internal broadcast buffer | A 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
| Symptom | Likely cause |
|---|---|
Pusher is not defined | /pusher.js was not loaded, or the inline script runs before the script tag. |
/pusher.js returns 404 | The 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 connected | Wrong gateway port, old server build, or a proxy without WebSocket upgrade headers. |
Only cluster is set while the API is on another host | The bundled client defaults to same-origin; set wsHost/wsPort or wsUrl. |
Handshake fails with 404 | Wrong app key, or the app belongs to another environment/database. |
| Socket opens but no events | Not subscribed, or channel in the trigger differs by case/whitespace. |
channel.bind never fires | The trigger sent event/name incorrectly, or the client subscribed to a different channel. |
Event has a name field instead of event | Old server build; this gateway sends the Pusher envelope. |
| Each event arrives twice | Two sockets/tabs are subscribed, or two channel objects were created for the same name. |
Trigger returns 429 | More than 1000 triggers in 60 seconds for that app key. |
Trigger returns 422/400 | Missing field, or data sent as an object instead of a string. |
Private channel works without auth | Expected today; signatures are not enforced. |
| Sender also receives its own event | The trigger omitted socket_id; pass the sender’s socket_id to exclude it. |
12. What to check when debugging
GET /healthzon the API returns{"status":"ok"}.GET /pusher.jsreturns JavaScript, not the dashboard HTML fallback.- The app key exists in the same database the server uses.
pusher.connection.stateisconnectedandchannel.subscribedis true.- The trigger response is
{"status":"ok"}(not404/429). - The channel string is byte-for-byte identical on both sides.
- The delivered frame is JSON with
event— notname.