Realtime
Connect to the opendesk realtime gateway with Phoenix Channels for live events, presence and typing.
The realtime gateway pushes every change in your workspace to connected clients as it happens. It speaks Phoenix Channels over WebSocket (JSON serializer v2), so the easiest client is the phoenix npm package.
The gateway is push only. Clients make changes through the REST API, never through the socket.
Endpoints
Section titled “Endpoints”There are two socket endpoints, one for agents and one for the chat widget:
Agents (ws tokens) |
Chat widget (widget_ws tokens) |
|
|---|---|---|
| Cloud | wss://gateway.opendesk.sh/socket/websocket |
wss://gateway.opendesk.sh/socket/widget/websocket |
| Self-hosted | wss://<your-domain>/socket/websocket |
wss://<your-domain>/socket/widget/websocket |
The agent endpoint (GATEWAY_URL) checks the browser Origin against CHECK_ORIGINS. The widget endpoint (GATEWAY_URL/widget) accepts only widget tokens, and accepts any origin, because the widget runs on your customers’ sites. The phoenix client appends /websocket itself, so pass it .../socket or .../socket/widget.
Get a token
Section titled “Get a token”Request a short-lived token, then connect with it:
curl -X POST https://api.opendesk.sh/v1/realtime/token \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "OD-Workspace: $WORKSPACE_ID"{ "token": "eyJhbGciOiJIUzI1NiIs...", "url": "wss://gateway.opendesk.sh/socket", "expires_at": "2026-10-11T09:05:00Z" }Users and API keys with the realtime scope can request tokens. Tokens live 5 minutes and only need to be valid when the socket connects. On every reconnect, fetch a fresh one.
Connect
Section titled “Connect”import { Socket, Presence } from 'phoenix';
const API = 'https://api.opendesk.sh/v1';const headers = { Authorization: `Bearer ${accessToken}`, 'OD-Workspace': workspaceId };
async function realtimeToken() { const res = await fetch(`${API}/realtime/token`, { method: 'POST', headers }); if (!res.ok) throw new Error(`realtime token: ${res.status}`); return res.json(); // { token, url, expires_at }}
let { url, token } = await realtimeToken();
const socket = new Socket(url, { // Called on every (re)connect, so each attempt uses a fresh token. params: () => ({ token }),});socket.onError(async () => { ({ token } = await realtimeToken());});socket.connect();The gateway checks the token’s signature, purpose, expiry and, on the agent endpoint, the browser’s Origin. Non-browser clients send no Origin and are accepted on the token alone.
Topics
Section titled “Topics”| Topic | Who may join | You receive |
|---|---|---|
workspace:<wid> |
a ws token for that workspace |
"event" for every event in the workspace; a stand-in for an event of a private team’s ticket |
team:<wid>:<tid> |
a ws token whose teams lists that private team |
"event" for that team’s tickets, in full |
user:<uid> |
a user’s own token | "event" for events addressed to that user, such as notifications |
ticket:<wid>:<ticket_id> |
users in the workspace | Presence, "typing" and "customer_typing" |
contact:<wid>:<contact_id> |
the chat widget for that contact | public "event"s and agent "typing" |
A successful join replies { "server_time": "..." }. A join you are not allowed replies with { "reason": "forbidden" }.
Private teams
Section titled “Private teams”Tickets of a private team are seen only by the people on that team. An event about one goes in full to team:<wid>:<tid>, and workspace:<wid> gets a stand-in in its place: the same seq and occurred_at, with type: "event.hidden" and empty data. Track seq on the workspace channel, and apply team events whenever they arrive, before or after their stand-in.
The ws token lists the private teams you may see in its teams claim. Join team:<wid>:<tid> for each of them. When your private teams change (you join or leave one, or one becomes private), the gateway closes your socket; reconnect with a fresh token to get the new list. On ticket:<wid>:<ticket_id> for a private team’s ticket, join with { "team_id": "<tid>" } to see its presence and typing.
Workspace events
Section titled “Workspace events”let lastSeq = null;
const workspace = socket.channel(`workspace:${workspaceId}`);
workspace.on('event', async (envelope) => { if (lastSeq !== null && envelope.seq !== lastSeq + 1) { await catchUp(); // a gap: fetch what we missed first } apply(envelope); lastSeq = Math.max(lastSeq ?? 0, envelope.seq);});
workspace .join() .receive('ok', () => catchUp()) .receive('error', ({ reason }) => console.error('join failed', reason));Every "event" payload is an envelope: id, seq, type, visibility, data and occurred_at.
Your notifications
Section titled “Your notifications”const me = socket.channel(`user:${userId}`);me.on('event', (envelope) => { if (envelope.type === 'notification.created') showNotification(envelope.data.notification);});me.join();Presence and typing on a ticket
Section titled “Presence and typing on a ticket”const ticket = socket.channel(`ticket:${workspaceId}:${ticketId}`);const presence = new Presence(ticket);
presence.onSync(() => { const here = presence.list((_id, { metas }) => metas[0]); // [{ user_id, name, avatar, state: "viewing" | "replying" | "idle", online_at }] renderAvatars(here);});
ticket.on('typing', ({ user_id, name }) => showAgentTyping(user_id, name));ticket.on('customer_typing', ({ ticket_id, contact_id }) => showCustomerTyping(contact_id));
ticket.join().receive('ok', () => ticket.push('presence', { state: 'viewing' }));
// When the agent starts writing:ticket.push('presence', { state: 'replying' });// While typing (throttle this). Include contact_id on chat tickets so the customer sees it:ticket.push('typing', { contact_id: contactId });Presence updates are rate limited, so send them when the state changes, not on every keystroke. Leave the channel (ticket.leave()) when the agent closes the ticket.
Missed events
Section titled “Missed events”Events carry seq, which is contiguous per workspace. If you see a jump, or after any reconnect, fetch what you missed:
curl "https://api.opendesk.sh/v1/events?since=1041&limit=100" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "OD-Workspace: $WORKSPACE_ID"{ "data": [ { "seq": 1042, "type": "ticket.updated", "...": "..." } ], "latest_seq": 1042, "resync": false }async function catchUp() { if (lastSeq === null) { // First connect: start from the latest seq without replaying history. const res = await fetch(`${API}/events?since=0&limit=1`, { headers }); lastSeq = (await res.json()).latest_seq; return; } for (;;) { const res = await fetch(`${API}/events?since=${lastSeq}&limit=100`, { headers }); const page = await res.json(); if (page.resync) { // The gap is older than retention: refetch everything instead. lastSeq = page.latest_seq; return refetchAll(); } for (const envelope of page.data) { apply(envelope); lastSeq = envelope.seq; } if (page.data.length === 0 || lastSeq >= page.latest_seq) return; }}Events are kept for 7 days. When the gap is older, the response has "resync": true, and you should reload your data from the REST API. Results are filtered by what the caller may see.
Being disconnected
Section titled “Being disconnected”The gateway closes a user’s sockets when they log out, change their password, have a session revoked or are removed from the workspace. Fetching a new token then fails, and the client should sign in again.
The chat widget
Section titled “The chat widget”The widget gets a widget_ws token from POST /v1/widget/session, connects to the widget endpoint (<GATEWAY_URL>/widget, such as wss://gateway.opendesk.sh/socket/widget), and joins contact:<wid>:<contact_id>.
import { Socket } from 'phoenix';
const socket = new Socket('wss://gateway.opendesk.sh/socket/widget', { params: () => ({ token: wsToken }) });socket.connect();const conversation = socket.channel(`contact:${workspaceId}:${contactId}`);conversation.on('event', (envelope) => showMessage(envelope));conversation.on('typing', ({ ticket_id, user }) => showAgentTyping(user.name));conversation.join();It receives "event" for public events about its conversations (agent replies on chat tickets) and "typing" from agents. It can push "typing" with { ticket_id }, which agents see as "customer_typing". Internal notes and every other event are never sent to it.