Palbase
Sign inGet started

iOS SDK

Messaging

pb.messaging is end-to-end encrypted chat, and it ships in the PalbeMessaging product — Palbe alone does not carry it. Message text, media, group names, reactions, edits, deletes, mentions and disappearing-message timers all ride inside an MLS ciphertext, so the server stores an opaque blob and never holds plaintext. There is no crypto code to write and no enrollment step to manage: the first chat operation self-enrolls the device. The top-level noun is a Chat — a DM or a group, uniformly — and a single observable Chat bundles its own transcript, typing, presence, read receipts and members, so a chat screen binds to one object.

Note: Every pb.messaging method uses untyped throws (a plain Swift Error, not throws(BackendError)), unlike generated endpoint calls. The failures underneath are still BackendError values, so error as? BackendError works — see Error Handling. Messaging also requires a signed-in user: enrolling without a session throws not_signed_in (401).

Quick example

import Palbe
import PalbeMessaging

// Open a 1:1 — instant and local, no network.
let chat = await pb.messaging.directChat(with: peerUserId)

// The first send materializes the chat on the server and encrypts on-device.
try await chat.send(text: "Saturday works for me")

// Bind the transcript in a SwiftUI body — it re-renders as messages land.
ForEach(chat.messages) { message in
    Text(message.text ?? "(media)")
}

The Swift package vends four products. Three are stacked — each one carries every layer beneath it exactly once — and PalbePurchases is an independent sibling. Add exactly one of the stacked three to a target.

FeatureMinimum productWhat it pulls in
pb.realtime, pb.notifications, typed backend calls, auth, flags, analyticsPalbeFoundation only
Chat: pb.messaging, Chat, media, reactions, edits, deletes, mentions, TTLPalbeMessagingthe Rust MLS engine (PalbeMlsFFI)
Call signaling only — ring UI, accept, decline, an alternate SFUPalbeMessagingas above
Actually placing or answering a call with media flowingPalbeCallLiveKit's ~29 MB WebRTC stack
Notification Service Extension enrichmentPalbeMessaging on the NSE target tooas above

An app that chats but never calls links PalbeMessaging and never carries LiveKit. That split is the whole reason the products are separate: a 42 MB app whose 29 MB was unused WebRTC shipped once, and the App Store rejected it under ITMS-90683 for a missing purpose string it had no reason to declare. Only a PalbeCall-linking app needs NSMicrophoneUsageDescription (and NSCameraUsageDescription for video); Palbe and PalbeMessaging link no camera or microphone API at all.

Linking PalbeMessaging makes your app's export-compliance answer ITSAppUsesNonExemptEncryption = true — MLS is non-exempt encryption. Palbe on its own is false.

Note: import PalbeMessaging also surfaces the generated MLS uniffi bindings — PalbeMlsClient, PalbeMlsGroup, generateClient(...), PalbeMlsError, RebaseExhausted and roughly fifteen more symbols. They are unsupported implementation detail, not API. Use pb.messaging and Chat; do not build on the bindings.

messaging is a reserved namespace, so a backend endpoint cannot be named messaging. The reserved set is nine: auth, analytics, flags, realtime, notifications, perf, messaging, purchases, debug. See Calling Your Backend.

Opening a chat

Every chat starts local. directChat and groupChat return a Chat you can show immediately; nothing hits the server until the first send. A never-sent chat leaves no server-side trace — open a contact, type nothing, and there is no empty chat behind you.

// The ONE direct chat with a user. Instant, local, no network.
// Idempotent and pointer-stable: two calls with the same peer return
// the SAME Chat instance, keyed by a sorted-pair direct key.
let dm = await pb.messaging.directChat(with: peerUserId)

// An explicit multi-party group. Local and lazy, created on the first send.
// Unlike a DM, a group is NOT deduped — each call is a distinct conversation.
let group = await pb.messaging.groupChat(title: "Weekend plans", members: ["usr_a", "usr_b"])

// Look up a cached chat by its grp_ handle (e.g. from a deep link). nil if unknown.
let fromLink = await pb.messaging.chat(id: "grp_...")
MethodReturnsNotes
directChat(with userId: String) asyncChatInstant, local; idempotent and pointer-stable per user pair.
groupChat(title: String? = nil, members: [String] = []) asyncChatLocal and lazy; not deduped by roster.
chat(id: String) asyncChat?Lookup by grp_ id; nil if this device has never seen it.

