# Using Realtime

This page covers connecting to the Quickback realtime system from client applications — authentication, subscribing to events, and handling messages.

## Connecting

Open a WebSocket connection to the realtime worker:

```typescript
const ws = new WebSocket("wss://api.yourdomain.com/broadcast/v1/websocket");
// Note: /broadcast/v1 tracks contract.routes — use /broadcast/v2 when that is "v2".
```

## Authentication

Quickback uses **ticket-based authentication** for WebSocket connections. This is a two-step process:

1. **Get a ticket** — Call the ws-ticket endpoint with your session auth
2. **Connect with ticket** — Pass the ticket as a URL parameter when opening the WebSocket

This approach is faster and more secure than in-band auth messages — the connection is authenticated at upgrade time with no HTTP round-trip from the Durable Object.

### Step 1: Get a WebSocket Ticket

```typescript
const response = await fetch("/broadcast/v1/ws-ticket", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ eventId: "evt_123" }), // optional; used by resource-scoped wsTicket config
});
const { wsTicket, expiresIn } = await response.json();
// expiresIn = 60 (seconds)
```

The ticket is a short-lived (60-second) HMAC-signed token containing your userId, roles, and the resolved subscription scope.

### Step 2: Connect with Ticket

```typescript
const ws = new WebSocket(
  `wss://api.yourdomain.com/broadcast/v1/websocket?ws_ticket=${wsTicket}`
);

ws.onopen = () => {
  console.log("Connected and authenticated!");
  // No auth message needed — connection is pre-authenticated
};
```

If the ticket is invalid or expired, the WebSocket upgrade is rejected with a 401 status.

## Handling Messages

### CRUD Events

Data frames are [CloudEvents 1.0 envelopes](#cloudevents-envelopes).
Discriminate on the `qbframe` extension — **never** on `type`, whose shape is
not a stable contract — and read the row from `data`:

```typescript
ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  if (msg.qbframe === "postgres_changes") {
    const { table, eventType, new: newRecord, old: oldRecord } = msg.data;

    switch (eventType) {
      case "INSERT":
        addRecord(table, newRecord);
        break;
      case "UPDATE":
        updateRecord(table, newRecord);
        break;
      case "DELETE":
        removeRecord(table, oldRecord.id);
        break;
    }
  }
};
```

**Event payload:**

```json
{
  "specversion": "1.0",
  "id": "evt_5b1c…",
  "source": "/broadcast/v1/websocket",
  "type": "dev.quickback.myapp.applications.insert",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "postgres_changes",
  "data": {
    "table": "applications",
    "schema": "public",
    "eventType": "INSERT",
    "new": { "id": "app_123", "candidateId": "cnd_456", "stage": "interview" },
    "old": null
  }
}
```

For UPDATE events, both `new` and `old` are populated. For DELETE events, only `old` is populated.

### Custom Broadcasts

Custom events carry `qbframe: "broadcast"`. Their `type` is your wire name
verbatim — author-supplied names are never prefixed:

```typescript
if (msg.qbframe === "broadcast") {
  const event = msg.type;
  const payload = msg.data;

  if (event === "screening-complete") {
    refreshApplication(payload.applicationId);
  } else if (event === "screening:progress") {
    updateProgressBar(payload.percent);
  }
}
```

**Event payload:**

```json
{
  "specversion": "1.0",
  "id": "evt_a71f…",
  "source": "/broadcast/v1/websocket",
  "type": "screening-complete",
  "time": "2026-08-17T10:04:11.921Z",
  "datacontenttype": "application/json",
  "qbframe": "broadcast",
  "data": {
    "applicationId": "app_123",
    "candidateId": "cnd_456",
    "stage": "interview"
  }
}
```

`type` is your wire name verbatim — author-supplied event names are never
prefixed. Match on `qbframe`, not `type`.

Custom namespaces (from `defineRealtime()`) use the format `namespace:event` — e.g., `screening:started`, `screening:progress`.

## Security

### Role-Based Filtering

Events are only delivered to subscribers who fall within the broadcast's audience. A broadcasting table's realtime config **must** declare `access: { roles: [...] }` — e.g. `access: { roles: ["hiring-manager", "recruiter"] }` delivers only to those roles. The audience is evaluated at fanout with the same uppercase pseudo-role semantics as REST (`PUBLIC` = everyone in the room, `AUTHENTICATED`, `USER`, `SYSADMIN`), plus concrete membership/scope roles. This is **fail-closed**: an empty or omitted audience delivers to no one (and is a compile error for a broadcasting resource). Applies to generated CRUD broadcasts and to [live view](/platform/realtime/live-views) `view_changes` deltas alike.

### Named rules and gates in `requiredRoles`

Declared [`authz.roles`](/define/authz-roles#centrally-defined-access-rules) and [`authz.rules` gates](/define/authz-roles#named-gates-authzrules) can appear in `realtime.requiredRoles`. The compiler lowers them to flat static role names at build time, because the WebSocket ticket handshake has no DB in hand:

- A named role lowers to its composing static roles; relationship (`via:`) arms contribute nothing at the handshake (the row-stream firewall enforces that dimension later) and emit a warning.
- A **who-only gate** lowers as its `who`. A gate carrying `record:` or `firewall` arms is undecidable at the handshake — it contributes nothing, with a warning.
- **Fail-closed rule:** a declared non-empty `requiredRoles` that lowers to an **empty** set is a **compile error**, never a silently-assigned `[]`. The error names which entries lowered to nothing and why. Fix it by referencing a who-only rule, adding a static BA/pseudo-role fallback arm, or dropping the undecidable entries and relying on the row-stream firewall.

### Per-Role Masking

Field values are masked according to the subscriber's role. For example, with this masking config:

```typescript
masking: {
  ssn: { type: "ssn", show: { roles: ["owner"] } },
}
```

- **Owner sees:** `{ ssn: "123-45-6789" }`
- **Recruiter sees:** `{ ssn: "*****6789" }`

Masking is pre-computed per-role (O(roles), not O(subscribers)) for efficiency. A `type: 'custom'` mask function can't run inside the Broadcaster — those columns broadcast as `[REDACTED]` (fail closed).

### User-Specific Events

Events can target a specific user lane. Only that user receives the broadcast — all other connections on the same scope are skipped.

### Scope Isolation

Each WebSocket connection is attached to exactly one resolved scope: `scopeKey`, `organizationId`, or `userId`. Users can only subscribe to the scope stamped into their ws ticket, enforced during authentication and ticket minting.

## Reconnection

WebSocket connections can drop due to network issues. Implement reconnection logic in your client — note that you need to fetch a fresh ticket on each reconnect since tickets expire after 60 seconds:

```typescript
async function connect() {
  // Get a fresh ticket each time
  const res = await fetch("/broadcast/v1/ws-ticket", {
    method: "POST",
    headers: { Authorization: `Bearer ${sessionToken}` },
    body: JSON.stringify({ eventId: "evt_123" }),
  });
  const { wsTicket } = await res.json();

  const ws = new WebSocket(
    `wss://api.yourdomain.com/broadcast/v1/websocket?ws_ticket=${wsTicket}`
  );

  ws.onclose = () => {
    // Reconnect after delay
    setTimeout(connect, 2000);
  };

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    handleMessage(msg);
  };

  return ws;
}
```

## Environment

No additional environment variables are needed for realtime. The Broadcaster Durable Object runs inline in your main worker and reuses `BETTER_AUTH_SECRET` for WebSocket ticket signing.

The compiler generates the required `wrangler.toml` bindings automatically:

```toml
[[durable_objects.bindings]]
name = "BROADCASTER"
class_name = "Broadcaster"

