Skip to content

Client SDKs/iOS

iOS

VeloClient is the join half of Velo and nothing else. Async await gets you into a room, an ObservableObject carries the call into SwiftUI, and there is no project-key client anywhere in the package, because an IPA is a zip file.

The package

VeloClient is a Swift package added from the Velo repository rather than from a registry. It is a thin wrapper, not a fork: media handling stays in LiveKit's client-sdk-swift, so VeloRoom wraps LiveKit's Room and every LiveKit type stays available to you. import VeloClient re-exports LiveKit, so Room, RoomDelegate, Track and SwiftUIVideoView are already in scope.

  • iOS 15 or newer, macOS 12 or newer.
  • A Swift 6.1 toolchain, Xcode 16.3 or newer. The package builds in Swift 6 language mode, so VeloRoom is @MainActor and VeloToken is Sendable.
  • client-sdk-swift 2.16.0 or newer, resolved for you as a package dependency.
  • A physical device to publish camera. The Simulator has none.

There is no VeloClient(apiKey:), on purpose. A Velo project API key starts with vk_, an IPA is a zip file anyone can pull strings out of, and that key controls your whole project and its billing. A client gets into a room exactly two ways: your backend mints a room token with POST /v1/tokens and sends it to the app, or the app exchanges a room code with POST /v1/codes/{code}/exchange. VeloRoom.connect rejects any string starting with vk_ before it opens a socket.

Install

In Xcode, File, then Add Package Dependencies, and enter the repository. Choose the VeloClient product.

text
https://github.com/judeotine/Velo

Both point at the same repository. There is no pod and no registry entry.

Two products ship, and which one you want depends on the target.

VeloClient
Everything, including VeloRoom. Depends on LiveKit, so it pulls in the WebRTC binaries. This is what an app imports.
VeloCore
Room code exchange, VeloToken, VeloPublishProfile and VeloError, with no LiveKit dependency at all. For a target that needs a token but no media, such as a share extension or a command line tool.

Permissions

iOS terminates the app on first capture when a usage description is missing, so both strings go in before the first call, not after the first crash report.

xml
<key>NSCameraUsageDescription</key>
<string>Used for video calls.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for voice and video calls.</string>

Info.plist. The background modes are what Xcode writes from Signing and Capabilities.

  • The system prompts on the first capture attempt, which is your first setMicrophoneEnabled(true) or setCameraEnabled(true), not at connect.
  • audio and voip keep a call running while the app is backgrounded.
  • Screen sharing needs a Broadcast Extension registered in Xcode, which is LiveKit territory and reached through the underlying room.

Getting a token

An app needs two strings to join: a room token and the url that came with it. A room code is the path that puts nothing secret on the device at all. Codes are short lived, bound to one room and one role, and use capped, so they are safe to type into a join screen, put in a deep link, or print on a card. Exchange consumes a use, and codes are rate limited per code and per client IP.

swift
import VeloClient

let token = try await VeloRoomCodes.exchange(
    baseUrl: "https://api.usevelo.xyz",
    code: "abcdefghjkmnpqrs",
    identity: "patient-1187"
)

Either way, only token and url reach the media server.

VeloRoomCodes.exchange parameters
FieldTypeRequiredDescription
baseUrlStringYesThe API origin, https://api.usevelo.xyz. Trailing slashes are stripped and it must be absolute.
codeStringYesThe room code. It is percent encoded into POST /v1/codes/{code}/exchange.
identityString?NoWho the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise. Trimmed, and a blank string is rejected locally.
sessionURLSessionNoDefaults to URLSession.shared. Pass your own for a custom configuration or a test.
timeoutTimeIntervalNoDefaults to 30 seconds, applied as the request timeout. Exceeding it throws VeloError.timeout.

It returns a VeloToken.