A draft chat materializes on its first send, flips from .draft to .active, and enters pb.messaging.chats. Drafts never appear in the chat list, and the server-metadata calls (setNotifyScope, refreshNotifyScope, refreshUnread) throw chat_is_draft (422) until a message has been sent.

The chat list

pb.messaging.chats is the observable inbox. Reading pb.messaging.chats.all in a SwiftUI body re-renders the view when a chat is added, removed, or hydrated from disk on launch. It lists active chats only.

struct InboxView: View {
    var body: some View {
        List(pb.messaging.chats.all) { chat in
            NavigationLink(chat.title) { ChatScreen(chat: chat) }
                .badge(chat.unreadCount)
        }
    }
}

Non-SwiftUI callers can use a closure or an AsyncStream:

let unsubscribe = pb.messaging.chats.onChange { chats in /* ... */ }
for await chats in pb.messaging.chats.snapshots() { /* ... */ }

Reading chats self-enrolls and starts live delivery in the background, so incoming DMs and group invites drain into the list on their own. If you want messaging warmed up at sign-in, call start() once — it is never required, and a second call on an already-resolved device is a no-op:

try await pb.messaging.start()

There is no pb.messaging.enroll(). The whole namespace is exactly this:

@MainActor public struct MessagingNamespace: Sendable {
    public var chats: ChatListStore { get }
    public func directChat(with userId: String) async -> Chat
    public func groupChat(title: String? = nil, members: [String] = []) async -> Chat
    public func chat(id: String) async -> Chat?
    public var incomingCall: IncomingCall? { get }
    public var activeCall: Call? { get }
    public func start() async throws
    public func commsPrefs() async throws -> CommsPrefs
    @discardableResult
    public func setCommsPrefs(sharePresence: Bool? = nil,
                              shareReceipts: Bool? = nil) async throws -> CommsPrefs
}

Warning: One shipped SDK error string is stale. device_not_enrolled (412) reads "call pb.messaging.enroll() before using groups". That method does not exist in this version — the fix is to make sure a user is signed in, not to call anything.

The Chat object

A Chat is @MainActor @Observable: read any property in a SwiftUI body and the view re-renders when it changes.

PropertyTypeMeaning
idStringgrp_<uuidv7> once active; a reserved local handle while draft.
kind / isDirectChatKind / Bool.direct or .group.
stateChatState.draft until the first send, then .active.
titleStringSee the warning below — it is not a peer name.
members[ChatMember]The participating users (not devices). A direct chat is [me, peer].
messages[ChatMessage]The deduped transcript, sorted by serverSeq ascending (newest last).
lastMessageChatMessage?The most recent renderable message, for a list-row preview.
unreadCountIntLocal, instant, offline badge — incoming messages above this device's read watermark.
typing[ChatMember]Users currently typing (auto-clears after ~6 s).
onlineMembers[ChatMember]Members currently online (60 s TTL, device-union).
trustChatTrust.unverified or .verified — see Safety numbers.
notifyScopeNotifyScope?The cached mute toggle. nil until first read or set.
serverUnreadUnreadView?The cached authoritative server unread view. nil until first refreshUnread().
deliveredUpTo / readUpToIntSender-side watermarks for the double-tick. Both start at 0.

Warning: There is no profile or display-name source anywhere in the iOS SDK. ChatMember.displayName and ResolvedMention.displayName exist as fields and are always nil in practice. chat.title therefore resolves to a group's decrypted name when one was set, and otherwise to a fallback built from the group's display id — "Group a1b2". A direct chat has no name at all: an active DM also renders "Group a1b2", and a draft DM renders "Chat". Render peer and member names from your own user directory; do not expect them from Chat or ChatMember.

There is also no rename API — no chat.rename, no title setter. A group's name is set once, at groupChat(title:) creation time. It is sealed under a per-epoch exporter secret and re-sealed automatically when the group's membership changes, so the server never reads it.

Sending

The first send on a draft chat materializes it on the server and flips it active; an already-active chat sends with no extra round-trip. Seven overloads:

