Quickback Docs

Using Realtime

WebSocket connections, authentication, and client-side event handling.

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:

const ws = new WebSocket("wss://api.yourdomain.com/realtime/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

const response = await fetch("/realtime/v1/ws-ticket", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    "Content-Type": "application/json",
  },
});
const { wsTicket, expiresIn } = await response.json();
// expiresIn = 60 (seconds)

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

Step 2: Connect with Ticket

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

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:

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:

{
  "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:

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:

{
  "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:

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 within an organization. Only that user receives the broadcast — all other connections in the org are skipped.

Organization Isolation

Each organization has its own Durable Object instance. Users can only subscribe to events in organizations they belong to, enforced during authentication.

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:

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

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

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

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

  return ws;
}

Required Environment Variables

VariableWhereDescription
REALTIME_URLMain APIURL of the broadcast/realtime worker
ACCESS_TOKENBothShared secret for broadcast API auth and ws-ticket signing
# Main API wrangler.toml
[vars]
REALTIME_URL = "https://my-app-broadcast.workers.dev"

# Broadcast worker: set secret via CLI
# wrangler secret put ACCESS_TOKEN

Cloudflare Only

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

See Also

On this page