# Cookbook

> Copy-pasteable Pusher-compatible recipes: subscribe, presence, private channels, server publish, client events, and webhooks on FirstByte.

Everything on this page is real `pusher-js` and Pusher server-library code. The
only change from a stock Pusher app is the **host**. Point the client at the
FirstByte realtime host. Your existing code continues to work, because the
wire protocol is Pusher Channels in both cases. Copy a block. Add your app
credentials. Run it.

> **NOTE:**
> You need an app's **key**, **secret**, and **app ID** for these recipes. Create
> them in the [Realtime Console](/modalities/realtime/console) under **Apps &
> keys**. Each app is a Pusher-compatible key/secret pair. The console shows the
> secret once. Store the secret as an environment variable, never in client code.

In these recipes, the realtime host is `realtime.firstbyte.fast`. The
control-plane publish API is on `portal.firstbyte.fast`. Replace `<APP_KEY>`,
`<APP_ID>`, and `<APP_SECRET>` with your own values.

## Subscribe to a public channel

Public channels are open. Any client with the app key can subscribe. There is
no auth round trip. The only Pusher option that differs from a hosted Pusher
app is `wsHost`.

**pusher-js (WebSocket):**
```js
import Pusher from "pusher-js";

// Stock pusher-js — the one changed line is wsHost.
const pusher = new Pusher("<APP_KEY>", {
  wsHost: "realtime.firstbyte.fast",
  wsPort: 443,
  forceTLS: true,
  enabledTransports: ["ws", "wss"],
  cluster: "",           // required by pusher-js's types; ignored when wsHost is set
});

const channel = pusher.subscribe("orders");

channel.bind("order.created", (data) => {
  console.log("new order", data);
});

// Lifecycle you can hook if you want it:
pusher.connection.bind("state_change", ({ current }) => {
  console.log("connection:", current); // connecting → connected → …
});
```

> **NOTE:**
> **WebTransport / QUIC transport.** The same channels are reachable over
> WebTransport/QUIC and over WebSocket. The wire is the identical Pusher
> protocol, served on the `/app/<APP_KEY>` path. Stock `pusher-js` uses
> WebSocket. To use the QUIC transport, use the FirstByte realtime client.
> That client is a drop-in `pusher-js`-compatible fork that adds WebTransport
> with automatic WebSocket fallback. The subscribe/bind API is unchanged from the
> block above. See [Realtime — Details](/modalities/realtime/details) for the
> transport ladder.

- **`wsHost`** (`string`, required) — The FirstByte realtime host, `realtime.firstbyte.fast`. This is the single line that repoints a stock Pusher client at FirstByte.

- **`forceTLS`** (`boolean`, default `true`) — Always keep TLS on in production. Connections are `wss://`, and QUIC is TLS by construction.

- **`cluster`** (`string`) — This option is unused when `wsHost` is set (there are no Pusher clusters here). But `pusher-js` still expects the key to be present. Pass an empty string.

## Presence channels and a live member list

Presence channels (`presence-*`) track *who is here*. They require the same
auth step as private channels (below). In return, every subscriber gets the
current member roster plus `member_added` / `member_removed` events.

  1. **Subscribe and read the initial roster**
`pusher:subscription_succeeded` fires once with the full member list.
  2. **Bind to joins and leaves**
Keep your local roster in sync as members come and go.

**pusher-js:**
```js
import Pusher from "pusher-js";

const pusher = new Pusher("<APP_KEY>", {
  wsHost: "realtime.firstbyte.fast",
  wsPort: 443,
  forceTLS: true,
  cluster: "",
  // Auth endpoint your server hosts (see "Private channels" below).
  channelAuthorization: { endpoint: "/pusher/auth" },
});

const presence = pusher.subscribe("presence-room-42");

// Render the whole roster once, on join.
presence.bind("pusher:subscription_succeeded", (members) => {
  const roster = [];
  members.each((m) => roster.push({ id: m.id, ...m.info }));
  console.log("me:", members.me.id, members.me.info);
  render(roster);
});

// Keep it in sync.
presence.bind("pusher:member_added", (member) => {
  console.log("joined:", member.id, member.info);
  render(currentRoster().concat({ id: member.id, ...member.info }));
});

presence.bind("pusher:member_removed", (member) => {
  console.log("left:", member.id);
  render(currentRoster().filter((m) => m.id !== member.id));
});
```

