Palbase
Sign inGet started

iOS SDK

Overview

Palbe is the Palbase SDK for Apple platforms. One package URL vends four products — three stacked layers (PalbePalbeMessagingPalbeCall) and one independent sibling (PalbePurchases) — and everything you call lives on a single global value, pb. There is no client to construct and no credential in your source: the SDK configures itself on the first pb.* access by reading Palbase-Info.plist out of your app bundle, a file the CLI generates and you commit. This page covers which product to link, what linking each one costs an App Store submission, how the configuration is resolved, and what is on pb.

Quick example

cd ~/code/TodoApp
palbase link todoapp --platform ios
# wrote palbase/environments/main/ios-config.json
# wrote palbase/environments/staging/ios-config.json
#
# Add these to your own build configuration (xcconfig, build settings, Tuist —
# whichever you already use). PALBASE_ENV is the only line you change; the other
# two never do:
#
#     PALBASE_ENV = main
#     EXCLUDED_SOURCE_FILE_NAMES = */palbase/environments/*/*
#     INCLUDED_SOURCE_FILE_NAMES = */palbase/environments/$(PALBASE_ENV)/*
#
# Then add palbase/environments to your app target. …
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/main/PalbaseGenerated.swift
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/main/Palbase-Info.plist
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/staging/PalbaseGenerated.swift
# ✓ wrote /Users/you/code/TodoApp/palbase/environments/staging/Palbase-Info.plist
#
# linked to todoapp (proj_01)
#   contract read from main; each verb resolves its own environment
# commit palbase/
import Palbe

let todos = try await pb.todos.list()
let created = try await pb.todos.create(.init(title: "Ship the iOS app"))
try await pb.todos.delete(id: created.id)

No base URL, no API key, no configure call with credentials — that overload is internal and there is no public equivalent. See Codegen for everything the link command writes.

The four products

ProductAddsWhat linking it costs
Palbepb — generated typed calls, pb.call, pb.upload, pb.auth, pb.flags, pb.realtime, pb.analytics, pb.perf, pb.notifications, pb.debugFoundation only
PalbeMessagingpb.messaging, Chat, the whole call surface and signaling, the CallMediaTransport seam, the Rust MLS enginethe PalbeMlsFFI binary; ITSAppUsesNonExemptEncryption becomes true
PalbeCallthe built-in LiveKit media transport, registered by PalbeCall.enable()LiveKit's ~29 MB WebRTC stack; mic/camera purpose strings
PalbePurchasespurchases — StoreKit 2. Not usable on this cloud — see belownothing; it depends on no other product

Palbe, PalbeMessaging and PalbeCall are stacked: each one carries every layer beneath it exactly once, so you add exactly one of the three to your app target — the highest one you need. PalbePurchases is a sibling, not a layer: it stacks on nothing and can be added beside any of the three, or alone.

The split between the top two layers is the one that matters most for app size. The call surfaceCall, IncomingCall, CallParticipant, CallCoordinator, chat.startCall(...), ringing, decline, host moderation and all the signaling — ships in PalbeMessaging. PalbeCall adds only the media transport that actually carries audio and video, plus one public function:

import PalbeCall

@main
struct MyApp: App {
    init() { PalbeCall.enable() }   // once, at startup, before any startCall/accept
    var body: some Scene { WindowGroup { RootView() } }
}

So an app that chats but never calls links PalbeMessaging alone and never carries WebRTC. That is the whole reason the layer exists: a 42 MB app of which 29 MB was unused WebRTC shipped once, and Apple's ITMS-90683 (missing purpose string) is what surfaced it. Without PalbeCall linked and PalbeCall.enable() called, chat.startCall(...) and incoming.accept() throw BackendError.mediaUnavailable — a signaling-only app is a supported shape, not a broken one.

