WebSocket Realtime

Connect to wss://cliqer.io/ws to drive a Cliqer room from your own software or show-control system, like a remote clicker: change slides, run the host's timer, point with a laser dot and message the host. Your app joins the room as a presenter; the room itself is always hosted by the Cliqer desktop app.
session_token cookie (browsers send it automatically), a ?token= query parameter or an Authorization: Bearer header. The token is checked when you connect, not on every command, so when it expires open a new connection with a fresh token and join again. Without a token you can connect but cannot use the API. Team API keys (cliqer_...) authenticate the REST endpoints only; on this socket a key is not a session token.Connection
# WebSocket connections require a WebSocket client
# Using websocat for testing:
websocat "$WS_URL/ws?token=YOUR_ACCESS_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
ws.onopen = () => {
console.log('Connected to Cliqer WebSocket')
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('Received:', message)
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
import websocket
import json
ws = websocket.WebSocketApp(
f"$WS_URL/ws?token={access_token}",
on_message=lambda ws, msg: print(json.loads(msg)),
on_error=lambda ws, err: print(f"Error: {err}"),
on_open=lambda ws: print("Connected")
)
ws.run_forever()
import (
"github.com/gorilla/websocket"
)
url := "$WS_URL/ws?token=" + accessToken
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
fmt.Printf("Received: %s\n", message)
}
use WebSocket\Client;
$client = new Client("$WS_URL/ws?token={$accessToken}");
while (true) {
$message = $client->receive();
$data = json_decode($message, true);
print_r($data);
}
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{StreamExt, SinkExt};
let url = format!("$WS_URL/ws?token={}", access_token);
let (mut ws_stream, _) = connect_async(&url).await?;
while let Some(msg) = ws_stream.next().await {
match msg? {
Message::Text(text) => {
let data: serde_json::Value = serde_json::from_str(&text)?;
println!("Received: {:?}", data);
}
_ => {}
}
}
The server sends connected with your clientId once it has set up the connection. Wait for it before you send anything: a message that arrives earlier is rejected with an error and the connection is closed.
Connection & Room Flow
Slide Navigation
Drive the slides in a room from your own software, like a remote clicker. Your app joins the room as a presenter, so the host sees it in the presenter list under its presenterName and controls it like any presenter: the operator can switch its clicks off, remove it or block it.
- Connect with a session token (
?token=, anAuthorization: Bearerheader or thesession_tokencookie). Team API keys work on the REST endpoints only, not on this socket. - The room owner's plan must include the Client API: the API add-on on Plus, Pro and Max, included on Ultra and Enterprise.
- Slide and timer commands run only while the host allows clicks from your connection.
Join the room
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- Send it after the
connectedevent: the server rejects anything that arrives before it. presenterNameis the name the host sees (defaultAPI). Your app is always a presenter in the room, never its host.- Wait for a
clicksEnabledevent with payloadtruebefore you send commands. The host sends it once it has set up your connection, and again whenever the operator switches your clicks on or off. - Keep the
presenterKeyfromjoinedRoomand send it injoinRoomwhen you reconnect, so the host keeps your click permission. - Send the text frame
pingevery 15 seconds to keep the connection open. The server answers with the text framepong(not JSON).
Send commands
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | Extra field | What it does |
|---|---|---|
next | Next slide (or build) | |
prev | Previous slide (or build) | |
first | First slide | |
last | Last slide | |
goto | slide: whole number from 1 to 9999 | Go to that slide |
blackout | on: true or false | Black the screen, or show it again |
idis optional: 1 to 64 letters, digits,_,.,:or-. It comes back in the result. Use a newidfor every command: the host drops a command whoseidit already had from your connection in the last 5 seconds, so a retry of the same command runs once.first,last,gotoandblackoutrun only on a host whose latestcontrolsevent lists them innav. A host that sends nocontrolsevent supportsnextandprevonly. With several hosts in the room, each host runs the commands it supports.nextorprevwhile the screen is black shows it again without moving.- A
gotopast the last slide is ignored. - Send at most 10 commands per second, slide and timer commands together. The host also drops a repeat of the same command within 50 milliseconds.
What each presentation app supports today:
| Presentation app | next, prev | first, last | goto | blackout |
|---|---|---|---|---|
| PowerPoint on Windows | Yes | Yes | Yes | Yes |
| PowerPoint on Mac | Yes | Yes | No | No |
| Keynote | Yes | No | No | Yes |
| Google Slides (browser extension connected) | Yes | Yes | No | No |
| vMix | Yes | No | No | No |
| Focused window | Yes | No | No | No |
The controls event is the live answer for the host you are driving: read nav from it rather than from this table.
# One connection: wait for `connected`, join as an API client, give the host a
# moment to set it up, then send commands. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"webAction","payload":{"action":"next","id":"a1"}}'
sleep 1
echo '{"type":"webAction","payload":{"action":"prev","id":"a2"}}'
sleep 1 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
const send = (message: object) => ws.send(JSON.stringify(message))
let ready = false
ws.onmessage = (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
send({ type: 'joinRoom', payload: { room: 'ABC123', presenterName: 'Show control', api: true } })
setInterval(() => ws.send('ping'), 15000) // keeps the connection open
}
if (msg.type === 'clicksEnabled' && msg.payload === true && !ready) {
ready = true
send({ type: 'webAction', payload: { action: 'next', id: 'a1' } })
// Also: { action: 'prev' }, { action: 'first' }, { action: 'last' },
// { action: 'goto', slide: 5 }, { action: 'blackout', on: true }
}
if (msg.type === 'controls') console.log('Commands this host supports:', ['next', 'prev', ...msg.payload.nav])
if (msg.type === 'webActionResult') console.log(`${msg.payload.action} delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'slide') console.log('Slide:', msg.payload?.number ?? 'no deck open')
if (msg.type === 'error') console.error(msg.error, msg.payload?.message)
}
import json
import threading
import time
import websocket
ready = False
def send(ws, message):
ws.send(json.dumps(message))
def keepalive(ws): # keeps the connection open
while ws.sock and ws.sock.connected:
time.sleep(15)
ws.send("ping")
def on_message(ws, message):
global ready
if message == "pong":
return
msg = json.loads(message)
if msg["type"] == "connected":
send(ws, {"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": True}})
threading.Thread(target=keepalive, args=(ws,), daemon=True).start()
elif msg["type"] == "clicksEnabled" and msg["payload"] is True and not ready:
ready = True
send(ws, {"type": "webAction", "payload": {"action": "next", "id": "a1"}})
# Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": True}, ...
elif msg["type"] == "webActionResult":
print(f"{msg['payload']['action']} delivered to {msg['payload']['hosts']} host(s)")
elif msg["type"] == "slide":
print("Slide:", msg["payload"]["number"] if msg["payload"] else "no deck open")
elif msg["type"] == "error":
print("Error:", msg["error"], msg["payload"]["message"])
ws = websocket.WebSocketApp(f"$WS_URL/ws?token={access_token}", on_message=on_message)
ws.run_forever()
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
conn, _, err := websocket.DefaultDialer.Dial("$WS_URL/ws?token="+accessToken, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
var writeMu sync.Mutex // one writer at a time
send := func(message interface{}) {
writeMu.Lock()
defer writeMu.Unlock()
conn.WriteJSON(message)
}
go func() { // keeps the connection open
for range time.Tick(15 * time.Second) {
writeMu.Lock()
conn.WriteMessage(websocket.TextMessage, []byte("ping"))
writeMu.Unlock()
}
}()
ready := false
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
if string(data) == "pong" {
continue
}
var msg struct {
Type string `json:"type"`
Error string `json:"error"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(data, &msg) != nil {
continue
}
switch msg.Type {
case "connected":
send(map[string]interface{}{
"type": "joinRoom",
"payload": map[string]interface{}{"room": "ABC123", "presenterName": "Show control", "api": true},
})
case "clicksEnabled":
if string(msg.Payload) == "true" && !ready {
ready = true
send(map[string]interface{}{
"type": "webAction",
"payload": map[string]interface{}{"action": "next", "id": "a1"},
})
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
case "webActionResult", "slide", "error":
fmt.Printf("%s %s %s\n", msg.Type, msg.Error, msg.Payload)
}
}
use WebSocket\Client;
use WebSocket\TimeoutException;
// A 15 second read timeout doubles as the keepalive timer.
$client = new Client("$WS_URL/ws?token={$accessToken}", ['timeout' => 15]);
$ready = false;
while (true) {
try {
$message = $client->receive();
} catch (TimeoutException $e) {
$client->text('ping'); // keeps the connection open
continue;
}
if ($message === null) break; // closed
if ($message === 'pong') continue;
$msg = json_decode($message, true);
if ($msg['type'] === 'connected') {
$client->text(json_encode([
'type' => 'joinRoom',
'payload' => ['room' => 'ABC123', 'presenterName' => 'Show control', 'api' => true]
]));
} elseif ($msg['type'] === 'clicksEnabled' && $msg['payload'] === true && !$ready) {
$ready = true;
$client->text(json_encode([
'type' => 'webAction',
'payload' => ['action' => 'next', 'id' => 'a1']
]));
// Also: ['action' => 'goto', 'slide' => 5], ['action' => 'blackout', 'on' => true], ...
} elseif (in_array($msg['type'], ['webActionResult', 'slide', 'error'], true)) {
echo json_encode($msg), PHP_EOL;
}
}
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::{connect_async, tungstenite::Message};
let url = format!("$WS_URL/ws?token={}", access_token);
let (ws_stream, _) = connect_async(&url).await?;
let (mut write, mut read) = ws_stream.split();
let every = std::time::Duration::from_secs(15);
let mut keepalive = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
let mut ready = false;
loop {
tokio::select! {
_ = keepalive.tick() => write.send(Message::Text("ping".into())).await?, // keeps the connection open
frame = read.next() => {
let Some(frame) = frame else { break };
let Message::Text(text) = frame? else { continue };
if text.as_str() == "pong" { continue }
let msg: Value = serde_json::from_str(&text)?;
match msg["type"].as_str() {
Some("connected") => {
let join = json!({"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": true}});
write.send(Message::Text(join.to_string().into())).await?;
}
Some("clicksEnabled") if msg["payload"] == true && !ready => {
ready = true;
let next = json!({"type": "webAction", "payload": {"action": "next", "id": "a1"}});
write.send(Message::Text(next.to_string().into())).await?;
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
Some("webActionResult") | Some("slide") | Some("error") => println!("{}", msg),
_ => {}
}
}
}
}
Results and events
Each command is answered with a webActionResult or an error.
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts is the number of hosts the command was delivered to. The proof that it ran is the host's next slide event; blackout also changes blackout in the host's next controls event. A command the host does not run (clicks switched off, a command it does not support, a goto past the last slide) gets a result but no slide event.
error | When |
|---|---|
auth_required | joinRoom with api: true on a connection without a session token |
not_api_client | A command from a connection that has not joined a room with api: true, or after it left |
invalid_web_action | Unknown action, or a missing or invalid slide, on or id |
invalid_timer_action | Unknown timer action, or a missing or invalid minutes or id |
invalid_message | A message without text, or longer than 1000 characters |
invalid_presenter_mouse | A pointer position outside 0 to 1 |
rate_limited | More than 10 slide and timer commands in one second, or more than 5 messages in 10 seconds |
no_host | No host is connected to the room |
host_update_required | Every host in the room runs an older Cliqer version that cannot take commands from the API |
api_not_in_plan | The room owner's plan does not include the Client API |
Errors carry a readable message and, when they were well-formed, the command's action and id:
{ "type": "error", "error": "invalid_web_action", "payload": { "message": "Unknown or incomplete webAction. Use next, prev, first, last, goto with slide, or blackout with on.", "action": "goto", "id": "a3" } }
Events from a host carry that host's id in hostId:
| Event | Payload |
|---|---|
clicksEnabled | true when the host takes your commands, false when the operator switched your clicks off |
controls | { app, nav, blackout, timer }: the presentation app, the extra slide commands it supports (first, last, goto, blackout), whether the screen is black, and whether the host takes timer commands |
slide | { number, current, total, title, notes } after each command the host ran (number is "4/12", total is null when the app does not report it), or null when the host has no slide deck open |
countdown | The time the host's timer shows, such as "4:59" ("-0:12" in overtime), every second while it runs, or null when it stops |
countdownPhase | { phase, blink }: phase is idle, talk, warning (under a minute left), overtime or paused, and blink is whether the host's timer is flashing |
messageFromHost | A reply from the host operator, see Messages to the host |
You also receive the room's own events (clientJoined, clientLeft and others); ignore the ones you do not use. If the operator removes or blocks your connection you receive logout with reason: "kicked".
Timer
Run the timer in the host's Cliqer app with the same commands a Stream Deck or Bitfocus Companion sends.
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | What it does |
|---|---|
start | Start the timer, or carry on after stop |
stop | Pause the timer |
reset | Stop the timer and go back to the time it was set to |
plus1, plus2, plus5 | Add 1, 2 or 5 minutes |
minus1, minus2, minus5 | Take off 1, 2 or 5 minutes |
preset5, preset10, preset15, preset20 | Stop the timer and set 5, 10, 15 or 20 minutes |
set | Stop the timer and set minutes, a whole number from 1 to 999 |
- Each command is answered with a
timerActionResult({ action, id, hosts },hostsas for slides) or anerror. The proof it ran is the host's nextcountdownorcountdownPhaseevent. - The timer runs only on a host whose latest
controlsevent hastimer: true. Hosts on an older Cliqer version do not announce it and ignore timer commands. - Timer commands follow the same rules as slide commands: the host runs them only while it allows clicks from your connection, and they share the budget of 10 commands per second.
- The host's timer settings apply as they are: a timer set to count up counts up, and a timer that follows a connected hardware timer takes its time from that device.
Laser pointer
Show a laser dot on the host's shared screen, the way a presenter's phone does.
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xandyare the position on the shared screen from its top left corner, from0to1.- Send
{ "pressed": false }as the payload to hide the dot. Otherwise it disappears 30 to 90 seconds after your last position. - The dot shows only while the host shares a screen and has Laser Pointer switched on. Its colour follows your
presenterName. - Send at most 30 positions per second. Extra positions are dropped without an error, and there is no result event.
Messages to the host
Send a message to the host operator. It appears in the host's presenter chat, the same as a message from a presenter's phone, and the operator's replies come back as messageFromHost.
# Join as an API client, then send a message. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"messageToHosts","payload":{"text":"Two minutes to the break","id":"m1"}}'
sleep 30 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
// After joining (see Slide Navigation): send a message, print the replies.
send({ type: 'messageToHosts', payload: { text: 'Two minutes to the break', id: 'm1' } })
ws.addEventListener('message', (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'messageToHostsResult') console.log(`Delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'messageFromHost') console.log(`${msg.payload.fromPresenterName}: ${msg.payload.originalPayload.text}`)
})
# After joining (see Slide Navigation): send a message.
send(ws, {"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}})
# In on_message, print the replies:
# elif msg["type"] == "messageFromHost":
# print(f"{msg['payload']['fromPresenterName']}: {msg['payload']['originalPayload']['text']}")
// After joining (see Slide Navigation): send a message.
send(map[string]interface{}{
"type": "messageToHosts",
"payload": map[string]interface{}{"text": "Two minutes to the break", "id": "m1"},
})
// In the read loop, print the replies:
// case "messageFromHost":
// fmt.Printf("%s\n", msg.Payload)
// After joining (see Slide Navigation): send a message.
$client->text(json_encode([
'type' => 'messageToHosts',
'payload' => ['text' => 'Two minutes to the break', 'id' => 'm1']
]));
// In the read loop, print the replies:
// } elseif ($msg['type'] === 'messageFromHost') {
// echo $msg['payload']['originalPayload']['text'], PHP_EOL;
// After joining (see Slide Navigation): send a message.
let message = json!({"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}});
write.send(Message::Text(message.to_string().into())).await?;
// In the read loop, print the replies:
// Some("messageFromHost") => println!("{}", msg["payload"]["originalPayload"]["text"]),
Reply from the host:
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
textis 1 to 1000 characters. Send at most 5 messages every 10 seconds.- Each message is answered with a
messageToHostsResult({ id, hosts }) or anerror. - The operator sees the message under your
presenterName, and the host shows a notification when its Notifications setting is on.
Event Reference
Outgoing Events (Client → Server)
| Event | Description |
|---|---|
joinRoom | Join a room as an API client with api: true (see Slide Navigation) |
webAction | Slide control (see Slide Navigation) |
timerAction | Timer control (see Timer) |
presenterMouse | Laser pointer position (see Laser pointer) |
messageToHosts | Send a message to the host operator (see Messages to the host) |
logout | Leave the room; the connection stays open and can join again |
Incoming Events (Server → Client)
| Event | Description |
|---|---|
connected | Sent on open with { clientId, userId, isAuthenticated } |
joinedRoom | Successfully joined the room |
clientJoined | Someone joined the room |
clientLeft | Someone left the room |
webActionResult | A webAction was delivered to the room's hosts |
timerActionResult | A timerAction was delivered to the room's hosts |
messageToHostsResult | A messageToHosts was delivered to the room's hosts |
clicksEnabled | Whether a host takes your commands |
controls | What a host's presentation app and timer support |
slide | A host's current slide after a command |
countdown | The time on a host's timer |
countdownPhase | The phase of a host's timer |
messageFromHost | A message from the host operator |
logout | The operator removed or blocked your connection (reason: "kicked") |
error | Error message |