> **NOTE:**
> `members.me`, `member.id`, and `member.info` come from the `presence_data` that
> your auth endpoint returns (next section). The `id` must be unique per user.
> `info` is arbitrary JSON that you attach (name, avatar, role).

## Private channels and the server auth endpoint

Private (`private-*`) and presence (`presence-*`) channels require an
HMAC-signed subscription. When a client subscribes, `pusher-js` POSTs the
`socket_id` and `channel_name` to your `channelAuthorization.endpoint`. Your
server decides whether that user may join. Your server then signs the grant
with the app **secret**. The signature is a standard Pusher auth token. The
stock Pusher server library produces it for you.

  1. **Point the client at your auth endpoint**
Set `channelAuthorization: { endpoint: "/pusher/auth" }` (shown above). On
    older `pusher-js` this option is called `authEndpoint`.
  2. **Authorize and sign on the server**
Run your own access check. Then pass the request to the Pusher server
    library.

**Node (Express + pusher):**
```js
import express from "express";
import Pusher from "pusher";

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

const app = express();
app.use(express.urlencoded({ extended: false })); // pusher-js posts form-encoded

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

  // 1. Your own authorization — is req.user allowed on this channel?
  const user = req.user; // from your session/JWT middleware
  if (!user || !mayAccess(user, channel_name)) {
    return res.status(403).send("forbidden");
  }

  // 2. Presence channels also carry the member payload.
  if (channel_name.startsWith("presence-")) {
    const presenceData = {
      user_id: String(user.id),
      user_info: { name: user.name, avatar: user.avatarUrl },
    };
    return res.send(
      pusher.authorizeChannel(socket_id, channel_name, presenceData)
    );
  }

  // 3. Plain private channel.
  res.send(pusher.authorizeChannel(socket_id, channel_name));
});

app.listen(3000);
```
**Python (Flask + pusher):**
```python
import os
from flask import Flask, request, jsonify
import pusher

pusher_client = pusher.Pusher(
    app_id=os.environ["APP_ID"],
    key=os.environ["APP_KEY"],
    secret=os.environ["APP_SECRET"],
    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"]

    user = current_user()  # from your session
    if user is None or not may_access(user, channel):
        return "forbidden", 403

    if channel.startswith("presence-"):
        payload = pusher_client.authenticate(
            channel=channel,
            socket_id=socket_id,
            custom_data={
                "user_id": str(user.id),
                "user_info": {"name": user.name},
            },
        )
    else:
        payload = pusher_client.authenticate(channel=channel, socket_id=socket_id)

    return jsonify(payload)
```

> **WARNING:**
> The app **secret** must stay only on your server. Any party that can produce
> these auth tokens can subscribe to any channel. Keep the secret in an
> environment variable. Never put it in browser code. If the secret leaks, rotate
> it from the console.

## Trigger an event from your server

Server-side publishing uses the Pusher server library pointed at the FirstByte
host. `trigger(channel, event, data)` fans the event out across the edge mesh to
every subscriber.

**Node (pusher):**
```js
import Pusher from "pusher";

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

await pusher.trigger("orders", "order.created", {
  id: 42,
  total: 19.99,
});

// Fan the same event to several channels at once:
await pusher.trigger(["orders", "private-admin"], "order.created", { id: 42 });
```
**Python (pusher):**
```python
pusher_client.trigger("orders", "order.created", {"id": 42, "total": 19.99})
```
**REST (control plane):**
```bash
# Publish over the REST API on the control-plane host. Pusher's REST calls are
# signed (auth_key + auth_signature + body_md5); the server libraries above build
# that signature for you, and the console's Event Creator prints a ready-to-run,
# already-signed cURL you can copy.
curl -X POST "https://portal.firstbyte.fast/apps/<APP_ID>/events" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order.created",
    "channel": "orders",
    "data": "{\"id\":42,\"total\":19.99}"
  }'
```