token
The room token to dial with.
url
The Velo media server websocket url.
expiresAt
A Date, parsed from RFC 3339 with or without fractional seconds. A value that will not parse is a VeloError.invalidResponse rather than a silent zero.
role
Optional. The template role the code carries.
publishProfile
Optional. The capture and subscribe hints for that role.
claims
Optional. What the SDK could read out of the token itself, exposed as roomName and identity. Not wire fields, and nil for a token this SDK cannot parse.
isExpired
Computed against the current time, for deciding whether to re-exchange before dialling.

Nothing is verified by that decode. It reads sub for the identity and video.room for the room, strips the prj_ namespace prefix, and exists so you can show which room and which identity you are about to join without a second round trip. Do not treat it as authoritative; the token itself is.

Connecting

VeloRoom is a @MainActor ObservableObject, so SwiftUI observes it directly with no bridging of your own. Build it once, hold it in a model, and connect with either a VeloToken or a token and url pair.

swiftCallView.swift
import SwiftUI
import VeloClient

@MainActor
final class CallModel: ObservableObject {
    let room = VeloRoom()

    func join(code: String, identity: String) async throws {
        let token = try await VeloRoomCodes.exchange(
            baseUrl: "https://api.usevelo.xyz",
            code: code,
            identity: identity
        )
        try await room.connect(token)
        try await room.setMicrophoneEnabled(true)
    }
}

struct CallView: View {
    @StateObject private var model = CallModel()

    var body: some View {
        VStack {
            Text(model.room.isConnected ? "Connected" : "Connecting")
            ForEach(model.room.participants, id: \.self) { participant in
                Text(participant.identity?.stringValue ?? "unknown")
            }
        }
        .task {
            try? await model.join(code: "abcdefghjkmnpqrs", identity: "patient-1187")
        }
    }
}
  • init(connectOptions:roomOptions:) takes the LiveKit option types, both optional, and both are fixed for the life of the room.
  • A failed handshake disconnects the room for you and rethrows, so a failed join leaves nothing running.
  • The guard runs first: a blank token, a vk_ key, a blank url, or a url that is not ws, wss, http or https throws VeloError.invalidRequest before any socket work.
texta project key passed as a token
VeloError.invalidRequest: connect was given a Velo project API key instead of a room
token. Project keys must never ship inside an app: mint a room token on your server or
exchange a room code, and pass that instead.

Call controls

The published properties are what a view binds to. A room delegate inside the wrapper listens for connection, participant, publish and mute changes and refreshes them on the main actor, so a view never polls.

swift
try await room.setMicrophoneEnabled(true)
try await room.setCameraEnabled(false)

if room.connectionState == .reconnecting {
    showBanner()
}

await room.disconnect()
connectionState
Published. LiveKit's ConnectionState, so disconnected, connecting, reconnecting or connected.
participants
Published. The remote participants only, sorted by identity so a list does not reshuffle on every event.
isMicrophoneEnabled
Published, read from the local participant rather than from what you last asked for.
isCameraEnabled
Published, on the same terms.
disconnectError
Published. The LiveKitError the room ended on, or nil. This is where an unexpected drop surfaces.
setMicrophoneEnabled
Async and throwing, returns the LocalTrackPublication and is @discardableResult.
setCameraEnabled
Async and throwing, same contract.
localParticipant
The LiveKit local participant, for publish options this wrapper does not name.
room
The underlying LiveKit room. Data channels, screen share, RPC and end to end encryption are all reached through it.

Rendering is LiveKit's, unchanged. Take a track off a participant and hand it to a video view.

swift
struct ParticipantVideo: View {
    let participant: Participant

    var body: some View {
        if let track = participant.firstCameraVideoTrack {
            SwiftUIVideoView(track)
        } else {
            Color.black
        }
    }
}

Chat and reactions