@discardableResult func send(text: String) async throws -> SentReceipt
@discardableResult func send(text: String, mentions: [MentionRange]) async throws -> SentReceipt
@discardableResult func send(text: String, replyingTo parent: ChatMessage?) async throws -> SentReceipt
@discardableResult func send(text: String, expiresIn ttlSeconds: Int,
                             start: ExpiryStart = .send) async throws -> SentReceipt
@discardableResult func send(image: Data, filename: String,
                             mimeType: String = "image/jpeg") async throws -> SentReceipt
@discardableResult func send(file: Data, filename: String, mimeType: String) async throws -> SentReceipt
@discardableResult func send(voice: Data, durationMs: Int,
                             peaks: [Int]? = nil) async throws -> SentReceipt
let receipt = try await chat.send(text: "hello")
print(receipt.serverSeq)   // the message's ordering position in this chat
print(receipt.epoch)       // the MLS key epoch it was accepted at

Media

try await chat.send(image: jpegData, filename: "beach.jpg")
try await chat.send(file: pdfData, filename: "report.pdf", mimeType: "application/pdf")
try await chat.send(voice: m4aData, durationMs: 8200)   // peaks auto-computed when nil
  • The attachment kind is derived from the mime type in send(file:): image/*.image, video/*.video, audio/*.voice, anything else → .file.
  • send(image:) also renders and encrypts a small inline thumbnail, so the receiving side previews it with no round-trip.
  • send(voice:) computes waveform peaks when peaks is nil, and always uses the filename voice.m4a with mime audio/m4a.
  • There is no send(video:) overload. Send video through send(file:mimeType: "video/…"). That path gets no poster thumbnail — only send(image:) generates one.

Each attachment is sealed with a fresh random 256-bit key of its own, deliberately not the MLS exporter secret (which is per-epoch and forward-secret, so an exporter-sealed blob would become undecryptable at the next membership change). The key travels only inside the end-to-end encrypted message body. Bytes are AES-256-GCM sealed in 1 MiB segments and go straight to Storage over a plain URLSession PUT on a presigned URL, never through the pb transport. The ciphertext's SHA-256 is bound into the encrypted descriptor and checked before decryption, so a swapped blob is detected rather than decrypted.

The server sees only sizes and ids:

Verb and pathBody it receivesResponse
POST /v1/messaging/media/upload-urlgroup_id, declared_ciphertext_size, optional content-type label — no key, no hash, no filenameattachment_id, blob_ref, upload_url, upload_token
POST /v1/messaging/media/{attachment_id}/commitattachment_id, status, actual_size
POST /v1/messaging/media/{attachment_id}/download-urldownload_url

Messages and history

chat.messages is one transcript — durable on-device history and live messages unified, deduped, and sorted by serverSeq ascending.

FieldTypeMeaning
idStringStable, URL and log safe: <chat.id>#<serverSeq>.
kindChatMessageKind.text, .media, or .system (a membership change).
directionMessageDirection.incoming / .outgoing.
senderUserIdString?Who sent it, resolved device → user. nil for .system.
textString?Decrypted text; nil for media and system messages.
attachmentAttachment?Decoded media, if any.
serverSeqIntServer-minted per-group monotonic sequence — sort key and read cursor.
sentAtDateWhen the message was sent or received.
replyResolvedReply?Non-nil when this message is a reply.
reactions[String: Set<String>]emoji → reactor user ids. Empty when none.
isEditedBoolWrite-once: once any valid edit applies it stays true.
isDeletedBoolWrite-once and absorbing — delete strictly dominates edit.
mentions[ResolvedMention]Normalized mention ranges.

ChatMessage conforms to Equatable by comparing id only.

Warning: ChatMessage has a public 9-parameter memberwise initializer, but the internal clientMsgId it needs is synthesized as "" there — and reactions, isEdited, isDeleted and mentions cannot be set through it either. A ChatMessage you built yourself is therefore not replyable, reactable, editable or deletable-for-everyone: every one of those methods returns early on an empty clientMsgId. Always pass the value you got out of chat.messages.

History is local, and it is capped

// Returns how many older messages were prepended. Idempotent.
let added = try await chat.loadEarlier(limit: 50)

loadEarlier reads this device's local sealed store and nothing else. There is no server backfill and there never can be: mls-rs erases an application message's key on first decrypt, so the ciphertext the server keeps is useless afterwards — the sender cannot decrypt its own ciphertext, and a device that joins later cannot decrypt epochs from before it joined.

The store holds at most 1000 records per group, trimmed oldest-first on every write. Past 1000, there is nothing left to page back to on any device, ever. Each record is individually AES-GCM sealed under a Keychain key that never leaves the device and never syncs, in one file per group under Application Support/PalbeMessaging/messages/. Decrypted plaintext is persisted before the delivery queue row is acked, so a failed write means the row is re-served rather than lost.

Receiving media: Attachment

The thumbnail arrives already decrypted — it travelled inside the encrypted body, so there is no round-trip. The full blob downloads, integrity-verifies and decrypts on demand.

public struct Attachment: Sendable {
    public let filename: String
    public let mimeType: String
    public let kind: AttachmentKind       // .image, .video, .file, .voice
    public let plaintextSize: Int         // bytes, for UI and progress
    public let thumbnail: Data?           // already decrypted; nil for file and voice
    public let waveform: [Int]?           // voice only
    public let durationMs: Int?           // voice and video only

    public func fetchBlob() async throws -> Data
    public func fetchBlobStream() -> AsyncThrowingStream<Data, any Error>
}
if let attachment = message.attachment {
    thumbnailView.image = attachment.thumbnail.flatMap(UIImage.init(data:))

    // On tap: download, integrity-verify, decrypt — all inside the SDK.
    let fullData = try await attachment.fetchBlob()
}

Use fetchBlobStream() for large files: it yields decrypted chunks in order with bounded memory instead of materializing the whole file.

Replies

try await chat.send(text: "agreed", replyingTo: someMessage)
public struct ResolvedReply: Sendable, Equatable {
    public let parentClientMsgId: String
    public let state: ReplyState          // .resolved | .unavailable
    public let quoteSenderUserId: String
    public let quoteText: String?
    public let quoteKind: String          // "text" | "image" | "video" | "file" | "voice"
}

The quote is rebuilt from the real parent whenever it is in this device's local history, and the sender-embedded preview is ignored — that is the spoof defence. The preview is used only as a fallback, and then state is .unavailable. Replying to a .system message, to nil, or to a message with no clientMsgId degrades silently to a plain text send; no reply_to is fabricated.

Reactions

A reaction is an annotation, never a bubble, and never a notification. It rides inside the E2E ciphertext as an opaque application message, folds into the target's tally at the server-assigned order so the count updates immediately, and dedups against the durable echo that arrives on the next hydrate.

try await chat.react(message, emoji: "👍")
try await chat.unreact(message, emoji: "👍")

for (emoji, userIds) in message.reactions {
    Text("\(emoji) \(userIds.count)")
}

Edits

try await chat.edit(message, newText: "…on second thought, Sunday")

An edit is a supersession, not a new bubble. It is guarded to your own text messages: a non-text message, or one you constructed yourself, is a silent no-op, and the durable echo carries an author gate so a peer cannot edit your words. isEdited is write-once — editing back to the original still renders as edited.

Deletes

try await chat.deleteForEveryone(message)   // author only; async, throws
chat.deleteForMe(message)                   // synchronous, non-throwing, local
  • deleteForEveryone sends a cooperative encrypted tombstone — the server stays blind, and it is neither a bubble nor a notification. Only the original author can do it, and a legacy message with no clientMsgId cannot be deleted for everyone at all. isDeleted is write-once and absorbing: once set, text becomes a neutral deleted descriptor and reactions, the reply quote and any edit are hidden.
  • deleteForMe is purely local. It adds a suppression key to a persisted per-chat set and the message vanishes from chat.messages. Nothing is sent, and there is no cross-device sync — hiding a message on this iPhone does not hide it on your iPad. It works on any message, including legacy ones.

Mentions

_ = try await chat.send(
    text: "hey @ali, look",
    mentions: [MentionRange(start: 4, length: 4, mentionedUserId: "usr_ali")]
)
public struct MentionRange: Codable, Sendable, Equatable {
    public let start: Int              // UTF-16 code-unit offset, half-open [start, start+length)
    public let length: Int
    public let mentionedUserId: String
}

public struct ResolvedMention: Sendable, Equatable {
    public let start: Int
    public let length: Int
    public let mentionedUserId: String
    public let displayName: String?    // always nil today
}

Warning: Offsets are UTF-16 code units, not Characters and not Unicode scalars. A string containing an emoji outside the Basic Multilingual Plane shifts every offset after it by two, not one. Compute ranges with String.utf16 or NSRange, never with Character counts.

Ranges travel inside the ciphertext, so the server never learns who was mentioned. On receipt they are normalized: out-of-bounds, overlapping and surrogate-splitting ranges are dropped before they surface. An empty mentions array is byte-identical on the wire to a plain send(text:). A mention is an ordinary text bubble — unlike a reaction or an edit, it is not a silent fold.

Because ResolvedMention.displayName is always nil, render the mention from the text slice the range points at, or from your own directory.

Elevating your own mentions

chat.onMentionElevation = { message in
    // raise a louder LOCAL notification for `message`
}

Fires at most once per message for an incoming message that mentions this device's user and was sent by someone else. The dedup set is persisted and shared through the App Group, so a hook wired later will not re-fire for messages already decided. The SDK guarantees the decision, not the buzz — what happens on screen is your code.

Disappearing messages

// One message.
try await chat.send(text: "the code is 4417", expiresIn: 300)

// A chat-wide default. nil DISABLES it.
try await chat.setDisappearing(ttlSeconds: 86_400, start: .read)
try await chat.setDisappearing(ttlSeconds: nil)

ExpiryStart is .send (anchor the deadline to the sender's compose time) or .read (anchor it to each device's first read). An explicit per-message expiresIn always wins over the chat default. The TTL rides inside the ciphertext — there is no expires_at column and no extra route — and setDisappearing emits an opaque control envelope, never a bubble.

Deadline arithmetic survives a restart: the SDK stores a monotonic clock reading, a wall-clock reading and a boot token, takes the larger of the two deltas within one boot, and falls back to wall-clock across a reboot.

Warning: Both methods are fire-and-forget advisory. They return once this device has emitted the message or the control envelope. There is no "active for all peers" acknowledgement and no peer enforcement — disappearing messages are cooperative, and a modified client can keep a copy. Do not present them as a guarantee.

Mute, unread, and double-ticks

A Chat carries three observable server-metadata signals. The server stays MLS-blind for all of them — it stores opaque metadata, never content.

Mute (notify scope)

.all (the default) fires the push wake on a new message; .none suppresses it.

public func setNotifyScope(_ scope: NotifyScope) async throws
@discardableResult public func refreshNotifyScope() async throws -> NotifyScope
try await chat.setNotifyScope(.none)   // no push banner for new messages
try await chat.setNotifyScope(.all)    // restore push wakes

if chat.notifyScope == NotifyScope.none {
    MutedIcon()
}

Both calls throw chat_is_draft (422) on a chat that has never sent a message.

.none Optional-collision caveat. chat.notifyScope is an Optional (NotifyScope?, nil until first read or set). Writing chat.notifyScope == .none resolves .none to Optional.none — so that comparison tests "not yet loaded", not "muted". Fully qualify it: chat.notifyScope == NotifyScope.none.

Server unread

chat.unreadCount is the local, instant, offline badge. For the authoritative server view, call refreshUnread, which caches the full view into chat.serverUnread and returns the clamped count:

let canonical = try await chat.refreshUnread()   // server-derived, clamped >= 0

if let view = chat.serverUnread {
    // view.groupMaxSeq, view.lastReadSeq, view.deliveredSeq, view.unreadCount
}

refreshUnread throws chat_is_draft on a draft. The two counts are complementary: local is instant and works offline, server is canonical on demand.

Delivered and read ticks

let delivered = ownMessage.serverSeq <= chat.deliveredUpTo
let read      = ownMessage.serverSeq <= chat.readUpTo

Both watermarks are folded to user level: the wire carries the acking device id, a user's devices fold to one sequence (the maximum), and across peers the maximum is taken. They are monotonic — a stale lower sequence never lowers the displayed watermark — and both start at 0. read is gated by the peer's shareReceipts preference (the broadcast simply never arrives when a peer keeps receipts private); delivered is a transport fact and is not gated.

Presence, typing, and read receipts

Live conversation status rides the SDK's shared realtime connection (see Realtime) and is bundled onto the Chat. Observing a chat starts observing its conversation — there is no setup call.

// Typing: announce your own (throttled internally; a `false` is immediate).
chat.setTyping(!composerText.isEmpty)
ForEach(chat.typing) { member in TypingDot(member) }

// Presence, per user.
if chat.presence(of: peerUserId)?.isOnline == true { OnlineDot() }
ForEach(chat.onlineMembers) { member in /* ... */ }

