SDKs

JavaScript Headless SDK

TypeScript SDK for the HotMic public API. Headless — no UI, no HLS player. Wraps auth, stream listing, the stream session lifecycle, and the delta-polling loop. Runs in browser, Node 18+, and React Native.

@hotmic/sdk is the headless TypeScript client for the HotMic public API. It runs in modern browsers, Node 18+, and React Native. It wraps everything a viewer client needs — auth, stream listing, joining a stream, the 3-second delta-poll loop, presence, and all REST send actions for chat, reactions, and polls.

It does not ship a video player or any UI. You get stream.hls_url and feed it to <video> or hls.js yourself. For a HotMic-hosted player URL with no integration work, use the JavaScript Player SDK instead.

What's wrapped

  • Auth and token management (anonymous + email/password)
  • Stream listing (listStreams) and metadata (getStream)
  • Communities (getCommunity)
  • A StreamSession that runs the 3-second delta-poll loop and emits typed events for chat, reactions, polls, presence, and stream state changes
  • Send actions: send chat, react, moderate, answer polls, peer-block, create battle question

What's not wrapped

  • A video player (you get stream.hls_url — feed it to <video> or hls.js)
  • UI of any kind
  • Apple IAP / Google Play tips, paid join, cameo (inherently UI-coupled)
  • Battle-question voting (voteForContestant, upvote/downvote/remove vote) — the Swift SDK wraps these; the TS port hasn't landed yet
  • Tokens (packages / purchase / balance / transactions) — Swift-only today
  • Push notifications, webhooks (server-side; use Svix if you need them)
  • Password reset / token refresh (handle in your own auth layer)

Install

sh
npm install @hotmic/sdk

Requires Node 18+ (or any environment with global fetch). For older Node, pass a fetchImpl in the client config (for example, node-fetch).

Quickstart — anonymous viewer

The fastest path: one client, one session, no login required.

ts
import { HotMicClient } from "@hotmic/sdk";

const client = new HotMicClient({
  apiKey: "your-api-key",
  baseUrl: "https://api.hotmic.io",
  deviceId: getOrCreateStableDeviceId(),
});

const session = await client.joinStream(streamId);

session.on("streamUpdated", (stream) => {
  console.log("title:", stream.title, "viewers:", stream.viewers);
  // stream.hls_url is what you feed to <video> or hls.js
});

session.on("chat", (chat) => {
  console.log(`${chat.user_name}: ${chat.message}`);
});

await session.sendChat("hello world");

// When the user leaves:
await session.end();

The session creates an anonymous user on the server, gets a JWT back, and uses it for every subsequent call — including sendChat. No login required to chat, react, or vote in polls.

Configuration

ts
new HotMicClient({
  apiKey: string,             // required — provisioned by HotMic
  baseUrl: string,            // required — "https://api.hotmic.io"
  deviceId: string,           // required — stable per install (UUID)
  platform?: string,          // default "web"
  sdkVersion?: string,        // default "0.1.0"
  osVersion?: string,         // optional, populates hm-os-version header
  deviceModel?: string,       // optional, populates hm-device-model header
  accessToken?: string,       // optional — pre-existing JWT (logged-in flow)
  fetchImpl?: typeof fetch,   // optional — for environments without global fetch
});

deviceId should be persisted across launches (localStorage on the web, AsyncStorage on React Native). The server uses it for moderation and rate-limiting heuristics.

ts
function getOrCreateStableDeviceId(): string {
  let id = localStorage.getItem("hm.deviceId");
  if (!id) {
    id = crypto.randomUUID();
    localStorage.setItem("hm.deviceId", id);
  }
  return id;
}

Authentication

Two flows, your choice.

Anonymous (default)

Don't pass accessToken. The first client.joinStream(...) call mints a JWT for an anonymous user; the SDK stores it and uses it for everything else in that session. The user can chat, react, and vote — the only thing they can't do is reach data tied to a specific user identity.

Authenticated

Call signIn or signUp before joining a stream:

