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 StreamSession actor that runs the 3-second delta-poll loop and emits events for chat, reactions, polls, presence, and stream state changes via assignable @Sendable closures
  • 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 get stream.hlsUrl — feed it to AVPlayer / 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:

swift
// 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

swift
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

swift
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:

swift
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

swift
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):

swift
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

swift
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:

swift
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.

swift
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:

  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.
  3. Starts the 3-second delta-poll loop.
  4. Starts presence timers (POST /presence every ~30s, GET .../viewers every ~10s).
  5. Begins firing event handlers.
swift
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:

swift
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):

swift
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:

swift
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

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

After 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.

swift
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:

swift
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:

swift
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

  • HotMicClient is Sendable — share it across tasks freely.
  • StreamSession is an actor — every method is async; reads of state (currentStream(), chats(), user()) require await.
  • Event handlers must be @Sendable. Always marshal to @MainActor before touching UI.
  • The SDK uses URLSession.shared by 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 |