// Read receipts: idempotent and monotonic.
try await chat.markRead(upTo: lastVisible)
let readers = chat.readers(upTo: receipt.serverSeq)
let watermark = chat.readWatermark(of: peerUserId)

markRead takes a ChatMessage, not a raw serverSeq. A peer's typing state auto-clears after about 6 seconds; presence has a 60-second TTL.

Note: PresenceStore, TypingStore and ReceiptsStore are public types with public initializers, but nothing public vends a populated one — constructing them yourself gets you empty stores. The live data is on the Chat: chat.typing, chat.onlineMembers, chat.presence(of:), chat.readers(upTo:).

Privacy preferences

Comms prefs are per user, not per chat. Both default to true, and setCommsPrefs is a partial update — nil leaves a field unchanged.

let prefs = try await pb.messaging.commsPrefs()
try await pb.messaging.setCommsPrefs(sharePresence: false)
  • sharePresence: false hides the user's online status and suppresses their typing indicators.
  • shareReceipts: false keeps the read cursor private: their own unread counts still work, but peers never see their read ticks.

Members and roles

try await chat.addMember(userId: "usr_...")
try await chat.removeMember(userId: "usr_...")
try await chat.leave()

addMember and removeMember throw chat_is_direct (422) on a DM. Adding a user joins every one of that user's devices in one atomic MLS epoch; removing a user evicts all of them in one epoch.