Note: SwiftPM makes two upstream packaging modules — LiveKitWebRTC and RustLiveKitUniFFI — visible to any target that links PalbeCall. They are implementation artifacts, not Palbe API; do not import them. An app on Palbe or PalbeMessaging never sees them, and import LiveKit fails for every consumer: LiveKit is an access-level internal import and a release is blocked if a LiveKit symbol appears in the public interface.

PalbePurchases does not work against Palbase

PalbePurchases is genuinely shipped — it is a real product of the package, and every release publishes its XCFramework — but it does not talk to your Palbase backend. Its calls go to palstore, a separate service Palbase does not operate: on a live Environment its three paths answer 404, and there is no palstore behind any palbase.studio hostname. Two further gaps mean that supplying your own palstore takes two explicit steps rather than none. The CLI never writes a purchases block into palbase-config.json or Palbase-Info.plist (and re-running link would delete one you added by hand), so auto-configuration always throws PurchasesError.notConfigured — call purchases.configure(url:publishableKey:) yourself at launch instead, which is public, shipped API. And it never passes --purchases-catalog to the generator, so the generated catalog constants do not exist — use the String overload of requiresEntitlement(_:paywall:). The backend half matches: Purchases, @RequireEntitlement and @Spend are exported by @palbase/backend but are not injected by the v2 runtime, so a decorated route answers 500 internal_error on every authenticated request. It fails closed — it never grants access — but it never grants it correctly either. Treat in-app purchases as unavailable on this platform today.

Install

Add the package in Xcode — File ▸ Add Package Dependencies… with the URL https://github.com/palgroup/palbackend-ios — or declare it in Package.swift:

dependencies: [
    .package(url: "https://github.com/palgroup/palbackend-ios", from: "0.53.0")
]

from: is a floor, not a pin — SwiftPM reads it as .upToNextMajor, so 0.53.0 resolves to the newest 0.x tag on the repository and keeps resolving forward as releases ship; you do not have to edit it to stay current. Pin a single build with exact: "0.53.0" instead, and check the releases page for what is newest today. Then add one library to your app target:

.target(
    name: "MyApp",
    dependencies: [.product(name: "Palbe", package: "palbackend-ios")]
)

Requirements

PlatformMinimumDistributed slices
iOS / iPadOS18.0ios-arm64, ios-arm64-simulator
macOS15.0macos-arm64

There are no Mac Catalyst, Intel macOS, tvOS, watchOS or visionOS slices, and no @available back-compat shims — that is a deliberate policy, not an omission. The SDK is Swift 6 with strict concurrency (swiftLanguageModes: [.v6]) and ships as prebuilt binary XCFrameworks.

Two submission details follow from which product you linked:

Product linkedITSAppUsesNonExemptEncryptionInfo.plist purpose strings
Palbefalsenone
PalbeMessagingtrue (MLS)none
PalbeCalltrue (DTLS-SRTP)NSMicrophoneUsageDescription, and NSCameraUsageDescription for video
PalbePurchasesfalsenone

Privacy manifests ship inside each framework, one per product. Palbe's declares NSPrivacyAccessedAPICategoryUserDefaults and collects email address, user id, coarse location, product interaction and diagnostic data — all linked to the user, none used for tracking. PalbeMessaging's declares file-timestamp access and collects device id and user id. PalbeCall's is composed at build time as the union of its own (empty) first-party half and LiveKit's resolved manifest, so what ships declares NSPrivacyAccessedAPICategorySystemBootTime with reasons 35F9.1 and 8FFB.1; the release build fails outright if that union would declare nothing. PalbePurchases' declares nothing at all. NSPrivacyTracking is false in all four.

Wiring the contract

One command from your Xcode project's directory binds the checkout and writes everything the app and the generator read:

palbase link <ref-or-url> --platform ios