VeloChat is built from a room that is already connected, and it never owns that room. It rides the data channel the room already has, under the topic velo.chat, so there is no extra endpoint and no server round trip. Messages and reactions share one bounded in-memory history, oldest first, historyLimit entries deep and 200 by default, deduplicated by payload id. Sending takes an optional target identity, and with one set the payload is published only to that participant and arrives marked direct. A reaction body is a short name your application chooses, such as clap or raise_hand; the SDK defines no vocabulary and ships no emoji.

swift
@MainActor
final class CallModel: ObservableObject {
    let room = VeloRoom()
    lazy var chat = VeloChat(room: room, historyLimit: 200)

    func listen() -> VeloChatSubscription {
        chat.onRecord { record in
            switch record.kind {
            case .reaction: flash(record.senderIdentity, record.body)
            case .message: notify(record.senderIdentity, record.body)
            }
        }
    }

    func send() async throws {
        try await chat.sendMessage("The nurse will join in a moment")
        try await chat.sendReaction("raise_hand")
        try await chat.sendMessage("Your results are ready", to: "patient-1187")
    }
}

VeloChat is itself a @MainActor ObservableObject, and records is published, so a list binds to it the same way a view binds to the room. onRecord is for the things you do once rather than render, such as a sound or a toast; it returns a VeloChatSubscription whose cancel() is safe to call twice. clear() empties the history without detaching, and close() detaches for good: it is a no-op the second time, and sending afterwards throws VeloError.invalidRequest.

Each VeloChatRecord carries id, kind, body, sentAt as epoch milliseconds with sentAtDate alongside it, senderIdentity, senderName, local and direct. The sender identity is taken from the participant on the data event rather than from the payload, so a peer cannot claim to be someone else. What you send is echoed into your own history immediately, so a view renders it without a round trip. A message body is 1 to 2000 characters after trimming and a reaction 1 to 64, rejected locally before publishing; a malformed or unknown payload from a remote peer is dropped silently rather than raised, because a peer you do not control decides what arrives.

Errors and disconnects

Every failure this SDK raises is a VeloError, an enum with five cases: api, network, timeout, invalidRequest and invalidResponse. code, message, status and field read off every case, so you can switch on the case or read the code directly. It also conforms to LocalizedError, so errorDescription is the message.

swift
do {
    let token = try await VeloRoomCodes.exchange(
        baseUrl: "https://api.usevelo.xyz",
        code: code,
        identity: identity
    )
    try await room.connect(token)
} catch let error as VeloError {
    if error.code == "code_not_found" {
        show("That code is not valid any more. Ask for a new one.")
    } else if error.isRateLimited {
        show("Too many attempts. Wait a moment and try again.")
    } else if error.isQuotaExceeded {
        show("This account is out of minutes.")
    } else {
        show(error.message)
    }
}
  • The api case carries Velo's envelope, { "error": { "code", "message", "field" } }, verbatim. When the body is not an envelope the code becomes http_{status} and the message is the first 512 characters of the body.
  • isQuotaExceeded is any 402, isRateLimited any 429, and isPermissionDenied a 403 whose code is permission_denied. They exist because a plan limit, a throttle and a missing permission are expected states, not bugs.
  • An unknown, expired, disabled or exhausted room code all come back the same way, a 404 with code code_not_found, so a caller learns nothing about the code space.
  • network carries the URLError.Code when there is one, and timeout carries the interval it gave up after.

Errors raised by the media stack after a successful handshake are LiveKit's own LiveKitError, passed through untouched and surfaced on disconnectError. A drop is not something to catch: connectionState moves to reconnecting and the media stack retries on its own. A room token expires, so a session longer than its TTL needs a fresh token and a fresh connect.

Where to go next

  • Your first room walks the four requests that get a call running, from the server side you have to build anyway.
  • The REST API is where room creation, token minting and room codes live. Every one of them belongs on your backend.
  • Templates and roles explains what the role on a token actually fixes: publish sources, the subscribe graph, permissions, priority and capacity.
  • Authentication is the full account of the two credentials, and why only one of them may ever reach a device.

Was this page useful?

Edit this page on GitHub