ts
const auth = await client.signIn({ email, password });
// SDK now uses auth.access_token automatically.
ts
const auth = await client.signUp({
  name: "Pat",
  email: "pat@example.com",
  password: "...",
});

You can also pre-seed an existing token (for example, from your own auth backend that exchanges your session cookie for a HotMic JWT):

ts
new HotMicClient({ ..., accessToken: existingJwt });
// or later:
client.setAccessToken(existingJwt);

There is no SDK-level password reset or refresh in v1. Handle expired tokens by catching HotMicError with status === 401/403 and re-running the sign-in flow.

Listing streams

ts
const live      = await client.listStreams({ state: "LIVE",      limit: 25 });
const scheduled = await client.listStreams({ state: "SCHEDULED", limit: 25 });
const past      = await client.listStreams({ state: "VOD",       limit: 25 });

Returns Stream[]. Most-useful fields: _id, state, title, user.display_name, viewers, hls_url, vod_url.

getStream for a single stream's metadata without joining:

ts
const stream = await client.getStream(streamId);

Communities

client.getCommunity(communityId) fetches a community by id. Public endpoint — works with or without an access token. When the SDK has a JWT, the response includes is_joined for that user.

ts
const community = await client.getCommunity(communityId);
// { _id, name, topic, description, tone, thumbnail_image, emotes,
//   member_count, stream_count, is_joined?, config: { questionGen: { ... } } }

Joining a stream