That writes the committed palbase/project.json (the binding), one OpenAPI contract per environment at palbase/environments/<env>/openapi.json, the iOS slot at palbase/environments/<env>/ios-config.json, and — for every environment whose contract it could fetch — that environment's own generated Swift client at palbase/environments/<env>/PalbaseGenerated.swift plus its own palbase/environments/<env>/Palbase-Info.plist. On an Apple platform it also prints a one-time environment-selection snippet (PALBASE_ENV, EXCLUDED_SOURCE_FILE_NAMES, INCLUDED_SOURCE_FILE_NAMES) for you to add to your own build configuration — the CLI writes no xcconfig of its own any more. Commit all of palbase/: none of it is gitignored, and link actively repairs a .gitignore that tries to narrow or ignore it. The one thing that stays off the checkout is per-machine state — the stack palbase start brought up here, and what palbase plan last measured — which lives under ~/.palbase/checkouts/<hash>/, outside any repository. See Linking a Checkout and Codegen.

palbase link is the same wiring reached from the platform side, and re-running it is how you refresh that wiring later — Codegen and there is nothing to re-target, because each link writes every environment and the Xcode build configuration picks between them. There is no build-tool plugin: the client is ordinary committed source, so it is in autocomplete before the first build, arrives as a reviewable git diff, and a fresh clone compiles with no CLI, no network and no Xcode trust prompt.

How configuration works

Palbase-Info.plist is the SDK's sole configuration source, but it no longer carries every environment. Every environment has its own directory and its own plist, and the app bundle a build produces holds exactly one of them — the one whose directory INCLUDED_SOURCE_FILE_NAMES selected. There is nothing left to resolve by name at runtime. Each plist is a flat {ios?, macos?} envelope, and each populated slot IS one environment's config:

// palbase/environments/main/ios-config.json — the committed input swiftgen reads
{
  "app_id": "app_...",
  "base_url": "https://k3xq81w4m.palbase.studio",
  "api_key": "pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6",
  "oauth": {
    "contract_revision": 1, "config_revision": "2", "environment_ref": "project",
    "application_key": "consumer", "platform": "ios", "variant": "release",
    "clients": [{
      "key": "google-ios", "provider": "google", "mode": "native", "adapter": "google_ios_pkce",
      "bundle_id": "com.example.app", "ios_client_id": "IOS.apps.googleusercontent.com",
      "redirect_uri": "com.googleusercontent.apps.IOS:/oauthredirect"
    }]
  }
}

swiftgen turns this into palbase/environments/main/Palbase-Info.plist — the same flat fields, wrapped in an {ios, macos} envelope so one checkout can serve both Apple platforms of the same environment from one file. There is no default_environment and no environment map any more: that shape existed only because the plist lived at one path, and pointing an app at another environment meant overwriting it. Every environment now has its own directory, so two build configurations can point at main and at local at the same time without either one overwriting the other. The web slot (palbase/environments/<env>/web-config.json) is the same flat shape for a different generator — do not copy one into the other.

On the first pb.* access the SDK reads the one Palbase-Info.plist in Bundle.main and wires the HTTP client, session storage, token refresh, flags sync, realtime and analytics.

The platform slot is decided at compile time. An iOS or iPadOS build reads only ios; a macOS build reads only macos. If the current platform's slot is missing, configuration fails — it never borrows the sibling's values.

The environment is decided before the SDK ever runs — by which directory's files the build included, per Environments and build configurations below. There is no PALBASE_ENV Info.plist key and no process-environment fallback inside the SDK: with exactly one plist in the bundle, there is nothing left to pick between at runtime.

Failures are the public, Equatable AppConfigError, and autoConfigureFromBundle() turns each one into a fatalError("Palbe: …") rather than a silent fallback:

CaseWhenWhat the message says
.missingConfigPalbase-Info.plist is absent, unreadable, malformed, or carries no slot for this platformrun the platform link command, then rebuild
.missingAPIKeythe bundled slot's api_key is emptythe local stack was down when palbase link wrote the plist — run palbase start, then palbase spec, then rebuild

