SDKs
iOS Headless SDK
Swift SDK for the HotMic public API. Headless — no UI, no AVPlayer integration. Wraps auth, stream listing, the stream session lifecycle, the delta-polling loop, and in-app token currency. iOS 15+, macOS 12+, tvOS 15+, watchOS 8+.
HotMicSDK is the headless Swift client for the HotMic public API. It runs on iOS 15+, macOS 12+, tvOS 15+, and watchOS 8+. 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, polls, battle questions, and in-app token currency.
It does not ship a UI or AVPlayer integration. You get stream.hlsUrl and feed it to AVPlayer / Bitmovin / etc. yourself. For HotMic's full player UI (chat, polls, tipping all built-in), use the iOS Player SDK instead.
What's wrapped
- Auth and token management (anonymous + email/password)
- Stream listing (
listStreams) and metadata (getStream) - Communities (
getCommunity) - In-app currency — list / purchase (via Apple StoreKit 2 JWS) / balance / transactions
- A
StreamSessionactor that runs the 3-second delta-poll loop and emits events for chat, reactions, polls, presence, and stream state changes via assignable@Sendableclosures - Send actions: send chat, react, moderate, answer polls, peer-block, create / list / upvote-downvote battle questions, vote for a battle contestant by emoji
What's not wrapped
AVPlayer(you getstream.hlsUrl— feed it toAVPlayer/ Bitmovin / etc.)- UI of any kind (no view controllers, no SwiftUI views)
- Apple IAP tips to creators (separate from the in-app currency purchase flow that IS wrapped — tipping ties an amount directly to a stream / user and is inherently UI-coupled)
- Paid join / cameo, OpenTok A/V guest joining
- Push notifications, password reset / token refresh
Install
Swift Package Manager:
// Stable — pinned to a tagged release
.package(url: "https://github.com/hotmic-wp/hotmic-sdk-swift", from: "0.1.0")
// Dev channel — always the latest commit on the monorepo's `dev` branch.
.package(url: "https://github.com/hotmic-wp/hotmic-sdk-swift", branch: "dev")In Xcode, use File → Add Package Dependencies with the same URL. To track the dev branch, set Dependency Rule to Branch and enter dev.
Requires Swift 5.9+ for Sendable and async/await ergonomics.
Quickstart — anonymous viewer
import HotMicSDK
let client = HotMicClient(config: .init(
apiKey: "your-api-key",
baseURL: URL(string: "https://api.hotmic.io")!,
deviceID: stableDeviceID()
))
let session = try await client.joinStream(streamID: streamID)
await session.setOnStreamUpdated { stream in
print("title:", stream.title ?? "", "viewers:", stream.viewers ?? 0)
// stream.hlsUrl is what you feed to AVPlayer
}
await session.setOnChat { chat in
print("\(chat.userName ?? "anon"): \(chat.message ?? "")")
}
try 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
HotMicClient(config: HotMicClientConfig(
apiKey: String, // required — provisioned by HotMic
baseURL: URL, // required — typically api.hotmic.io
deviceID: String, // required — stable per install (UUID)
platform: String = "ios", // populates hm-platform header
sdkVersion: String = "0.1.0",
osVersion: String? = nil, // populates hm-os-version
deviceModel: String? = nil, // populates hm-device-model
accessToken: String? = nil // pre-existing JWT (logged-in flow)
))deviceID should be persisted across launches — the server uses it for moderation and rate-limiting. A simple UserDefaults-backed UUID is fine:
import Foundation
func stableDeviceID() -> String {
if let existing = UserDefaults.standard.string(forKey: "hm.deviceID") {
return existing
}
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: "hm.deviceID")
return id
}For higher persistence (survives app reinstall) use the Keychain.
Authentication
Anonymous (default)
Don't pass accessToken. The first client.joinStream(...) mints a JWT for an anonymous user; the SDK stores it and uses it for everything else in that session.
Authenticated
let auth = try await client.signIn(email: "pat@example.com", password: "...")
// SDK now uses auth.accessToken automatically.
let auth = try await client.signUp(
name: "Pat",
email: "pat@example.com",
password: "..."
)Pre-seed an existing token (for example, from your own auth backend that exchanges your session cookie for a HotMic JWT):
let client = HotMicClient(config: .init(
apiKey: "...",
baseURL: ...,
deviceID: ...,
accessToken: existingJwt
))
// or later:
await client.setAccessToken(existingJwt)There's no SDK-level password reset or refresh in v1. On HotMicError with status == 401/403, surface to your auth layer and re-run sign-in.
Listing streams
let live = try await client.listStreams(.init(state: "LIVE", limit: 25))
let scheduled = try await client.listStreams(.init(state: "SCHEDULED", limit: 25))
let past = try await client.listStreams(.init(state: "VOD", limit: 25))Returns [HMStream]. Most-useful fields: id, state, title, user?.displayName, viewers, hlsUrl, vodUrl.
getStream for a single stream's metadata:
let stream = try await client.getStream(streamID: streamID)The SDK type is named HMStream (not Stream) to avoid a name collision with Foundation's NSStream (Swift name Stream). Same convention as the existing HotMicMediaPlayer SDK.
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 isJoined for that user.
let community = try await client.getCommunity(communityID: communityID)
// Community(id, name, topic, description, tone, thumbnailImage, emotes,
// memberCount, streamCount, isJoined?, config: CommunityConfig?)Joining a stream
client.joinStream(streamID:) returns a StreamSession actor:
- 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. - Starts the 3-second delta-poll loop.
- Starts presence timers (
POST /presenceevery ~30s,GET .../viewersevery ~10s). - Begins firing event handlers.
let session = try await client.joinStream(streamID: streamID)
let stream = await session.currentStream()
let user = await session.user()Events
The session is an actor, so handlers must be @Sendable and you set them via setOn... async methods:
await session.setOnChat { chat in
Task { @MainActor in
// marshal back to the main thread before touching UI
chatTableView.append(chat)
}
}
await session.setOnStreamUpdated { [weak viewModel] stream in
Task { @MainActor in viewModel?.update(with: stream) }
}
await session.setOnEnded {
Task { @MainActor in dismissPlayer() }
}| Setter | Payload | Fired when |
|---|---|---|
| setOnChat | Chat | A new chat appears in delta.chats |
| setOnChatDeleted | Chat | A chat appears in delta.deleted_chats |
| setOnReaction | Reaction | A reaction appears in delta.chats_reactions |
| setOnReactionRemoved | Reaction | A reaction appears in delta.deleted_reactions |
| setOnTip | Tip | A tip appears in delta.tips |
| setOnTipDeleted | Tip | A tip appears in delta.deleted_tips |
| setOnStreamUpdated | HMStream | Every poll cycle |
| setOnViewers | [Participant] | Each presence refresh tick |
| setOnEnded | () | Server sets stream.endVideo = true (fires once) |
| setOnDeleted | () | Server sets stream.deleted = true (fires once) |
| setOnError | Error | Any background HTTP error from the loops |
Always marshal to @MainActor before touching UIKit / SwiftUI state. Handlers are invoked on the SDK's actor — they're not main-thread by default.
Read current state synchronously (still requires await because actor):
let snapshot = await session.currentStream()
let chats = await session.chats() // sorted ascending by createdAt
let reactions = await session.reactions() // seeded from /feed on cold-start
let me = await session.user()Send actions
All async throws, throw HotMicError on failure:
try await session.sendChat("hello world")
try await session.react(chatID: chatID, type: .fire)
try await session.removeReaction(chatID: chatID, type: .fire)
try await session.deleteChat(chatID: chatID) // sender, mod, or broadcaster
try await session.reportChat(chatID: chatID)
try await session.blockUserInChat(userID: userID) // mod/broadcaster only
try await session.submitPollAnswer(pollID: pollID, optionID: optionID)
// Battle questions
let questions = try await session.getQuestions()
let q = try await session.createQuestion(questionText: "Who wins this round?")
_ = try await session.voteOnQuestion(questionID: q.id, isDownVote: false)
_ = try await session.removeQuestionVote(questionID: q.id)
// Battle voting — emoji tap on a contestant.
// `type` must be a string id present in the stream's community emoji_palette.
// Pass an empty string for questionID when no question is currently selected.
_ = try await session.voteForContestant(
questionID: "",
contestantID: contestantID,
type: "fire",
tapCount: 1
)Lifecycle
await session.end() // POSTs /end_session/ with watched-ms, stops timersAfter end(), the session is dead — handlers stop firing, send actions throw.
Video playback (AVPlayer example)
The SDK gives you stream.hlsUrl; bring your own player.
import AVKit
@MainActor
final class PlayerViewModel: ObservableObject {
@Published var player = AVPlayer()
private var session: StreamSession?
private var currentURL: URL?
func join(streamID: String, client: HotMicClient) async {
do {
let session = try await client.joinStream(streamID: streamID)
self.session = session
await session.setOnStreamUpdated { [weak self] stream in
Task { @MainActor in self?.applyStream(stream) }
}
await session.setOnEnded { [weak self] in
Task { @MainActor in self?.player.pause() }
}
} catch {
print("join failed:", error)
}
}
private func applyStream(_ stream: HMStream) {
guard let urlString = stream.hlsUrl ?? stream.vodUrl,
let url = URL(string: urlString) else { return }
if currentURL != url {
player.replaceCurrentItem(with: AVPlayerItem(url: url))
player.play()
currentURL = url
}
}
func leave() async {
await session?.end()
session = nil
player.pause()
player.replaceCurrentItem(with: nil)
}
}hlsUrl can change mid-session (state transitions like SCHEDULED → LIVE, or LIVE → VOD). Compare against the player's current item URL on every onStreamUpdated and replace when different.
stream.endVideo == true or stream.deleted == true are surfaced via the onEnded / onDeleted events — handle them in one place rather than checking the snapshot manually.
Error handling
All HTTP failures throw HotMicError:
do {
try await session.sendChat("hi")
} catch let err as HotMicError {
switch err.status {
case 401, 403: /* re-auth */ break
case 429: /* rate-limited */ break
default: print("HTTP", err.status, err.message)
}
}Background errors (poll loop / presence timers) surface via setOnError:
await session.setOnError { error in
print("[hotmic background]", error)
}Polling rule
GET /streams/{id}/poll-data is called every 3 seconds with the to query param floored to the nearest whole second. The server's delta cache is keyed on second boundaries, so all viewers hitting the same second share one cache lookup. Enforced inside StreamPollManager and not configurable — a hard contract shared across all HotMic SDKs.
Concurrency model
HotMicClientisSendable— share it across tasks freely.StreamSessionis anactor— every method isasync; reads of state (currentStream(),chats(),user()) requireawait.- Event handlers must be
@Sendable. Always marshal to@MainActorbefore touching UI. - The SDK uses
URLSession.sharedby default.
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 | setOnChat / setOnReaction / etc. |
| Send chat | session.sendChat |
| React | session.react, session.removeReaction |
| Delete / report chat | session.deleteChat, session.reportChat |
| Block user in chat | session.blockUserInChat |
| Submit poll answer | session.submitPollAnswer |
| Audience Q&A | session.getQuestions, session.createQuestion, session.voteOnQuestion, session.removeQuestionVote |
| Battle voting (emoji tap on contestant) | session.voteForContestant |
| In-app currency | client.tokenPackages, client.purchaseTokens(jws:), client.tokenBalance, client.tokenTransactions |
| Presence (heartbeat + viewer list) | Automatic via joinStream |
| Anonymous viewing | Automatic — no accessToken needed |