[[migrations]]
tag = "v1"
new_classes = ["Broadcaster"]
```

## CloudEvents envelopes

Every **data frame** is wrapped in a [CloudEvents 1.0](https://cloudevents.io)
envelope at the Durable Object's send boundary. Control frames (`pong`,
`error`, `reauth_ok`) stay raw.

The endpoint path is independent of the framing — it tracks `contract.routes`,
so `/broadcast/v1` unless you set `routes: "v2"`. Frames are CloudEvents on
either path.

| Frame | CloudEvents `type` | `data` | extensions |
|---|---|---|---|
| `broadcast` (named invalidation) | the wire name verbatim | payload minus `version`/`reason` | `qbframe: "broadcast"`, `qbversion`, `qbreason` |
| `postgres_changes` | `dev.quickback.<project>.<table>.<insert\|update\|delete>` | `{ table, schema, eventType, new, old }` | `qbframe: "postgres_changes"` |
| `view_changes` | `dev.quickback.<project>.view.<viewName>.changed` | `{ view, rootId, delta }` | `qbframe: "view_changes"`, `qbseq` (the per-surface monotonic seq — your reconnect/gap-detection contract) |

**Discriminate frames on the `qbframe` extension.** Never on `type`, whose
shape is not a stable contract, and never on payload keys — named-invalidation
payloads are author-supplied maps, so a broadcast whose payload happens to
carry `view`/`delta` or `table`/`eventType` keys must not be mistaken for a
system frame.

Compiler-defined types carry the `dev.quickback.` reverse-DNS prefix, per the
CloudEvents core spec: the prefixed domain names whoever defines the event's
semantics. Your own broadcast wire names are **not** prefixed — that vocabulary
is yours.

A changeset's [`afterCommit`](/define/changesets#aftercommit--emit-a-realtime-signal-after-the-changeset-commits)
broadcast rides the **same `broadcast` frame** (the referenced typed event's
`wireName` verbatim), so clients handle it exactly like any named invalidation —
one CloudEvent per aggregate write, carrying the mapped payload (e.g.
`{ eventId, reservationId, occupantIds }`). Its optional root-row frame arrives
as a normal `postgres_changes` frame on the table's room.

```json
{ "specversion": "1.0",
  "id": "evt_9f2…",
  "source": "/broadcast/v1/websocket",
  "type": "event.mobile-bundle.changed",
  "time": "2026-07-20T12:00:00Z",
  "datacontenttype": "application/json",
  "qbframe": "broadcast",
  "qbversion": 1, "qbreason": "document.created",
  "data": { "eventId": "…", "documentId": "…" } }
```

The bundled CMS and Account SPAs consume these envelopes as-is:
match on `qbframe`, read the row from `data`, and (for live views) the
monotonic seq from `qbseq`. The full channel/message catalog ships as
`GET /asyncapi.json` (AsyncAPI 3.0), served under the same auth gating as
`/openapi.json`. Any project with realtime or webhooks serves it, and it
documents the CloudEvents envelopes above.

## Cloudflare Only

Realtime requires Cloudflare Durable Objects and is only available with the Cloudflare runtime.

## See Also

- [Durable Objects Setup](/platform/realtime/durable-objects) — Configuration, event formats, masking, and custom namespaces
- [Masking](/define/masking) — Field masking configuration
