Interactive livestream
Polls, Q&A, reactions, hand-raise queue, promote-to-host, spotlight pin, brand overlays. The full toolkit for audience engagement during a live broadcast — server-persisted so state survives reconnects.
/app/streams/<streamId> → Interactive tab. Every operation below is also a button there.
On this page
1. Mental model
Every interactive primitive has the same shape: a REST endpoint that mutates state on the Kardolive side + a webhook event that fires so your app can react + a matching chat-WS data channel that delivers the change instantly to all connected clients in the room.
┌──────────────┐ POST /v1/me/interactive/... ┌──────────────┐
│ Your backend │ ─────────────────────────────────> │ Kardolive │
└──────────────┘ │ control API │
└──────┬───────┘
│ persist
▼
┌──────────────────────┐
│ Postgres (durable) │
└──────┬───────────────┘
│
┌─────────────────────────────┴─────────────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Webhook fanout │ │ Chat WS data │
│ → your backend │ │ channel → ALL │
│ (durable, │ │ connected │
│ HMAC-signed) │ │ clients (live) │
└────────────────┘ └────────────────┘
This means your audience sees updates instantly (chat WS), your backend records them durably (webhook), and your dashboards can query the database directly (REST).
2. Polls
Multi-option polls (2–8 options). Single-select or multi-select. Auto-close by timer or manual.
Create a poll
POST https://api.kardocloud.com/v1/me/interactive/polls
Authorization: Bearer kl_live_xxx
{
"streamId": "stream_abc", // optional — scope to one stream
"question": "Which color should we ship first?",
"options": ["Black", "White", "Olive", "Cream"],
"multiSelect": false,
"endsAt": "2026-12-01T18:30:00.000Z" // optional, auto-close
}
→ { "id": "poll_xxx", "status": "open", "options": [...], "createdAt": "..." }
Vote
POST /v1/me/interactive/polls/poll_xxx/vote
{
"voterId": "user_42", // your stable identifier
"voterName": "Maya", // optional, shown in dashboards
"optionIdxs": [2] // array — supports multi-select polls
}
→ { ...poll, "counts": [3, 8, 5, 1], "totalVotes": 17 }
Votes are idempotent — re-voting with the same (pollId, voterId, optionIdx) tuple is a no-op.
Get current results
GET /v1/me/interactive/polls/poll_xxx
→ {
"id": "poll_xxx",
"question": "...",
"options": ["Black","White","Olive","Cream"],
"counts": [3, 8, 5, 1],
"totalVotes": 17,
"status": "open"
}
Close the poll
POST /v1/me/interactive/polls/poll_xxx/close
→ { ...poll, "status": "closed", "closedAt": "..." }
Closes can also fire automatically when endsAt is reached (checked by the scheduler).
3. Q&A queue
Audience asks questions. Other audience members upvote the ones they want answered. Host marks one "live" while answering it. Host marks it "answered" when done.
Ask a question
POST /v1/me/interactive/qa
{
"streamId": "stream_abc",
"body": "When will the EU region launch?",
"askerId": "user_42",
"askerName": "Maya"
}
→ { "id": "q_xxx", "status": "open", "upvotes": 0, "body": "...", "createdAt": "..." }
List questions (sorted by upvotes desc, then time asc)
GET /v1/me/interactive/qa?streamId=stream_abc&status=open
→ [ {q_1, upvotes:23}, {q_2, upvotes:11}, ... ]
Statuses: open (in queue) · live (host is answering right now) · answered · dismissed.
Upvote
POST /v1/me/interactive/qa/q_xxx/upvote
{ "voterId": "user_42" }
Per-voter idempotent — same user upvoting twice does nothing.
Host actions
POST /v1/me/interactive/qa/q_xxx/mark-live // I'm answering this now
POST /v1/me/interactive/qa/q_xxx/answer
{ "answerText": "Q4 2026, after the AU launch." } // written answer (optional; live verbal answer is fine too)
POST /v1/me/interactive/qa/q_xxx/dismiss // off-topic, abusive, etc.
Only ONE question can be live at a time per stream. Marking a new one live auto-flips the previous one back to open.
4. Reactions & engagement graphs
Typed counter system bucketed by minute, so you can graph engagement over time. Use it for hearts, claps, fire, wow, custom emoji — anything that's "fire and forget" with no individual voter identity.
Fire a reaction (or batch)
POST /v1/me/interactive/reactions
{
"streamId": "stream_abc",
"kind": "heart", // free string; group by emoji or named kind
"count": 5 // optional, default 1; batch up to 50/req
}
→ { "ok": true, "count": 5 }
For high-frequency UIs (TikTok-style tap-spam), throttle client-side and batch — e.g. accumulate taps for 250 ms then send a single {count: N} request.
Get totals + per-minute series
GET /v1/me/interactive/reactions/stream_abc
→ {
"streamId": "stream_abc",
"byKind": { "heart": 1843, "clap": 412, "fire": 98 },
"total": 2353,
"series": [
{ "ts": "2026-12-01T18:05:00.000Z", "kind": "heart", "count": 32 },
{ "ts": "2026-12-01T18:05:00.000Z", "kind": "clap", "count": 11 },
{ "ts": "2026-12-01T18:06:00.000Z", "kind": "heart", "count": 48 }
]
}
5. Hand-raise queue
Audience members can request to join the stream as a publisher. Hosts see an ordered queue and pick who comes up.
Raise hand (from audience client)
POST /v1/me/interactive/hand-raises
{
"streamId": "stream_abc",
"raiserId": "user_42",
"raiserName": "Maya",
"note": "I have a follow-up on the EU question" // optional
}
→ { "id": "hr_xxx", "status": "queued", "position": 3, ... }
Host: see queue
GET /v1/me/interactive/hand-raises/stream_abc
→ [ { id, raiserName, note, status, position }, ... ]
Host: promote (or dismiss)
POST /v1/me/interactive/hand-raises/hr_xxx/promote
POST /v1/me/interactive/hand-raises/hr_xxx/lower
Promoting only changes the queue status — to actually let them publish, follow up with Promote to publisher below.
6. Promote audience member to publisher
Mints an SFU JWT with canPublish: true scoped to the stream and to a
specific external user. Hand that token to their client, they get to publish camera /
mic / screen-share into the room.
POST /v1/me/interactive/streams/stream_abc/promote
{
"externalUserId": "user_42",
"displayName": "Maya",
"ttlSec": 3600 // optional, default 1 hour, max 8 hours
}
→ {
"token": "<short-lived JWT>",
"expiresIn": 3600,
"sfuUrl": "wss://sfu.kardocloud.com",
"role": "guest",
"canPublish": true
}
The guest client connects to sfuUrl with this token and can publish — just like a host. When the host wants to remove them, they revoke by ending the session or simply don't grant the next token rotation.
7. Spotlight pin + brand overlay
Hosts pin a participant so every viewer sees them as the primary speaker. Also configures layout, a brand logo overlay, and a lower-third caption.
POST /v1/me/interactive/spotlight/stream_abc
{
"pinnedPeerId": "peer_xxx", // SFU peer id to pin (null = unpin)
"pinnedName": "Maya",
"layout": "speaker", // auto | grid | speaker | pip | full-screen
"brandLogoUrl": "https://your-cdn.example.com/your-logo.png",
"brandPosition": "bottom-right", // top-left | top-right | bottom-left | bottom-right
"lowerThirdText": "Maya · Product Lead"
}
→ { ...spotlight, "updatedAt": "..." }
The spotlight state is delivered to every connected client via the chat WS data channel spotlight, so player UIs update instantly without polling.
GET /v1/me/interactive/spotlight/stream_abc
→ { pinnedPeerId, pinnedName, layout, brandLogoUrl, brandPosition, lowerThirdText, updatedAt }
Clients should listen for the spotlight data channel and apply changes immediately to the video layout.
8. Webhook events
| Event | When |
|---|---|
poll.created | Host launched a poll |
poll.closed | Manual or auto-close; final counts in payload |
qa.asked | Audience asked a question |
qa.answered | Host marked question answered |
handraise.queued | Audience raised hand |
handraise.promoted | Host promoted from queue |
spotlight.updated | Layout, pin, or overlay changed |
9. Real-time delivery to clients
Every state change is also broadcast over the chat WebSocket as a typed data channel — connected clients receive it without polling.
// Connect to chat WS, then:
chat.onData('poll-created', (poll) => renderPoll(poll));
chat.onData('poll-vote', (delta) => bumpCount(delta.pollId, delta.optionIdx));
chat.onData('poll-closed', (poll) => lockPoll(poll));
chat.onData('qa-asked', (q) => addQuestion(q));
chat.onData('qa-upvote', ({ id, upvotes }) => updateUpvotes(id, upvotes));
chat.onData('qa-live', (q) => markLive(q));
chat.onData('qa-answered', (q) => markAnswered(q));
chat.onData('reaction', ({ kind, count }) => floatReaction(kind, count));
chat.onData('handraise', (hr) => queueHand(hr));
chat.onData('handraise-promoted', (hr) => removeFromQueue(hr.id));
chat.onData('spotlight', (s) => applyLayout(s));
Next
Chat WebSocket protocol → · Live streaming basics → · Webhook signature verification →