ChatRole is .owner, .admin, .member, deduped device → user with the strongest role winning. The creator of a group is its owner.

Warning: Roles are read-only on iOS. There is no client API to assign or change one — no setRole, no makeAdmin. The server publishes a role route, but the SDK has no builder and no call site for it. Membership control from the app is exactly addMember, removeMember and leave. Roles are also a server ACL, not a cryptographic guarantee.

Safety numbers and verification

A safety number lets two users confirm out of band that they are talking to each other's real devices. It is computed locally from both users' public device sets — no private key leaves the device — and the same algorithm runs on every Palbase platform, so both sides always see the same number.

let result = try await chat.safetyNumber()                 // direct chat; throws chat_is_group on a group
let result = try await chat.safetyNumber(with: memberUserId)  // a specific member of a group

print(result.safetyNumber)   // 60 digits: two 30-digit halves, six groups of five each
SafetyNumberResultTypeMeaning
versionIntAlgorithm version.
safetyNumberString60 digits, displayed as groups of five.
qrPayloadDataStable payload for a QR-compare flow.
verifiedBoolLocal pin state; false on a fresh computation.

After the user confirms the numbers match, record it. This flips chat.trust to .verified:

try await chat.verify(peerUserId, deviceSetHash: peerDeviceSetHash)

