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/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:
- Get a ticket — Call the ws-ticket endpoint with your session auth
- 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("/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
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.
Discriminate on the qbframe extension — never on type, whose shape is
not a stable contract — and read the row from data:
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:
{
"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:
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:
{
"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 view_changes deltas alike.
Named rules and gates in requiredRoles
Declared authz.roles and authz.rules gates 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 carryingrecord:orfirewallarms is undecidable at the handshake — it contributes nothing, with a warning. - Fail-closed rule: a declared non-empty
requiredRolesthat 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:
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:
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:
[[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
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
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.
{ "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 — Configuration, event formats, masking, and custom namespaces
- Masking — Field masking configuration