Core API

Interactions

Relay lets you broadcast real-time interactions — reactions, chat messages, hand raises, polls, and custom events — to participants inside a live hall. Interactions are delivered over LiveKit's data channels and received by clients in milliseconds without any additional WebSocket infrastructure.

Interactions require an active hall. If the hall is closed or not yet started, the API returns 409 hall_not_active.

How it works

When you call the interactions endpoint, Relay sends a typed JSON envelope to every participant in the hall (or a subset you specify) via LiveKit data channels. Your frontend SDK listens for these envelopes and renders the appropriate UI — no separate WebSocket connection needed.

Participants can also send interactions directly from the client using the LiveKit JS SDK, since all join tokens include can_publish_data: true. The server-side endpoint is for interactions you want to send from your backend — polls, system announcements, moderation actions, and so on.

Send an interaction

POST/v1/halls/{hall_id}/interactions

Request body

typestringrequired

Interaction type. Built-in types: reaction, chat, hand_raise, poll, custom. Any string is accepted — unknown types are forwarded as-is to clients.

payloadobjectrequired

Arbitrary JSON payload. Shape is defined by the interaction type. See examples below.

sender_idstring

Participant ID of the user initiating this interaction. Included in the broadcast envelope so recipients know who sent it. Omit for server-initiated events.

tostring[]

Target participant IDs. Omit or pass null to broadcast to all participants in the hall.

bash
1
2
3
4
5
6
7
8
curl -X POST https://api.relay.dev/v1/halls/h_01j9x2kp3n/interactions \
  -H "Authorization: Bearer rk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "reaction",
    "payload": { "emoji": "👍", "x": 0.42, "y": 0.75 },
    "sender_id": "user_abc123"
  }'

Response

json
1
2
3
4
5
6
7
{
  "sent": true,
  "type": "reaction",
  "broadcast": true,
  "recipient_count": null,
  "timestamp": "2025-04-05T10:00:00.000Z"
}

recipient_count is null for broadcasts (all participants) and a number when to is specified.

Interaction types

Reaction

Floating emoji or thumbs-up overlay. Broadcast to all participants.

json
1
2
3
4
5
6
7
8
9
{
  "type": "reaction",
  "payload": {
    "emoji": "🔥",
    "x": 0.42,
    "y": 0.75
  },
  "sender_id": "user_abc123"
}

Chat message

In-hall text chat. Broadcast to all or targeted at specific participants.

json
1
2
3
4
5
6
7
8
{
  "type": "chat",
  "payload": {
    "text": "Great presentation!",
    "display_name": "Alice"
  },
  "sender_id": "user_abc123"
}

Hand raise

Signals that a participant wants to speak. The host can lower it by sending the same event with raised: false.

json
1
2
3
4
5
6
7
{
  "type": "hand_raise",
  "payload": {
    "raised": true
  },
  "sender_id": "user_abc123"
}

Poll

Server-initiated polls. Send the question and options from your backend; collect responses via a poll_response interaction sent from clients.

json
1
2
3
4
5
6
7
8
9
{
  "type": "poll",
  "payload": {
    "poll_id": "poll_001",
    "question": "How are you finding the session so far?",
    "options": ["Great", "Good", "Could be better"],
    "duration_seconds": 30
  }
}

Custom

Use custom (or any string type) for application-specific events. Relay forwards the payload as-is.

json
1
2
3
4
5
6
7
{
  "type": "scene_change",
  "payload": {
    "scene": "breakout",
    "breakout_room_id": "br_42"
  }
}

The interaction envelope

Regardless of how an interaction originates (server API or client SDK), every message delivered to participants shares the same envelope format:

json
1
2
3
4
5
6
7
8
9
10
11
{
  "relay_version": "1",
  "type": "reaction",
  "sender_id": "user_abc123",
  "timestamp": "2025-04-05T10:00:00.000Z",
  "payload": {
    "emoji": "👍",
    "x": 0.42,
    "y": 0.75
  }
}
relay_version

Protocol version. Currently "1".

type

The interaction type string.

sender_id

Participant ID of the sender. null for server-initiated events.

timestamp

ISO 8601 UTC timestamp of when the interaction was sent.

payload

The interaction payload as provided in the request.

Receiving interactions on the client

Use the LiveKit JS SDK to listen for incoming data messages in your frontend. Parse the envelope and dispatch based on the type field.

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Room, RoomEvent } from "livekit-client";

const room = new Room();
await room.connect(livekitUrl, joinToken);

room.on(RoomEvent.DataReceived, (data: Uint8Array, participant) => {
  const envelope = JSON.parse(new TextDecoder().decode(data));

  switch (envelope.type) {
    case "reaction":
      showReaction(envelope.payload.emoji, envelope.payload.x, envelope.payload.y);
      break;
    case "chat":
      appendChatMessage(envelope.sender_id, envelope.payload.text);
      break;
    case "hand_raise":
      updateHandRaise(envelope.sender_id, envelope.payload.raised);
      break;
    case "poll":
      showPoll(envelope.payload);
      break;
    default:
      console.log("custom interaction", envelope.type, envelope.payload);
  }
});

Sending interactions from the client

All join tokens include can_publish_data: true, so participants can also send interactions directly from the browser without going through your backend. Use the same envelope format so your existing handler works for both server and client-originated events.

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const encoder = new TextEncoder();

function sendReaction(room: Room, emoji: string) {
  const envelope = {
    relay_version: "1",
    type: "reaction",
    sender_id: room.localParticipant.identity,
    timestamp: new Date().toISOString(),
    payload: { emoji, x: Math.random(), y: Math.random() },
  };

  room.localParticipant.publishData(
    encoder.encode(JSON.stringify(envelope)),
    { reliable: true }
  );
}
Use reliable delivery for chat and polls (guaranteed ordering), and lossy delivery for high-frequency events like cursor positions or live reaction streams where dropping occasional frames is acceptable.