The SafetyNumber computation helpers — compute(localUserId:localMaterial:remoteUserId:remoteMaterial:), qrPayload(version:localMaterial:remoteMaterial:), identityMaterial(_:), fingerprintInput(signatureKey:partyIdentity:), uuidBytes(fromDeviceId:) — are public, so you can build the QR and comparison UI yourself.

Warning: ChatTrust declares a third case, .changed(KeyChangeEvent), and it never fires. The only assignment anywhere in the module is trust = .verified; the key-change hub is internal and has no live source yet. Handle .unverified and .verified; do not build a "this contact's security code changed" banner on .changed — it will never appear.

Push notifications

A messaging push is content-free: the server is MLS-blind, so it can only say something arrived in this group. By default the user sees a generic banner and has to open the app. Opting into App-Group state sharing lets a Notification Service Extension read the same MLS state as the host app and decrypt the message on-device with the app closed.

1. Register the device token

Nothing arrives at all until the alert token is registered. This lives in Palbe, so it works without the messaging product.

// UIApplicationDelegate
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    pb.notifications.registerDeviceToken(token)
}

It is synchronous, returns Void, and never throws — a delegate callback cannot try or await. If the SDK is unconfigured or nobody is signed in it logs and no-ops, so re-call it after sign-in when the first callback fired while signed out. The raw token is lowercase-hex encoded and posted with platform "ios"; an identical re-delivery is skipped and a rotated token upserts on the stable per-install device id. registerVoipToken posts the same shape with platform "ios_voip" — a distinct row — and is covered on Voice & Video Calls.

