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.

No-code option: Manage live polls, Q&A, reactions, hand-raises and promote-to-host from the customer portal at /app/streams/<streamId>Interactive tab. Every operation below is also a button there.

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

EventWhen
poll.createdHost launched a poll
poll.closedManual or auto-close; final counts in payload
qa.askedAudience asked a question
qa.answeredHost marked question answered
handraise.queuedAudience raised hand
handraise.promotedHost promoted from queue
spotlight.updatedLayout, 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 →