WebSocket Signaling
Updated
On this page
Xirsys signaling carries application messages such as WebRTC offers, answers, and ICE candidates between peers on the same channel. Your application still owns the WebRTC state machine, peer roles, authorization, and message payloads.
A signaling connection requires two temporary values from your backend: a selected WebSocket host and a peer token. Do not put your Xirsys account secret in client code.
1. Select a Signaling Host
/_host?type=signalCall GET /_host?type=signal with HTTP Basic authentication. The endpoint selects a healthy signaling API host; it does not use a channel or peer k parameter.
curl --user "$XIRSYS_IDENT:$XIRSYS_SECRET" \
"https://global.xirsys.net/_host?type=signal"{
"s": "ok",
"v": "wss://signaling-host.example:443/ws"
}Treat v as the complete WebSocket base URL. Do not reconstruct its scheme, port, or /ws path.
2. Create a Peer Token
/_token/{channel}?k={peer-id}&expire={seconds}Call PUT /_token/{channel}?k={peer-id}&expire={seconds}. The token authorizes one peer ID on the specified channel.
curl --request PUT \
--user "$XIRSYS_IDENT:$XIRSYS_SECRET" \
"https://global.xirsys.net/_token/myChannel?k=peer-123&expire=120"{
"s": "ok",
"v": "eyJhbGciOi..."
}The default token lifetime is 60 seconds. expire=0 creates a long-lived but still finite token; it does not mean literal non-expiration. Prefer short-lived tokens and create a fresh token for a reconnect. The peer ID must be unique among peers connected to that channel at the same time.
3. Connect
{host}/v2/{token}Append /v2/{token} to the host returned by /_host and wait for the WebSocket open event before sending.
const socket = new WebSocket(
`${signalingHost}/v2/${encodeURIComponent(token)}`
);
socket.addEventListener('open', () => {
console.log('Xirsys signaling connected');
});
socket.addEventListener('close', () => {
// Reconnect with backoff after retrieving a fresh host and token.
});Connection lifecycle messages can include peers, peer_connected, and peer_removed. Use them as presence signals, but design your application to tolerate disconnects, reconnects, and simultaneous state changes.
4. Send User Messages
A user message requires top-level t, m, and p fields:
socket.send(JSON.stringify({
t: 'u',
m: {
f: myPeerId,
// t: remotePeerId, // [Optional] Direct messages only.
o: 'offer'
},
p: {
sdp: peerConnection.localDescription
}
}));| Field | Meaning |
|---|---|
t | Use "u" for an application user message. |
m.f | Required sender metadata supplied by your application. It does not select the channel. |
m.o | Required application operation, such as offer, answer, or candidate. |
m.t | [Optional] Add a remote peer ID for a direct message. |
p | Application-defined payload. |
The token determines channel membership. When m.t is omitted, Xirsys publishes the message to every other connected subscriber on the channel; the sender does not receive an echo. Use targeted messages for one-to-one WebRTC negotiation.
socket.addEventListener('message', async event => {
const message = JSON.parse(event.data);
if (message.t !== 'u') return;
switch (message.m?.o) {
case 'offer':
await handleOffer(message.p);
break;
case 'answer':
await handleAnswer(message.p);
break;
case 'candidate':
await handleCandidate(message.p);
break;
}
});5. Ping/Pong Heartbeats
Use an application-level JSON ping to keep a signaling connection healthy. The server returns a pong only to the same socket:
client -> {"t":"ping","ts":1721846400000}
server -> {"t":"pong","ts":1721846400000}The server echoes ts, which lets the client match the reply and measure round-trip time. The pong is a direct socket response and is not broadcast as a "u" message. Do not include m or p in heartbeat packets. Raw text ping or PING is also accepted and returns {"t":"pong"}, but timestamped JSON is recommended.
const pingEveryMs = 30_000;
const pongTimeoutMs = 10_000;
const maxMissedPongs = 2;
let pendingPing = null;
let missedPongs = 0;
let pongTimer;
function sendPing() {
if (socket.readyState !== WebSocket.OPEN || pendingPing !== null) return;
pendingPing = Date.now();
socket.send(JSON.stringify({ t: 'ping', ts: pendingPing }));
pongTimer = setTimeout(() => {
pendingPing = null;
missedPongs += 1;
if (missedPongs >= maxMissedPongs) {
socket.close(4000, 'Heartbeat timeout');
}
}, pongTimeoutMs);
}
const pingTimer = setInterval(sendPing, pingEveryMs);
socket.addEventListener('message', event => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.t === 'pong' && message.ts === pendingPing) {
clearTimeout(pongTimer);
pendingPing = null;
missedPongs = 0;
}
});
socket.addEventListener('close', () => {
clearInterval(pingTimer);
clearTimeout(pongTimer);
});A 30–60 second ping interval and reconnecting after two or three consecutive missed pongs are reasonable starting points. Tune the policy to your network and product requirements, and use exponential backoff with jitter when reconnecting.
Backend Configuration Example
This Node.js 18+ function retrieves both values after authenticating the application user and assigning a peer ID:
async function getSignalConfig(channel, peerId) {
const baseUrl = 'https://global.xirsys.net';
const encodedChannel = encodeURIComponent(channel);
const auth = Buffer.from(
`${process.env.XIRSYS_IDENT}:${process.env.XIRSYS_SECRET}`
).toString('base64');
const headers = { Authorization: `Basic ${auth}` };
const [hostResponse, tokenResponse] = await Promise.all([
fetch(`${baseUrl}/_host?type=signal`, { headers }),
fetch(
`${baseUrl}/_token/${encodedChannel}` +
`?k=${encodeURIComponent(peerId)}&expire=120`,
{ method: 'PUT', headers }
)
]);
const [hostData, tokenData] = await Promise.all([
hostResponse.json(),
tokenResponse.json()
]);
if (
!hostResponse.ok ||
!tokenResponse.ok ||
hostData.s !== 'ok' ||
tokenData.s !== 'ok'
) {
throw new Error('Unable to create Xirsys signaling configuration');
}
return { host: hostData.v, token: tokenData.v };
}