That last case is why link records a keyless local environment rather than omitting it: an app whose local configuration vanishes because a container was stopped is an app that stops working for a reason nobody connects to the container.

The key does not name the Environment

The embedded key is a publishable key, safe to ship in your binary, and it is sent on the apikey request header — never as Authorization. Its format is pb_<ref>_<scope><random>, and the SDK reads the middle segment as the project ref. On this cloud that segment is the compile-time constant project for every project, so every Environment's publishable key reads pb_project_c…. It tells you nothing about which Environment you are talking to.

The address is what selects the Environment, and it comes from that environment's base_url in the plist. The SDK never derives a base URL from a key, and a environment_ref field beside the key was removed: it was a copy that had to equal its original, and on 2026-08-16 it did not — link wrote one name while the key carried another, and everything derived from the wrong one named a channel nobody else used while both ends reported success. See the two API keys.

Note: A per-environment sealed_root field exists in the plist format — the base64 Ed25519 root a self-hosted stack's sealed keyset is signed by, needed because /auth/* bodies must be sealed and a client shipped with only the platform's roots cannot verify a stack signed by its own. No CLI command writes it today; an Environment on the Palbase cloud does not need it.

Environments and build configurations

Switching Environment is a build-configuration change, not a runtime swap — and, unlike before, it is wiring you own rather than a file the CLI writes for you. palbase link (on an Apple platform) prints this once:

PALBASE_ENV = main
EXCLUDED_SOURCE_FILE_NAMES = */palbase/environments/*/*
INCLUDED_SOURCE_FILE_NAMES = */palbase/environments/$(PALBASE_ENV)/*

Add these to your own build configuration (xcconfig, build settings, Tuist — whichever you already use), and add palbase/environments to your app target as a folder reference. PALBASE_ENV is the only line you ever change per build configuration; the other two are environment-agnostic and are written once, which is why the CLI can print them instead of maintaining a file. The two-level glob is not a typo: measured on a real Xcode 26.6 build, * does not cross a directory boundary in these settings, so a single-level */palbase/environments/* excludes nothing at all, while the two-level form above excludes correctly in both directions — the unselected environment's Palbase-Info.plist never enters the app bundle. An unset PALBASE_ENV expands to nothing in Xcode, so the include pattern matches nothing and the app ships with no plist at all — there is no default it silently falls back to.

This replaces per-environment xcconfig files the CLI used to write into Palbase/Config/. It could not finish that mechanism — assigning an xcconfig to a build configuration is a .pbxproj edit it never made — so a build in one configuration could still read another's settings; measured on a real simulator, the Local configuration signed up against the main environment's address while every build setting still read local. Printing the pattern once, for you to place, removes that half-mechanism rather than completing it.

The two public configuration calls

There are exactly two, and neither takes a credential:

pb.configure(onError: { error in
    Logger.backend.error("\(error.code): \(error.localizedDescription)")
})

pb.configure(messagingAppGroup: "group.net.pallasite.palbase.palbe")
BehaviorDetail
When onError firesOnce for every BackendError thrown by a backend RPC — generated methods, pb.call, and typed @Upload calls — before the error is thrown
Even for try?Yes: the hook runs first, so a swallowed error is still observed
Not coveredThe untyped pb.upload(_:fileURL:) / pb.upload(_:fileData:) overloads, and every pb.auth.* failure (AuthError)
FilteringNone built in — filter on error.code inside the hook
ReplacingCalling it again replaces the hook; passing nil removes it

configure(messagingAppGroup:) is for push: call it once at launch, before any pb.messaging.* use. It records the App Group, moves the session token into the shared keychain access group, and rebinds token storage if the SDK is already wired. See Messaging.

What's on pb

SurfaceWhat it isDocs
Generated endpoint methodsTyped calls for every backend endpoint, with typed throwsCalling Your Backend
pb.callThe untyped escape hatch — any verb, your own typesCalling Your Backend
pb.uploadOne multipart POST to an @Upload route, with progressUploads
pb.authEmail, Apple, Google, OTP, passkey and magic-link sign-in; persistent sessionsAuth
pb.flagsObservable feature flags — synchronous reads, per-key SwiftUI re-renderingFlags
pb.realtimeChannels over one shared WebSocket — subscribe and publishRealtime
pb.analyticsFire-and-forget event capture with automatic identity bindingAnalytics
pb.perfstartTrace, record, trackScreen — app-start and network traces are automaticAnalytics
pb.notificationsregisterDeviceToken(_:) (APNs) and registerVoipToken(_:) (PushKit), both fire-and-forgetVoice & Video Calls
pb.debugThe in-app network and log console — records from launch, gated by a server flagDebug Console
pb.setTestDevice(_:)Marks this process's perf and analytics flushes with X-Palbase-Test-Device: 1Analytics
pb.messaging — needs PalbeMessagingEnd-to-end encrypted chats and the call surface; requires a signed-in userMessaging

App Attest has no client namespace or enable flag. Configure the environment policy in Authentication → Settings or with palbase auth settings set. The SDK enrolls and retries when a backend answers 401 app_attest_required. See App Attest.

Note: Nine namespaces on pb are SDK-owned and reserved: auth, analytics, flags, realtime, notifications, perf, messaging, purchases and debug. A backend endpoint whose operation id starts with one of them is skipped by codegen, with a visible // codegen: skipped reserved namespace "<ns>" (SDK-owned) comment in the generated file. Name your controllers around them — see Codegen.

Every request, automatically

Every call — typed, untyped or generated — lowers onto one transport path, and that path owns:

  • JSON coding. Swift camelCase ↔ snake_case on the wire, ISO 8601 dates, both directions. Generated types rely on it; hand-written Codable types must too.
  • Auth. The session bearer token is attached when signed in, refreshed before it expires, and refreshed-and-replayed once on a 401. Eleven /auth/* paths are on an allowlist that never carries a stale bearer.
  • Idempotency. Every non-GET request carries an Idempotency-Key, reused across the transport's retries and across the App Attest retry, so a retried create never double-writes.
  • Rate limiting. A generic 429 is retried honoring Retry-After.
  • Correlation. A fresh X-Request-Id per request, plus the SDK version, your app's version, the platform and the OS version. A header is either correct or absent — never empty.
let requestID = await PalbeCorrelation.shared.lastRequestID  // "req_..."

PalbeCorrelation.shared.lastRequestID holds the id of the most recently completed request, success or failure — quote it in a support ticket. The full header table is in Calling Your Backend.

Debug tracing

Looking for something you can read inside the app? That is the Debug Consolepb.debug.view. This section is about the unified-logging stream, for when you want the output in Console.app or a terminal.

The SDK logs every HTTP exchange (method, URL, redacted headers, truncated body, status, duration, request id) to the unified logging system — off by default, enabled per run:

SwitchWhere to set it
PALBASE_DEBUG=1Xcode scheme ▸ Run ▸ Arguments ▸ Environment Variables
-PalbaseDebug YESXcode scheme ▸ Run ▸ Arguments Passed On Launch

Logs go to subsystem studio.palbase.sdk, category http. Stream them from a Simulator:

xcrun simctl spawn booted log stream --predicate 'subsystem == "studio.palbase.sdk"'

apikey, Authorization and attestation headers are always redacted.

Note: The switch is read once at SDK boot. Toggling the variable mid-run has no effect until the next launch.

  • Codegen — what the CLI writes, one client per environment, and the environment-selection snippet
  • Calling Your Backend — generated methods, pb.call, and the request pipeline
  • Error HandlingBackendError, AuthError, and generated failure enums
  • Auth — sign-in flows, sessions, and listeners
  • Linking a Checkoutpalbase link, and the files that decide what a directory acts on
  • the two API keys — the two keys an Environment is minted with
  • Introduction — the Environment an app talks to