> **NOTE:**
> The REST `data` field is a **string**: a JSON-encoded string of your payload,
> not a nested object. The server libraries do the encoding and the request
> signature. Use raw cURL only when you cannot run a server library. Get the
> signed version from the console's Event Creator.

## Send a client event

Subscribers can publish directly to a channel with a `client-` prefixed event.
This is useful for ephemeral signals like typing indicators or cursor
positions. There is no server round trip. Client events are allowed only on
`private-*` and `presence-*` channels, and only when **client events** are
enabled for the app.

**pusher-js:**
```js
const presence = pusher.subscribe("presence-room-42");

presence.bind("pusher:subscription_succeeded", () => {
  // Now safe to emit client events on this channel.
  presence.trigger("client-typing", { userId: "u_42", typing: true });
});

// Everyone else on the channel receives it:
presence.bind("client-typing", (data) => {
  console.log(`${data.userId} is typing`, data.typing);
});
```

> **WARNING:**
> First enable **client events** for the app in the console, under **Settings →
> App capabilities**. When the setting is off, the engine silently rejects
> `client-*` triggers. Client events are not delivered back to the sender. They
> are rate-limited per connection.

## Receive a webhook

FirstByte POSTs signed event batches to endpoints that you register in the
console (**Webhooks**). Each request carries an `X-Pusher-Signature` header.
The header is an HMAC of the raw body under the webhook's signing secret.
**Check the signature against the raw bytes before you parse the body.**
Compare in constant time.

**Node (Express):**
```js
import express from "express";
import crypto from "crypto";

const app = express();

// Capture the RAW body — the signature is over exact bytes, so verify before JSON.parse.
app.post(
  "/hooks/realtime",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get("X-Pusher-Signature") ?? "";
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(req.body) // req.body is a Buffer here
      .digest("hex");

    const ok =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!ok) return res.status(401).end();

    const { time_ms, events } = JSON.parse(req.body.toString("utf8"));
    for (const e of events) {
      switch (e.name) {
        case "channel_occupied":
          console.log("first subscriber on", e.channel);
          break;
        case "channel_vacated":
          console.log("last subscriber left", e.channel);
          break;
        case "member_added":
          console.log("member", e.user_id, "joined", e.channel);
          break;
        case "member_removed":
          console.log("member", e.user_id, "left", e.channel);
          break;
      }
    }

    res.status(200).end(); // ack fast; do work off the request path
  }
);

app.listen(3000);
```
**Python (Flask):**
```python
import hmac, hashlib, os, json
from flask import Flask, request

app = Flask(__name__)

@app.post("/hooks/realtime")
def realtime_hook():
    body = request.get_data()  # raw bytes
    signature = request.headers.get("X-Pusher-Signature", "")
    expected = hmac.new(
        os.environ["WEBHOOK_SECRET"].encode(), body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return "", 401

    payload = json.loads(body)
    for e in payload["events"]:
        print(e["name"], e.get("channel"), e.get("user_id"))
    return "", 200
```

> **NOTE:**
> The signing secret is separate from your app secret. The console shows it once,
> when you add the webhook. The `X-Pusher-Key` header identifies which app key
> the delivery is for. **Channel existence** (`channel_occupied` /
> `channel_vacated`) and **presence** (`member_added` / `member_removed`) are
> shipped. Additional groups shown in the console (client-event and cache-miss
> webhooks) are rolling out. Treat them as preview. Check delivery for your
> deployment before you depend on them.

## Related

  - **[Details](/modalities/realtime/details)** — How the Pusher-compatible layer maps onto WebSocket and QUIC transports.
  - **[SDK & API](/modalities/realtime/sdk-methods)** — The client, server-publish, and auth surfaces in reference form.
  - **[Recipes](/modalities/realtime/recipes)** — End-to-end builds: a live presence list and server-pushed notifications.
  - **[Console](/modalities/realtime/console)** — Create app keys, publish test events, and configure webhooks. No code is
    necessary.