client.joinStream(streamId) returns a StreamSession:

  1. Calls POST /streams/{id}/session/ (anonymous unless you've set a token).
  2. Backfills chat / tip / poll / reaction history via GET /streams/{id}/feed/. The reaction snapshot keeps the viewer's local state in sync when they leave and come back — any reaction that was deleted while they were away simply won't be in the feed response.
  3. Starts the 3-second delta-poll loop.
  4. Starts presence timers (POST /presence every ~30s, GET .../viewers every ~10s).
  5. Begins emitting events.
ts
const session = await client.joinStream(streamId);
console.log("HLS:", session.stream?.hls_url);
console.log("you:", session.currentUser?.display_name);

Events

session.on(event, handler) returns an unsubscribe function. Handlers run synchronously on the SDK's tick — keep them light.

| Event | Payload | Fired when | |---|---|---| | chat | Chat | A new chat appears in delta.chats | | chatDeleted | Chat | A chat appears in delta.deleted_chats | | reaction | Reaction | A reaction appears in delta.chats_reactions | | reactionRemoved | Reaction | A reaction appears in delta.deleted_reactions | | tip | Tip | A tip appears in delta.tips | | tipDeleted | Tip | A tip appears in delta.deleted_tips | | streamUpdated | Stream | Every poll cycle, regardless of changes | | viewers | Participant[] | Each presence refresh tick | | ended | void | Server sets stream.end_video = true (fired once) | | deleted | void | Server sets stream.deleted = true (fired once) | | error | unknown | Any background HTTP error from the poll/presence loops |

ts
const off = session.on("chat", (chat) => renderChatRow(chat));
// Later:
off();  // Unsubscribe

The session also exposes the current state for synchronous reads:

ts
session.stream;        // current Stream snapshot or undefined
session.chats;         // sorted ascending by created_at, _id as stable tiebreaker
session.reactions;     // seeded from /feed on cold-start, then kept in sync
session.currentUser;   // User object the server assigned (anonymous or not)

Send actions

All return Promise<void> and throw HotMicError on failure.

ts
await session.sendChat("hello world");
await session.react(chatId, "fire");          // like | fire | laugh | anger | sadness
await session.removeReaction(chatId, "fire");
await session.deleteMessage(chatId);          // sender, mod, or broadcaster only
await session.reportMessage(chatId);
await session.blockUserInChat(userId);        // mod/broadcaster only
await session.submitPollAnswer(pollId, optionId);

// Audience Q&A — create a new question on this session's stream.
// Returns the persisted Question with a server-assigned `_id`.
const q = await session.createQuestion("Who wins this round?");

Lifecycle

ts
await session.end();  // POSTs /end_session/ with watched-ms, stops timers

After end(), the session is dead — events stop firing, send actions throw. Create a new session by calling client.joinStream(...) again.

Video playback

The SDK gives you a URL; bring your own player.

Native <video> (Safari, iOS Safari)

ts
session.on("streamUpdated", (stream) => {
  if (stream.hls_url && video.src !== stream.hls_url) {
    video.src = stream.hls_url;
    video.play().catch(() => {});
  }
});

hls.js (Chrome, Firefox, Edge)

ts
import Hls from "hls.js";

let hls: Hls | undefined;

session.on("streamUpdated", (stream) => {
  const url = stream.hls_url ?? stream.vod_url;
  if (!url) return;
  if (video.canPlayType("application/vnd.apple.mpegurl")) {
    if (video.src !== url) { video.src = url; video.play().catch(() => {}); }
    return;
  }
  if (!Hls.isSupported()) return;
  if (!hls) { hls = new Hls(); hls.attachMedia(video); }
  hls.loadSource(url);
});

session.on("ended", () => { hls?.destroy(); hls = undefined; video.pause(); });

State changes during a session

stream.hls_url can change mid-stream (for example, when the broadcaster goes live after a SCHEDULED state, or when a stream transitions LIVE → VOD). Compare against the current <video> src on every streamUpdated and reattach if different.

stream.end_video === true or stream.deleted === true mean stop playback — the SDK fires ended / deleted events for these so you can react in one place.

Error handling

All HTTP failures throw HotMicError:

ts
import { HotMicError } from "@hotmic/sdk";

try {
  await session.sendChat("hi");
} catch (err) {
  if (err instanceof HotMicError) {
    if (err.status === 401) { /* re-auth */ }
    if (err.status === 429) { /* you're rate-limited */ }
    console.error(err.status, err.body);
  } else {
    throw err;
  }
}

Background errors (failures inside the polling loop or presence timers) surface via the "error" event. Don't throw from a handler — it's caught and logged, but the loop keeps going.

ts
session.on("error", (err) => {
  console.warn("[hotmic]", err);
});

Polling rule

GET /streams/{id}/poll-data is called every 3 seconds with the to query param floored to the nearest whole second (Math.floor(Date.now() / 1000) * 1000).

The server's delta cache is keyed on second boundaries, so all viewers hitting the same second share one cache lookup. This is enforced inside StreamPollManager and is not configurable — it's a hard contract that all HotMic SDKs (TypeScript, Swift, Kotlin) implement identically.

TypeScript types

Every wire shape is exported from the entry module:

ts
import type {
  Stream, StreamSession, StreamSessionResponse,
  Chat, Reaction, Poll, Tip, User, Participant,
  ReactionType, StreamState,
  PollDataResponse, PollDataDelta,
  Community, EmojiPaletteEntry,
  Question, CreateQuestionBody,
  AuthResponse, ListStreamsResponse,
} from "@hotmic/sdk";

Field naming follows the wire format (snake_case) — for example, chat.user_name, stream.hls_url. This keeps the SDK lean and avoids a translation layer that would drift from the server. If you want camelCase in your app code, adapt at the boundary.

Feature coverage

| Feature | API | |---|---| | Sign in / sign up | client.signIn, client.signUp | | List streams | client.listStreams | | Get stream metadata | client.getStream | | Get community | client.getCommunity | | Create viewer session | client.joinStream | | End session | session.end | | Live chat / reactions / polls | session.on('chat' \| 'reaction' \| ...) | | Send chat | session.sendChat | | React to chat | session.react, session.removeReaction | | Delete / report chat | session.deleteMessage, session.reportMessage | | Block user in chat | session.blockUserInChat | | Submit poll answer | session.submitPollAnswer | | Create battle question | session.createQuestion | | Presence (heartbeat + viewer list) | Automatic via joinStream | | Anonymous viewing | Automatic — no accessToken needed |