# SDK & API

> Connect stock pusher-js or the FirstByte QUIC-native client. Both are Pusher-compatible: client config, channel auth, and server publish.

Realtime is **wire-compatible with Pusher Channels**. You keep the client and
server libraries that you already use: the same `subscribe` / `bind` /
`trigger` surface, the same channel model, and your existing auth endpoint. You
only point them at FirstByte. There is no new API to learn.

On the **client** you have two first-class, drop-in options. Pick either one
without changes to your channel or event code:

- **Stock `pusher-js`** over WebSocket. Repoint `wsHost` and you are done. Use
  it when you want zero new dependencies and run on normal, reliable networks.
- **The FirstByte QUIC-native realtime client**. This is a
  `pusher-js`-API-compatible drop-in. It prefers WebTransport/QUIC and falls
  back automatically. Use it when mobile or lossy-network resilience matters
  (see [When to pick which](#when-to-pick-which)).

The **server** side is identical for both clients: the official Pusher server
SDKs or the REST publish endpoint, the same channels, and the same HMAC auth.

> **NOTE:**
> This page is a reference for the **compatible** surface. If you already run
> Pusher, the only required change is the connection host (client) and the
> `host` option (server). App credentials come from the
> [Realtime Console](/modalities/realtime/console).

## Connect a client

Construct a client with your app **key**. Point it at the FirstByte realtime
host. Choose one of the two clients below. After this step, the channel, bind,
and connection-state code is **identical** for both clients.

  
Repoint stock `pusher-js`: set `wsHost`. Nothing else changes. This is the pure
drop-in migration path.

**npm:**
```bash
npm install pusher-js
```

**yarn:**
```bash
yarn add pusher-js
```

```js Browser (pusher-js)
import Pusher from "pusher-js";

const pusher = new Pusher("<APP_KEY>", {
  wsHost: "realtime.firstbyte.fast",
  forceTLS: true,          // wss:// — TLS on the wire
  // Auth for private-* / presence-* channels (see "Authorize channels"):
  channelAuthorization: {
    endpoint: "/pusher/auth",   // your backend route
    transport: "ajax",
  },
});

const channel = pusher.subscribe("public-updates");
channel.bind("price.changed", (data) => {
  console.log("new price", data);
});
```

- **`wsHost`** (`string`, required) — The realtime host, `realtime.firstbyte.fast`. This is the one line that repoints stock `pusher-js` from Pusher's cluster onto FirstByte.

- **`forceTLS`** (`boolean`, default `false`) — Set `true` to connect over `wss://`. Recommended for all production traffic.

- **`wsPort / wssPort`** (`number`) — Override only if you terminate on a non-default port. By default the client uses `443` under `forceTLS`.
  

  
Swap the import for the FirstByte realtime subpath. The constructor is
**Pusher-compatible**: same signature, same options, same returned client. It
is a drop-in replacement for the `pusher-js` import. It prefers
WebTransport/QUIC and falls back automatically down the transport ladder.

**npm:**
```bash
npm install @clutchcall/agents
```

**yarn:**
```bash
yarn add @clutchcall/agents
```

```js Browser (QUIC-native, Pusher-compatible)
// Drop-in replacement for `import Pusher from "pusher-js"`:
import Pusher from "@clutchcall/agents/realtime";

const pusher = new Pusher("<APP_KEY>", {
  wsHost: "realtime.firstbyte.fast",
  forceTLS: true,
  // Prefer QUIC, then degrade — see "Transport ladder" below:
  enabledTransports: ["webtransport", "ws", "sockjs"],
  channelAuthorization: {
    endpoint: "/pusher/auth",   // same backend route, unchanged
    transport: "ajax",
  },
});

// Identical channel/bind API — nothing below this line changes:
const channel = pusher.subscribe("public-updates");
channel.bind("price.changed", (data) => {
  console.log("new price", data);
});
```

- **`enabledTransports`** (`string[]`, default `["webtransport", "ws", "sockjs"]`) — Ordered transport preference. The client tries each transport in turn and uses the first one that connects. Reorder or trim the list to force a transport (for example, `["ws"]` disables QUIC completely).

> **NOTE:**
> The QUIC path connects to the same `/app/<key>` route over a **WebTransport
> (HTTP/3)** handler. The WebTransport server plane is live. On networks or
> proxies where WebTransport cannot establish, the client drops to WebSocket,
> then to SockJS. A blocked QUIC path does not cause a hard failure.
  

> **NOTE:**
> `pusher-js` (and the compatible client) addresses your app by **key**, not by
> app ID. The app ID is used only server-side. The client ignores the `cluster`
> option when you set `wsHost` explicitly. That is the correct configuration
> here.

- **`channelAuthorization.endpoint`** (`string`) — Your backend route that signs `private-*` / `presence-*` subscriptions. It is identical for both clients. It is necessary only if you use private or presence channels.

### Transport ladder

FirstByte speaks the Pusher **protocol**. The QUIC-native client can carry
it over three transports. The client negotiates them in order and degrades
automatically:

| Rung | Transport | How it connects | Wins when |
| --- | --- | --- | --- |
| 1 | **WebTransport / QUIC** | HTTP/3 to `/app/<key>` | Lossy, mobile, or high-latency links — no TCP head-of-line blocking, faster reconnects via 0-RTT resumption, survives network changes. |
| 2 | **WebSocket** | `wss://` to `/app/<key>` | Reliable wired networks, or where WebTransport is blocked. Identical to stock `pusher-js`. |
| 3 | **SockJS** | HTTP long-polling fallback | Restrictive proxies that break both of the above. |

Browsers reach QUIC through **WebTransport (HTTP/3)**. Native mobile apps can
use **raw QUIC** directly and avoid the WebTransport framing layer. That is the
native-SDK path.

> **NOTE:**
> Raw-QUIC on mobile ships through the native mobile SDKs, not the browser
> package. In the browser, "QUIC" always means WebTransport/HTTP-3. If you need
> the raw-QUIC mobile transport, check current availability for your platform in
> the [Realtime Console](/modalities/realtime/console) before you depend on it.

### When to pick which

Measure your own traffic. On a clean wired network, WebSocket-over-TCP is
already fast, and stock `pusher-js` is the simplest option that works. The
QUIC-native client helps on **lossy, mobile, or high-latency** networks, where
TCP head-of-line blocking and slow reconnects hurt.

| Situation | Recommended client |
| --- | --- |
| Pure drop-in migration off Pusher, no new deps | Stock `pusher-js` (WebSocket) |
| Desktop / server on reliable wired networks | Stock `pusher-js` (WebSocket) |
| Mobile web, flaky Wi-Fi, cellular, roaming | QUIC-native client |
| High packet loss or high RTT paths | QUIC-native client |
| Fast reconnect / connection migration matters | QUIC-native client |

> **TIP:**
> Both clients are drop-in. They share the same channel, event, presence, and
> auth code. You can start on stock `pusher-js`. If resilience needs grow, switch
> the import to `@clutchcall/agents/realtime` later with no other changes.

### Subscribe, bind, unsubscribe

```js
// Public channel — open, no auth
const orders = pusher.subscribe("public-orders");

// Bind a handler for a single event…
orders.bind("order.created", (data) => render(data));

// …or catch every event on the channel
orders.bind_global((event, data) => console.log(event, data));

// Stop listening / leave
orders.unbind("order.created");
pusher.unsubscribe("public-orders");
```

- **`pusher.subscribe(channelName)`** (`Channel`) — Join a channel. The prefix decides the type. No prefix (or `public-`) is open. `private-` requires a signed subscription. `presence-` adds member state.

- **`channel.bind(eventName, handler)`** (`void`) — Register a handler for an event on that channel. `bind_global` receives all events.

- **`pusher.unsubscribe(channelName)`** (`void`) — Leave a channel. This drops its binds and stops delivery.

### Connection states

The client exposes the standard Pusher connection lifecycle. Bind to it to
drive UI (for example, a "reconnecting" banner) or to gate publishes:

```js
pusher.connection.bind("state_change", ({ previous, current }) => {
  console.log(`${previous} → ${current}`);
});

pusher.connection.bind("connected", () => {/* ready */});
```

| State | Meaning |
| --- | --- |
| `initialized` | Client constructed, not yet connecting. |
| `connecting` | Opening the transport / handshaking. |
| `connected` | Live; subscriptions and events flow. |
| `unavailable` | Reachability lost; the client retries with backoff. |
| `failed` | Transport unsupported in this environment. |
| `disconnected` | Closed after `pusher.disconnect()`. |

## Authorize private & presence channels

`private-*` and `presence-*` subscriptions are **HMAC-signed** with your app
secret. The browser never holds the secret. `pusher-js` POSTs the `socket_id`
and channel name to your `channelAuthorization.endpoint`. Your backend signs
the response with a Pusher server library. This is the standard Pusher auth
flow. Reuse your existing endpoint unchanged.

  1. **Client requests authorization**
On `subscribe("private-…")` or `subscribe("presence-…")`, `pusher-js` POSTs
    `{ socket_id, channel_name }` to your auth endpoint.
  2. **Backend signs with the app secret**
Your route constructs a Pusher server client with `{ appId, key, secret }`
    and returns the signed auth payload. For presence channels you also attach
    the member's `user_id` and `user_info`.
  3. **Client completes the subscribe**
`pusher-js` forwards the signature to FirstByte. FirstByte checks the
    HMAC and admits the subscription.

  
```js
import Pusher from "pusher";
import express from "express";

const pusher = new Pusher({
  appId: "<APP_ID>",
  key: "<APP_KEY>",
  secret: "<APP_SECRET>",          // never shipped to the browser
  host: "realtime.firstbyte.fast",
  useTLS: true,
});

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/pusher/auth", (req, res) => {
  const { socket_id, channel_name } = req.body;

  // Presence channel: identify the member
  if (channel_name.startsWith("presence-")) {
    const auth = pusher.authorizeChannel(socket_id, channel_name, {
      user_id: req.user.id,
      user_info: { name: req.user.name },
    });
    return res.send(auth);
  }

  // Private channel: signature only
  const auth = pusher.authorizeChannel(socket_id, channel_name);
  res.send(auth);
});
```
  
  
```python
import pusher
from flask import Flask, request

client = pusher.Pusher(
    app_id="<APP_ID>",
    key="<APP_KEY>",
    secret="<APP_SECRET>",          # server-side only
    host="realtime.firstbyte.fast",
    ssl=True,
)

app = Flask(__name__)

@app.post("/pusher/auth")
def pusher_auth():
    socket_id = request.form["socket_id"]
    channel = request.form["channel_name"]

    if channel.startswith("presence-"):
        return client.authenticate(
            channel=channel,
            socket_id=socket_id,
            custom_data={
                "user_id": current_user.id,
                "user_info": {"name": current_user.name},
            },
        )
    return client.authenticate(channel=channel, socket_id=socket_id)
```
  

> **WARNING:**
> The **app secret** must stay only on your server. Any party with the secret can
> sign subscriptions and publish events. Create and rotate the secret in the
> [Realtime Console](/modalities/realtime/console).

> **NOTE:**
> Presence channels deliver `pusher:subscription_succeeded` with the current
> member list. They then deliver `pusher:member_added` / `pusher:member_removed`
> as members join and leave. These are the standard presence events. Bind them
> the usual way.

## Publish from your server

Use the official Pusher **server** library, constructed with your
`{ appId, key, secret, host }`. Call `trigger(channel, event, data)`. This fans
the event out across the FirstByte edge. Every subscriber on that channel
receives it.

  
```js
import Pusher from "pusher";

const pusher = new Pusher({
  appId: "<APP_ID>",
  key: "<APP_KEY>",
  secret: "<APP_SECRET>",
  host: "realtime.firstbyte.fast",
  useTLS: true,
});

await pusher.trigger("public-orders", "order.created", {
  id: "ord_123",
  total: 4200,
});

// Fan one event to several channels at once:
await pusher.trigger(
  ["public-orders", "private-ops"],
  "order.created",
  { id: "ord_123" },
);
```
  
  
```python
import pusher

client = pusher.Pusher(
    app_id="<APP_ID>",
    key="<APP_KEY>",
    secret="<APP_SECRET>",
    host="realtime.firstbyte.fast",
    ssl=True,
)

client.trigger("public-orders", "order.created", {
    "id": "ord_123",
    "total": 4200,
})
```
  
  
```bash
# Publish over the REST API. The server libraries wrap this call and handle
# the request signing for you; this is the raw shape.
curl -X POST \
  "https://realtime.firstbyte.fast/apps/<APP_ID>/events" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "order.created",
        "channel": "public-orders",
        "data": "{\"id\":\"ord_123\"}"
      }'
```
  

- **`trigger(channel, event, data)`** (`Promise`) — Publish `event` with JSON `data` to one channel or an array of channels. `data` is delivered to every subscriber's matching `bind`.

- **`POST /apps/{app_id}/events`** (`REST`) — The underlying publish endpoint on the realtime host. The body is `{ name, channel | channels, data }`. Requests are HMAC-signed with the app secret (the server libraries do this for you). The [Console](/modalities/realtime/console) Event Creator shows the exact cURL for any event that you compose.

> **NOTE:**
> The wire sends `data` as a JSON string. The Pusher server libraries serialize
> an object for you. Over raw REST, you pass a stringified body, as shown in the
> cURL tab.

### Client events

On `private-*` and `presence-*` channels, subscribers can publish `client-*`
events directly to each other (typing indicators, cursors). There is no round
trip through your server. First enable **client events** for the app in the
[Console](/modalities/realtime/console) settings.

```js
const channel = pusher.subscribe("presence-room-42");
channel.trigger("client-typing", { user: "ada" });
```

> **WARNING:**
> Client events must have the `client-` prefix. They are permitted only on
> authenticated (`private-*` / `presence-*`) channels. They are never delivered
> back to the sender.

## Webhooks

Register endpoints in the [Console](/modalities/realtime/console) to receive
signed event batches. Batches are sent when channels become occupied or
vacated, and when presence members join or leave. Check the
`X-Pusher-Signature` HMAC (computed with the webhook signing secret) before you
trust a payload.

| Event | Fires when |
| --- | --- |
| `channel_occupied` | First subscriber joins a channel. |
| `channel_vacated` | Last subscriber leaves a channel. |
| `member_added` | A member joins a `presence-*` channel. |
| `member_removed` | A member leaves a `presence-*` channel. |

> **NOTE:**
> Channel-existence and presence webhooks plus per-delivery HMAC signing are
> shipped. Some batching and additional webhook event groups (for example,
> client-event and cache webhooks) are still rolling out. Treat those as preview.
> Check availability for your deployment in the Console before you depend on
> them.

## Compatibility scope

**What is drop-in today**

These features are drop-in today:

- Public channels.
- `private-*` HMAC subscription auth.
- `presence-*` with member state.
- `client-*` events.
- `subscribe`/`bind`/`unbind`/`unsubscribe`.
- The full connection-state lifecycle.
- Server-side `trigger` (single and multi-channel) over both the server
  libraries and the REST publish endpoint.

Both clients speak this surface identically: stock `pusher-js` and the
FirstByte QUIC-native client (`@clutchcall/agents/realtime`). The official
Pusher server SDKs work when you change only the host.

**Where to check before you rely on it**

Some webhook batching and the broader webhook event set are still landing (see
above). The browser QUIC path rides WebTransport (HTTP/3) and always has the
WebSocket/SockJS fallback beneath it. **Raw-QUIC on mobile** ships through the
native SDKs. Check availability for your platform before you depend on it. If
you use an advanced or newer Pusher feature that is not listed under "drop-in",
check it against your deployment first.

## Next steps

  - **[Realtime — Details](/modalities/realtime/details)** — How channels, transports, and the edge fan-out fit together.
  - **[Realtime Console](/modalities/realtime/console)** — Create app keys, publish test events, and configure webhooks.
  - **[Realtime — Cookbook](/modalities/realtime/cookbook)** — Copy-pasteable snippets: presence, private channels, server publish.
  - **[Realtime — Recipes](/modalities/realtime/recipes)** — End-to-end builds: a live presence list and server-pushed notifications.
