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
StreamSessionthat 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
npm install @hotmic/sdkRequires 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.
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
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.
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:
const auth = await client.signIn({ email, password });
// SDK now uses auth.access_token automatically.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):
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
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:
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.
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:
- Calls
POST /streams/{id}/session/(anonymous unless you've set a token). - 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. - Starts the 3-second delta-poll loop.
- Starts presence timers (
POST /presenceevery ~30s,GET .../viewersevery ~10s). - Begins emitting events.
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 |
const off = session.on("chat", (chat) => renderChatRow(chat));
// Later:
off(); // UnsubscribeThe session also exposes the current state for synchronous reads:
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.
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
await session.end(); // POSTs /end_session/ with watched-ms, stops timersAfter 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)
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)
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:
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.
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:
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 |