# 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");
```

## 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

CRUD events use the `postgres_changes` type:

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

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

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

**Event payload:**

```json
{
  "type": "postgres_changes",
  "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 use the `broadcast` type:

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

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

**Event payload:**

```json
{
  "type": "broadcast",
  "event": "screening-complete",
  "payload": {
    "applicationId": "app_123",
    "candidateId": "cnd_456",
    "stage": "interview"
  }
}
```

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

## Security

### Role-Based Filtering

Events are only delivered to users whose role matches the broadcast's `targetRoles`. If a table's realtime config specifies `requiredRoles: ["hiring-manager", "recruiter"]`, only users with those roles receive the events.

### 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.

### 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"]
```

## Cloudflare Only

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

## See Also

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