2. Host app: opt into App-Group sharing

public func configure(messagingAppGroup: String)

Call it once at launch, before any pb.messaging.* use, with the App-Group id that your host app and its NSE both declare in their entitlements:

pb.configure(messagingAppGroup: "group.net.pallasite.palbase.palbe")

It is not throws. On the first call it runs a one-time, data-preserving copy-verify-delete migration that relocates every messaging store into the shared container and moves the MLS seal keys and the session token into the shared keychain access group. The migration is idempotent and best-effort: a failure leaves the originals in place, so the app keeps working app-private and the NSE simply cannot enrich yet — your irreplaceable MLS keys are never lost. Omitting the call entirely is supported; you keep app-private storage and lose only enrichment.

Note: configure(messagingAppGroup:) and configure(onError:) are the only public configure overloads. There is no pb.configure(url:apiKey:) for app code — the SDK self-configures from the Palbase-Info.plist that codegen commits into your app bundle. Two shipped error strings still name a pb.configure(apiKey:) that is not public API.

3. NSE: forward the wake

Link PalbeMessaging in the extension target — the same product the host app links for chat, even when the host app links PalbeCall, because an NSE never places a call.

public enum WakeKind: String, Sendable {
    case message, welcome
    public init(pbWake: String?)      // unknown or absent -> .message
}

public struct EnrichedNotification: Sendable {
    public let title: String
    public let body: String
    public let badge: Int?
}

public static func handleWake(kind: WakeKind, groupDisplayId: String?,
                              budget: TimeInterval = defaultBudget) async -> EnrichedNotification?

The APNs payload carries pb_kind: "messaging_wake", pb_wake ("message" or "welcome") and pb_group (a grp_<uuidv7> group display id — not a project id).

import UserNotifications
import Palbe
import PalbeMessaging

final class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttempt: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        let best = request.content.mutableCopy() as? UNMutableNotificationContent
        self.bestAttempt = best

        // The extension is a SEPARATE PROCESS. Without this line it reads
        // app-private storage and handleWake returns nil every time.
        pb.configure(messagingAppGroup: "group.net.pallasite.palbase.palbe")

        let info = request.content.userInfo
        let kind = WakeKind(pbWake: info["pb_wake"] as? String)
        let groupDisplayId = info["pb_group"] as? String

        Task {
            if let enriched = await PalbeMessagingExtension.handleWake(
                kind: kind, groupDisplayId: groupDisplayId
            ) {
                best?.title = enriched.title
                best?.body = enriched.body
                if let badge = enriched.badge { best?.badge = NSNumber(value: badge) }
            }
            contentHandler(best ?? request.content)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        contentHandler?(bestAttempt ?? UNMutableNotificationContent())
    }
}

handleWake builds the same delivery source the app builds, over the shared container, with no socket — it pumps once over HTTP, drains welcomes and the device queue, decrypts, persists, acks, and reads the newest decrypted row. The budget defaults to 25 seconds (Apple allows roughly 30) and it processes at most 20 rows per wake.

It returns nil on any failure at all — not configured, no App Group, the MLS engine absent, a pump error, the budget blown, or nothing decrypted — so the user keeps the generic placeholder banner and never sees a broken one.

  • A .welcome body reads "<adder> added you to <group>", plus " · <first message>" when one decrypted in the same drain — one banner, not two. An unknown adder falls back to "Someone".
  • A .message body is the decrypted text, or a media summary: "📷 Photo", "🎥 Video", "🎤 Voice message", "📎 File". Your own outgoing messages are never the banner subject.
  • The badge is the count of incoming rows in that group — a conservative floor the app reconciles across groups on foreground.

