Voice & Video Calls
A call starts from a Chat, rings every device of every other member over the realtime connection, and carries media through an SFU that is blind to the plaintext — the frame key is derived from the chat's MLS group exporter secret, so every member at the same epoch derives the byte-identical key. The call surface — chat.startCall, pb.messaging.incomingCall, pb.messaging.activeCall, and all the signaling — ships in PalbeMessaging. The call media ships in a separate product, PalbeCall, which is the only one that carries LiveKit. An app links PalbeCall when it wants media to flow, and one line at startup registers it.
Note: Like the rest of the messaging surface, every call method uses untyped
throws(a plain SwiftError, notthrows(BackendError)) and requires a signed-in user. The failures underneath areBackendErrorvalues — see Error Handling.
Quick example
import Palbe
import PalbeMessaging
import PalbeCall
@main
struct MyApp: App {
init() {
PalbeCall.enable() // once, at startup, before any startCall or accept
}
var body: some Scene { WindowGroup { RootView() } }
}
// Start a video call in a chat.
let chat = await pb.messaging.directChat(with: peerUserId)
let call = try await chat.startCall(media: .audioVideo)
// call.state goes .connecting -> .active once the room connects.
The two products, and what each one costs
PalbeCall's entire first-party public surface is one function:
public enum PalbeCall {
/// Installs the built-in media transport into the registry. Idempotent.
public static func enable()
}
Skip it — because your app does not link PalbeCall, or links it but never calls enable() — and chat.startCall(...) and incoming.accept() throw BackendError.mediaUnavailable, code media_unavailable. That is deliberate: a load-time self-install was considered and rejected, because launch-time magic is hard to diagnose when it is silently missing.
| You want | Link | Weight it adds |
|---|---|---|
| Chat only | PalbeMessaging | the MLS engine; no WebRTC |
| Ring UI, accept, decline, or your own SFU | PalbeMessaging | the same — the whole signaling layer is already here |
| Media actually flowing | PalbeCall | LiveKit's ~29 MB WebRTC stack |
That split is why the products are separate. An app that chats but never calls carries no WebRTC at all — a 42 MB build whose 29 MB was unused WebRTC shipped once and was rejected under ITMS-90683 for a missing purpose string it had no reason to declare.
Warning: Do not surface
BackendError.mediaUnavailable'serrorDescriptionto your users. That one case's description string is untranslated Turkish while every other case's is English. Match on the code —media_unavailable— and write your own message.
Starting a call
@discardableResult
public func startCall(media: CallMedia = .audioVideo) async throws -> Call
Starting a call materializes a draft chat first (the same lazy rule as send), mints this device's join token, and connects the transport. The server fans a call_invite to the group's other devices, where it surfaces as pb.messaging.incomingCall.
public enum CallMedia: Sendable, Equatable {
case audio // on the wire: ["audio"]
case audioVideo // on the wire: ["audio","video"]
public var publishesVideo: Bool { get }
}
let voiceCall = try await chat.startCall(media: .audio)
let videoCall = try await chat.startCall() // .audioVideo is the default
Incoming calls
pb.messaging.incomingCall is an observable IncomingCall?. Read it in a SwiftUI body and present the ring. Because every one of a user's devices subscribes the conversation topic, this is the cross-device incoming-call signal.
IncomingCall is @MainActor @Observable:
| Property | Type | Notes |
|---|---|---|
id | String | The call id, which is also the SFU room id. |
groupId | String | The messaging group (grp_<uuidv7>) display id the call is in. |
fromUserId | String | The user who started the call. |
fromDeviceId | String | The device that started it. |
media | CallMedia | What the caller asked to publish. |
@discardableResult
public func accept(media: CallMedia? = nil) async throws -> Call
public func decline(reason: String? = nil) async throws
accept returns the live Call and clears incomingCall; pass media to publish something other than what the caller requested. decline fans call_declined and clears the ring.
struct RootView: View {
var body: some View {
ContentView()
.sheet(item: Binding(
get: { pb.messaging.incomingCall },
set: { _ in } // dismissal happens via accept or decline
)) { incoming in
IncomingCallSheet(incoming: incoming)
}
}
}
struct IncomingCallSheet: View {
let incoming: IncomingCall
var body: some View {
VStack {
Text("Incoming \(incoming.media == .audioVideo ? "video" : "voice") call")
Text("from \(incoming.fromUserId)")
HStack {
Button("Decline") { Task { try? await incoming.decline() } }
Button("Accept") { Task { _ = try? await incoming.accept() } }
}
}
}
}
Note:
incoming.fromUserIdis a rawusr_…id. There is no profile or display-name source in the SDK, so render the caller's name from your own directory — the same limitation described on Messaging.
The active call
pb.messaging.activeCall is an observable Call?. Both it and incomingCall are thin reads of the public CallCoordinator.shared, which is @MainActor @Observable and exposes the same two properties — use whichever spelling suits your view model.
Call is @MainActor @Observable:
| Property | Type | Notes |
|---|---|---|
id | String | The server call id, which is the SFU room id. |
groupId | String | The messaging group (grp_<uuidv7>) display id this call belongs to. |
state | CallState | The lifecycle below. |
participants | [CallParticipant] | Remote participants only — the local participant is never in this list. |
isMuted | Bool | Whether the local microphone is muted. |
isCameraOn | Bool | Whether the local camera is on. |
siblingDevicePresent | Bool | Whether another device of the same signed-in user has joined. |
canTransfer | Bool | state == .active and a sibling device is present. |
CallState | Meaning |
|---|---|
.connecting | Token minted, joining the SFU. |
.active | The room is connected and media is flowing. |
.ended | The call ended — you left, the remote closed it, or the network dropped. |
.transferred | This device handed the live call to another device of the same user. A distinct terminal state from .ended, so the UI can say "moved to your other device". |
The state machine is driven by real media events: .connecting becomes .active only on a transport connected, and .ended on disconnected. There is no signaling-only fallback that would report a connection that is not there.
Participants
Each remote peer is a CallParticipant (@MainActor @Observable), so a row bound to one re-renders on its own speaking, mute or camera change.
| Property | Type | Notes |
|---|---|---|
id | String | The SFU identity, formatted <userId>#<deviceId>. |
isSpeaking | Bool | Active-speaker detection. |
audioMuted | Bool | Whether this participant's microphone is muted. |
videoEnabled | Bool | Whether this participant is publishing video. |
hasAudioTrack | Bool | An audio track has been subscribed — a real media path is flowing. |
hasVideoTrack | Bool | A video track has been subscribed. |
Warning:
idis a composite, not a user id. Rendering it raw putsusr_abc#mdv_defon screen. Split it on the first#; a bare identity with no#means a user with an empty device id.
extension CallParticipant {
var userId: String { id.split(separator: "#", maxSplits: 1).first.map(String.init) ?? id }
}
Call controls
public func mute(_ on: Bool) async throws // mute or unmute the local mic
public func setCamera(_ on: Bool) async throws // enable or disable the local camera
public func leave() async // leave the call and disconnect
let call = pb.messaging.activeCall!
try await call.mute(true)
try await call.setCamera(false)
await call.leave()
Transfer to another device
When another of your own devices has joined the call, you can move the live call to it. This device leaves the room cleanly while the sibling — already connected with its own per-device token — keeps the conversation going, so the peer hears no break.
public var canTransfer: Bool
public func transfer() async throws
if call.canTransfer {
try await call.transfer() // state becomes .transferred
}
siblingDevicePresent is true while at least one remote participant carries our user id with a different device id. transfer() throws no_sibling_to_transfer (409) when canTransfer is false — no sibling in the room, or the call is not .active.
Host moderation
A chat owner or admin can force-mute or remove a participant. The server enforces the role and answers 403 otherwise.
public func mute(participant: CallParticipant, _ muted: Bool = true) async throws
public func kick(participant: CallParticipant) async throws
try await call.mute(participant: someone, true)
try await call.kick(participant: someone)
Roles come from the chat's membership and are read-only on iOS — there is no API to promote someone to admin. See Messaging.
End-to-end encryption
The frame key is the chat's MLS group exporter secret at the current epoch, derived with the label palbe call media key into 32 bytes — deliberately a different label from the one that seals group names, so the two can never collide. Every member at the same epoch derives the identical key, and the SFU only ever forwards ciphertext. The SDK applies it automatically on both startCall and accept.
Note: This is best-effort, not a guarantee you can assert in your UI. If the exporter secret cannot be derived at the current epoch the call proceeds DTLS-SRTP only — encrypted hop by hop, but readable by the SFU.
setMediaKeyon an empty key, or on a transport with E2EE disabled, logs an error and returns rather than throwing. If your product makes a promise about end-to-end call encryption, verify the state you actually reached rather than assuming it.
public func setMediaKey(_ key: Data) async throws
setMediaKey is public, so an app running its own key agreement can install its own frame key.
A call screen
struct CallScreen: View {
let call: Call
var body: some View {
VStack {
switch call.state {
case .connecting: Text("Connecting…")
case .active: Text("Connected")
case .ended: Text("Call ended")
case .transferred: Text("Moved to your other device")
}
ForEach(call.participants) { p in
HStack {
Text(p.userId) // the extension above, not p.id
if p.isSpeaking { Image(systemName: "waveform") }
if p.audioMuted { Image(systemName: "mic.slash") }
}
}
HStack {
Button(call.isMuted ? "Unmute" : "Mute") {
Task { try? await call.mute(!call.isMuted) }
}
Button(call.isCameraOn ? "Camera off" : "Camera on") {
Task { try? await call.setCamera(!call.isCameraOn) }
}
if call.canTransfer {
Button("Move to other device") {
Task { try? await call.transfer() }
}
}
Button("Leave") { Task { await call.leave() } }
}
}
}
}
Signaling
You never call these yourself — they are documented because they are the contract your backend and your logs will show.
| Verb and path | Body | Response |
|---|---|---|
POST /v1/messaging/groups/{gid}/calls | media: ["audio"] or ["audio","video"] | 201 — call_id, token, edge_url, max_members |
POST /v1/messaging/groups/{gid}/calls/{cid}/accept | empty | 200 — token, edge_url |
POST /v1/messaging/groups/{gid}/calls/{cid}/decline | reason | 204 |
POST /v1/messaging/groups/{gid}/calls/{cid}/mute | target_user_id, target_device_id, muted | 204 |
POST /v1/messaging/groups/{gid}/calls/{cid}/kick | target_user_id, target_device_id | 204 |
{gid} is always the grp_<uuidv7> display id, never the base64 group id the MLS layer uses internally. The realtime topic below is the other way round: it is keyed by that opaque base64 rfcGroupId, not by groupId. Two identifiers, two surfaces — do not substitute one for the other.
Live signaling rides the same conversation topic as presence, typing and read receipts — messaging:conv:<rfcGroupId> — and carries seven events:
call_invite call_ringing call_accepted call_declined call_ended
host_mute host_kick
call_invite carries call_id, from_user, from_device, media and room_id, and no token — every device mints its own on accept. Because all of a user's devices subscribe that topic, this is what makes a call ring on more than one device. The coordinator resolves the resulting races for you:
- An invite from our own user and device is suppressed, while sibling devices still ring.
- No ring fires for a call this device is already in.
- A
call_acceptedfrom a sibling device cancels the local ring — you answer on the iPad, the iPhone stops buzzing. call_declinedandcall_endedclear the ring;call_endedfor the active call also leaves it.host_kicktargeting this device leaves the active call.host_muteis a no-op at the signaling layer: the SFU enforces it, and the change arrives as a participant update.
max_members comes back from the start call and the client does not enforce it. If your product needs a hard cap in the UI, read the value and enforce it yourself.
Bringing your own media transport
The media engine sits behind a public seam. The registry starts empty, PalbeCall.enable() is just one caller of register, and the platform never sees a media-engine type — only events. You can therefore run calls on a different SFU, or against a test double, without linking PalbeCall at all.
public protocol CallMediaTransport: Sendable {
func connect(edgeUrl: String, token: String, media: CallMedia,
onEvent: @escaping @Sendable (CallMediaEvent) -> Void) async throws
func setMicrophoneMuted(_ muted: Bool) async throws
func setCameraEnabled(_ enabled: Bool) async throws
func setMediaKey(_ key: Data) async throws
func disconnect() async
}
public enum CallMediaEvent: Sendable {
case connected
case disconnected
case participantConnected(identity: String)
case participantDisconnected(identity: String)
case trackSubscribed(identity: String, kind: CallTrackKind)
case activeSpeakersChanged(identities: [String])
case participantUpdated(identity: String, audioMuted: Bool, videoEnabled: Bool)
}
public enum CallTrackKind: String, Sendable { case audio, video }
public typealias CallMediaFactory = @Sendable (_ e2ee: Bool) -> any CallMediaTransport
public final class CallMediaRegistry: @unchecked Sendable {
public static let shared: CallMediaRegistry
public func register(_ factory: @escaping CallMediaFactory)
public var isMediaAvailable: Bool { get }
}
CallMediaRegistry.shared.register { e2ee in MyTransport(e2ee: e2ee) }
Register once at startup, exactly where PalbeCall.enable() would go. isMediaAvailable tells you whether anything is registered; the registry's reset is internal and test-only, so a registration lasts for the process.
Note:
import LiveKitdoes not compile for a consumer of this package, by design.PalbeCallpulls LiveKit in with aninternal import, and a release check fails the build if a LiveKit, WebRTC or Protobuf symbol appears in the public interface. If you need LiveKit directly, depend on it yourself.
Push, PushKit, and what the SDK does not do
Register the PushKit token so the platform can reach the device:
import PushKit
func pushRegistry(_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
for type: PKPushType) {
pb.notifications.registerVoipToken(credentials.token)
}
public func registerVoipToken(_ token: Data)
- Synchronous,
Void, never throwing — a PushKit delegate cannottryorawait. If the SDK is unconfigured or nobody is signed in, it logs and no-ops, so re-call it after sign-in. - The raw token is lowercase-hex encoded and posted with platform
"ios_voip"— a distinct device row from the alert token registered withregisterDeviceToken, so VoIP sends target it specifically. - Idempotent and rotation-safe. PushKit re-hands the token on every launch; an identical re-delivery is skipped, and a rotated token upserts on the stable per-install device id.
Warning: Registering the token is where the SDK's involvement ends. There is no PushKit payload handler and no CallKit integration in Palbe.
pb.messaging.incomingCallis populated exclusively by thecall_inviteevent on a live realtime socket, so a terminated app does not ring through the SDK alone. Turning a VoIP push into a ring — decoding the payload, reporting the call to CallKit as iOS requires, and only then joining — is your app's own work today. An app that takes a VoIP push and does not report a CallKit call is killed by the system.
The Notification Service Extension story on Messaging enriches messaging wakes. It has nothing to do with call UI.
Permissions and App Store notes
NSMicrophoneUsageDescription— required by any app that linksPalbeCall.NSCameraUsageDescription— additionally required for video.- Neither is needed by an app on
PalbeorPalbeMessagingalone; those products link no camera or microphone API. - The SDK does not request permission itself and exposes no permission API — iOS prompts when capture starts.
ITSAppUsesNonExemptEncryptionistruefor an app linkingPalbeCall(DTLS-SRTP) orPalbeMessaging(MLS).PalbeCallships its third-party licenses inside the framework, and SwiftPM makes two upstream packaging modules visible to any target that links it. They are implementation artifacts, not API — do not import them.
Related
- Messaging — calls start from a
Chat, and roles come from its membership. - Realtime — the shared connection every
call_inviterides. - Auth — calls require a signed-in user.
- Error Handling —
BackendError.mediaUnavailableand the rest. - Calls on the web — the same signaling from a browser.