Realtime via WebSocket

Maak verbinding met wss://cliqer.io/ws om een Cliqer-ruimte vanuit je eigen software of showcontrolsysteem te bedienen, net als een presentatieclicker op afstand: wissel van dia, bedien de timer van de host, wijs aan met een laserstip en stuur de host berichten. Je app neemt als presentator deel aan de ruimte; de ruimte zelf wordt altijd gehost door de Cliqer-desktopapp.
session_token (browsers sturen die automatisch mee), een queryparameter ?token= of een Authorization: Bearer-header. Het token wordt gecontroleerd bij het verbinden, niet bij elke opdracht: open dus wanneer het verloopt een nieuwe verbinding met een nieuw token en neem opnieuw deel. Zonder token kun je verbinding maken, maar kun je de API niet gebruiken. Team-API-sleutels (cliqer_...) authenticeren alleen de REST-endpoints; op deze socket is een sleutel geen sessietoken.Verbinding
# 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);
}
_ => {}
}
}
De server stuurt connected met je clientId zodra de verbinding is opgezet. Wacht daarop voordat je iets verstuurt: een bericht dat eerder binnenkomt, wordt geweigerd met een error en de verbinding wordt gesloten.
Verbinding en verloop in de ruimte
Dianavigatie
Bedien de dia's in een ruimte vanuit je eigen software, net als een presentatieclicker op afstand. Je app neemt als presentator deel aan de ruimte, dus de host ziet hem in de presentatorlijst onder zijn presenterName en bedient hem zoals elke andere presentator: de operator kan zijn clicks uitschakelen, hem verwijderen of hem blokkeren.
- Maak verbinding met een sessietoken (
?token=, eenAuthorization: Bearer-header of de cookiesession_token). Team-API-sleutels werken alleen op de REST-endpoints, niet op deze socket. - Het abonnement van de eigenaar van de ruimte moet de Client API bevatten: de API-add-on bij Presenter, Studio HD en Studio 4K, inbegrepen bij Studio 8K en Enterprise.
- Dia- en timeropdrachten worden alleen uitgevoerd zolang de host clicks van je verbinding toestaat.
Deelnemen aan de ruimte
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- Stuur dit na het
connected-event: de server weigert alles wat daarvoor binnenkomt. presenterNameis de naam die de host ziet (standaardAPI). Je app is altijd een presentator in de ruimte, nooit de host.- Wacht op een
clicksEnabled-event met payloadtruevoordat je opdrachten verstuurt. De host stuurt dit zodra hij je verbinding heeft ingesteld, en opnieuw telkens wanneer de operator je clicks in- of uitschakelt. - Bewaar de
presenterKeyuitjoinedRoomen stuur die mee injoinRoomwanneer je opnieuw verbinding maakt, zodat de host je clickrechten behoudt. - Stuur elke 15 seconden het tekstframe
pingom de verbinding open te houden. De server antwoordt met het tekstframepong(geen JSON).
Opdrachten versturen
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | Extra veld | Wat het doet |
|---|---|---|
next | Volgende dia (of animatiestap) | |
prev | Vorige dia (of animatiestap) | |
first | Eerste dia | |
last | Laatste dia | |
goto | slide: geheel getal van 1 tot en met 9999 | Naar die dia gaan |
blackout | on: true of false | Het scherm zwart maken, of weer tonen |
idis optioneel: 1 tot 64 letters, cijfers,_,.,:of-. Het komt terug in het resultaat. Gebruik voor elke opdracht een nieuweid: de host negeert een opdracht waarvan hij deidin de afgelopen 5 seconden al van je verbinding heeft ontvangen, zodat een herhaalpoging van dezelfde opdracht maar één keer wordt uitgevoerd.first,last,gotoenblackoutworden alleen uitgevoerd op een host waarvan het laatstecontrols-event ze innavvermeldt. Een host die geencontrols-event stuurt, ondersteunt alleennextenprev. Met meerdere hosts in de ruimte voert elke host de opdrachten uit die hij ondersteunt.nextofprevterwijl het scherm zwart is, toont het scherm weer zonder van dia te wisselen.- Een
gotovoorbij de laatste dia wordt genegeerd. - Stuur maximaal 10 opdrachten per seconde, dia- en timeropdrachten samen. De host negeert ook een herhaling van dezelfde opdracht binnen 50 milliseconden.
Wat elke presentatieapp momenteel ondersteunt:
| Presentatieapp | next, prev | first, last | goto | blackout |
|---|---|---|---|---|
| PowerPoint op Windows | Ja | Ja | Ja | Ja |
| PowerPoint op Mac | Ja | Ja | Nee | Nee |
| Keynote | Ja | Nee | Nee | Ja |
| Google Slides (browserextensie verbonden) | Ja | Ja | Nee | Nee |
| vMix | Ja | Nee | Nee | Nee |
| Actief venster | Ja | Nee | Nee | Nee |
Het controls-event is het actuele antwoord voor de host die je bedient: lees nav daaruit in plaats van uit deze tabel.
# 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),
_ => {}
}
}
}
}
Resultaten en events
Elke opdracht wordt beantwoord met een webActionResult of een error.
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts is het aantal hosts waaraan de opdracht is afgeleverd. Het bewijs dat de opdracht is uitgevoerd, is het volgende slide-event van de host; blackout wijzigt ook blackout in het volgende controls-event van de host. Een opdracht die de host niet uitvoert (clicks uitgeschakeld, een opdracht die hij niet ondersteunt, een goto voorbij de laatste dia) krijgt wel een resultaat, maar geen slide-event.
error | Wanneer |
|---|---|
auth_required | joinRoom met api: true op een verbinding zonder sessietoken |
not_api_client | Een opdracht van een verbinding die niet met api: true aan een ruimte heeft deelgenomen, of nadat die de ruimte heeft verlaten |
invalid_web_action | Onbekende action, of een ontbrekende of ongeldige slide, on of id |
invalid_timer_action | Onbekende timer-action, of een ontbrekende of ongeldige minutes of id |
invalid_message | Een bericht zonder text, of langer dan 1000 tekens |
invalid_presenter_mouse | Een aanwijzerpositie buiten 0 tot en met 1 |
rate_limited | Meer dan 10 dia- en timeropdrachten in één seconde, of meer dan 5 berichten in 10 seconden |
no_host | Er is geen host met de ruimte verbonden |
host_update_required | Elke host in de ruimte draait een oudere Cliqer-versie die geen opdrachten van de API kan aannemen |
api_not_in_plan | Het abonnement van de eigenaar van de ruimte bevat de Client API niet |
Fouten bevatten een leesbaar message en, als de opdracht correct was opgebouwd, de action en id van de opdracht:
{ "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 van een host bevatten de id van die host in hostId:
| Event | Payload |
|---|---|
clicksEnabled | true wanneer de host je opdrachten aanneemt, false wanneer de operator je clicks heeft uitgeschakeld |
controls | { app, nav, blackout, timer }: de presentatieapp, de extra dia-opdrachten die deze ondersteunt (first, last, goto, blackout), of het scherm zwart is en of de host timeropdrachten aanneemt |
slide | { number, current, total, title, notes } na elke opdracht die de host heeft uitgevoerd (number is "4/12", total is null als de app het niet doorgeeft), of null als de host geen presentatie geopend heeft |
countdown | De tijd die de timer van de host toont, zoals "4:59" ("-0:12" bij overtijd), elke seconde zolang hij loopt, of null wanneer hij stopt |
countdownPhase | { phase, blink }: phase is idle, talk, warning (minder dan een minuut over), overtime of paused, en blink geeft aan of de timer van de host knippert |
messageFromHost | Een antwoord van de hostoperator, zie Berichten aan de host |
Je ontvangt ook de eigen events van de ruimte (clientJoined, clientLeft en andere); negeer de events die je niet gebruikt. Als de operator je verbinding verwijdert of blokkeert, ontvang je logout met reason: "kicked".
Timer
Bedien de timer in de Cliqer-app van de host met dezelfde opdrachten die een Stream Deck of Bitfocus Companion stuurt.
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | Wat het doet |
|---|---|
start | De timer starten, of verdergaan na stop |
stop | De timer pauzeren |
reset | De timer stoppen en terugzetten naar de ingestelde tijd |
plus1, plus2, plus5 | 1, 2 of 5 minuten toevoegen |
minus1, minus2, minus5 | 1, 2 of 5 minuten aftrekken |
preset5, preset10, preset15, preset20 | De timer stoppen en 5, 10, 15 of 20 minuten instellen |
set | De timer stoppen en minutes instellen, een geheel getal van 1 tot en met 999 |
- Elke opdracht wordt beantwoord met een
timerActionResult({ action, id, hosts },hostszoals bij dia's) of eenerror. Het bewijs dat de opdracht is uitgevoerd, is het volgendecountdown- ofcountdownPhase-event van de host. - De timer werkt alleen op een host waarvan het laatste
controls-eventtimer: truebevat. Hosts met een oudere Cliqer-versie melden dit niet en negeren timeropdrachten. - Voor timeropdrachten gelden dezelfde regels als voor dia-opdrachten: de host voert ze alleen uit zolang hij clicks van je verbinding toestaat, en ze delen het budget van 10 opdrachten per seconde.
- De timerinstellingen van de host blijven gewoon gelden: een timer die is ingesteld om op te tellen, telt op, en een timer die een verbonden hardwaretimer volgt, neemt zijn tijd over van dat apparaat.
Laserpointer
Toon een laserstip op het gedeelde scherm van de host, zoals de telefoon van een presentator dat doet.
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xenyzijn de positie op het gedeelde scherm, gemeten vanaf de linkerbovenhoek, van0tot en met1.- Stuur
{ "pressed": false }als payload om de stip te verbergen. Anders verdwijnt hij 30 tot 90 seconden na je laatste positie. - De stip is alleen zichtbaar zolang de host een scherm deelt en Laserpointer heeft ingeschakeld. De kleur hangt af van je
presenterName. - Stuur maximaal 30 posities per seconde. Extra posities worden zonder foutmelding genegeerd en er is geen resultaatevent.
Berichten aan de host
Stuur een bericht naar de hostoperator. Het verschijnt in de presentatorchat van de host, net als een bericht van de telefoon van een presentator, en de antwoorden van de operator komen terug als 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"]),
Antwoord van de host:
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
textbevat 1 tot 1000 tekens. Stuur maximaal 5 berichten per 10 seconden.- Elk bericht wordt beantwoord met een
messageToHostsResult({ id, hosts }) of eenerror. - De operator ziet het bericht onder je
presenterName, en de host toont een melding als de instelling Meldingen aan staat.
Overzicht van events
Uitgaande events (client → server)
| Event | Beschrijving |
|---|---|
joinRoom | Als API-client deelnemen aan een ruimte met api: true (zie Dianavigatie) |
webAction | Diabediening (zie Dianavigatie) |
timerAction | Timerbediening (zie Timer) |
presenterMouse | Positie van de laserpointer (zie Laserpointer) |
messageToHosts | Een bericht naar de hostoperator sturen (zie Berichten aan de host) |
logout | De ruimte verlaten; de verbinding blijft open en kan opnieuw deelnemen |
Inkomende events (server → client)
| Event | Beschrijving |
|---|---|
connected | Verzonden bij het openen, met { clientId, userId, isAuthenticated } |
joinedRoom | Succesvol deelgenomen aan de ruimte |
clientJoined | Iemand is de ruimte binnengekomen |
clientLeft | Iemand heeft de ruimte verlaten |
webActionResult | Een webAction is afgeleverd bij de hosts van de ruimte |
timerActionResult | Een timerAction is afgeleverd bij de hosts van de ruimte |
messageToHostsResult | Een messageToHosts is afgeleverd bij de hosts van de ruimte |
clicksEnabled | Of een host je opdrachten aanneemt |
controls | Wat de presentatieapp en de timer van een host ondersteunen |
slide | De huidige dia van een host na een opdracht |
countdown | De tijd op de timer van een host |
countdownPhase | De fase van de timer van een host |
messageFromHost | Een bericht van de hostoperator |
logout | De operator heeft je verbinding verwijderd of geblokkeerd (reason: "kicked") |
error | Foutmelding |