Warning: The banner title for a .message wake resolves the sending device to a raw usr_… user id, because there is no profile source (see the title warning above). It falls back to the group's display name. If you need human names on a lock screen, keep your own directory in the shared App-Group container and post a local notification instead of relying on the enriched title.

Voice and video calls

The call surface lives on pb.messagingchat.startCall(...), pb.messaging.incomingCall, pb.messaging.activeCall — and ships inside PalbeMessaging. The call media is a separate product, PalbeCall, and needs one PalbeCall.enable() call at startup. See Voice & Video Calls.

Errors messaging throws

All of these arrive as BackendError.server(ServerFailure) unless noted; read failure.code.

CodeStatusThrown by
not_signed_in401any chat operation without a session
messaging_not_configured412any Chat operation with no configured backend
device_not_enrolled412resolving a group before enrollment completed
chat_is_direct422addMember / removeMember on a DM
chat_is_group422the no-argument safetyNumber() on a group
chat_is_draft422setNotifyScope / refreshNotifyScope / refreshUnread on a draft
media_unavailablestarting or accepting a call without PalbeCall.enable() — a distinct BackendError.mediaUnavailable case, not .server

What does not exist on iOS

Confirmed absent from both the source and the compiled module interface — do not design around any of these:

  • Spaces and threads. No Space type, no thread id, nothing.
  • Profiles and display names. ChatMember.displayName and ResolvedMention.displayName are always nil.
  • Chat rename — a group's name is set at creation only.
  • Role assignment — roles are read-only.
  • Server-backed history pagingloadEarlier is local, and capped at 1000 per group.
  • pb.messaging.enroll(), createGroup, history(), onMessage, claimKeyPackages, listDevices, replenish, resetMessagingLocalState, onKeyChange — all moved out of the public surface.
  • A send(video:) overload.
  • Peer enforcement of disappearing messages — advisory only.
  • Cross-device sync of deleteForMe.
  • CallKit or PushKit payload handling — token registration only.
  • ChatTrust.changed — declared, never produced.

Value types

TypeShape
Chat@MainActor @Observableid, kind, state, title, members, messages, lastMessage, unreadCount, isDirect, typing, onlineMembers, trust, notifyScope, serverUnread, deliveredUpTo, readUpTo
ChatKind / ChatState.direct, .group / .draft, .active
ChatListStorepb.messaging.chatsall: [Chat], onChange(_:), snapshots()
ChatMessageid, kind, direction, senderUserId?, text?, attachment?, serverSeq, sentAt, reply?, reactions, isEdited, isDeleted, mentions
ChatMessageKind / MessageDirection.text, .media, .system / .incoming, .outgoing
ChatMemberid (== userId), userId, displayName? (always nil), role, isSelf
ChatRole.owner, .admin, .member — read-only
ResolvedReply / ReplyStateparentClientMsgId, state, quoteSenderUserId, quoteText?, quoteKind / .resolved, .unavailable
MentionRange / ResolvedMentionstart, length, mentionedUserId (+ displayName?, always nil) — UTF-16 offsets
ExpiryStart.send, .read
ChatTrust / KeyChangeEvent.unverified, .verified, .changed(KeyChangeEvent) (never produced)
SentReceiptserverSeq, epoch
Attachment / AttachmentKindsee Receiving media
PresenceStateuserId, isOnline, lastSeenAt?
SafetyNumberResultversion, safetyNumber, qrPayload: Data, verified
CommsPrefssharePresence, shareReceipts
NotifyScope.all (the default), .none (muted) — compare against NotifyScope.none
UnreadViewgroupMaxSeq, lastReadSeq, deliveredSeq, unreadCount
WakeKind / EnrichedNotification.message, .welcome / title, body, badge?
  • Voice & Video Callschat.startCall, the incoming-call signal, and what PalbeCall adds.
  • Auth — a signed-in user is required for everything on this page.
  • Error Handling — the BackendError cases messaging failures arrive as.
  • Realtime — the shared connection message delivery, presence and typing ride on.
  • App Attest — dormant today; module routes such as messaging would stay exempt when it ships.
  • Messaging on the web — the same protocol from a browser, with a different feature surface.