Palbase
Sign inGet started

iOS SDK

Calling Your Backend

Every endpoint you write in your backend controllers becomes a typed Swift method on pb. Codegen reads your deployed API contract and emits request structs, response structs, endpoint descriptions and per-endpoint error enums, so calling your API is a compile-checked function call rather than a string-and-dictionary exercise. Underneath, all three ways to reach your backend — a generated method, the untyped pb.call(name:), and a hand-built PBEndpoint — lower onto one transport path and emit byte-identical requests. This page covers all three, the encoding rules that path enforces, and what it adds to every request.

Quick example

Given this backend controller:

// modules/todos/todos.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param, User, z } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { CreateTodoBody, UpdateTodoBody, TodoSchema } from "./dto/schemas";

@Controller("/todos")
export class TodosController {
  @Get("")
  async list(@User() user: UserT): Promise<TodoSchema[]> { /* … */ }

  @Post("")
  async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> { /* … */ }

  @Get("/{id}")
  async get(@Param("id") id: string): Promise<TodoSchema> { /* … */ }

  @Patch("/{id}")
  async update(@Param("id") id: string, @Body(UpdateTodoBody) body: UpdateTodoBody): Promise<TodoSchema> { /* … */ }

  @Delete("/{id}")
  async delete(@Param("id") id: string): Promise<void> { /* … */ }
}

codegen produces a todos namespace on pb, one method per endpoint:

import Palbe

let todos   = try await pb.todos.list()
let created = try await pb.todos.create(.init(title: "Ship the iOS app"))
let one     = try await pb.todos.get(id: created.id)
let updated = try await pb.todos.update(id: created.id, .init(completed: true))
try await pb.todos.delete(id: created.id)

From controller to Swift method

The namespace comes from your class name, not from the base path: TodosControllertodos, minus the Controller suffix with the first letter lowercased. @Controller("/inbox") class MessagesController is pb.messages, not pb.inbox. The method name is your controller method's name verbatim, and together they are the operation id (todos.create) — so renaming a controller method renames the call in every client. An operation id with no dot lands directly on pb.

Note: Generated methods are committed source, not shipped in the SDK and not produced by a build plugin — there isn't one. The CLI regenerates them from palbase/environments/<env>/openapi.json whenever the contract moves. If a method is missing or its types look stale, run palbase spec and commit the result — see Codegen.

Method shapes

The generated signature follows the endpoint's shape. Arguments appear in a fixed order: path parameters first, then the input, then query:, then headers:.

Endpoint shapeGenerated Swift
Body (@Body)The input struct, unlabeled and last of the leading arguments: create(_ input: TodosCreateRequest)
Path params ({id})Leading named String arguments, in path order: get(id: String), get(orgId:userId:)
Query params (@QueryParams)A query: struct: search(query: TodosSearchQuery)
Declared request headersA headers: struct with a generated asHeaderDict()
No inputA zero-argument method: list()
Returns a valueThe typed response struct: -> TodosGetResponse
Returns an arrayA typed array with a named item struct: TodosListResponse = [TodosListResponseItem]
Returns voidNo return value, and no @discardableResult — there is nothing to discard
@UploadA different shape: create(file: PBFileSource, input: …, onProgress: …) — see Uploads

A few properties of the generated types:

  • Requests and responses are Codable & Sendable with public memberwise initializers, so .init(title: "…") works at the call site.
  • Properties are camelCase in Swift even though the wire is snake_case (created_atcreatedAt); the SDK's coders map both directions.
  • String unions in your zod schemas become nested Swift enums (enum KindValue: String, Codable, Sendable), with Swift keywords escaped (case `public` ).
  • Path parameters are percent-encoded for you — pass raw strings.
  • Nullable<T> appears wherever a PATCH field has three meanings; see below.

Verbs, including QUERY

PBMethod covers six verbs, and all of them are reachable:

public enum PBMethod: String, Sendable {
    case get = "GET", post = "POST", patch = "PATCH", put = "PUT", delete = "DELETE", query = "QUERY"
    public var carriesBody: Bool { self != .get }
}

QUERY (RFC 10008) is a first-class verb: safe and idempotent like GET, body-carrying like POST. A backend @Query("/search") route generates a method whose input rides in the body, not the query string:

public var pbRequest: PBRequest { PBRequest(.query, ["rooms", "search"], body: input) }

Use it for searches whose input is too large or too structured for a query string. It is the one verb people assume does not exist, so it is worth saying plainly: it does.

Typed throws

Every generated method uses Swift typed throws with its own failure enum:

func get(id: String) async throws(TodosGetError) -> TodosGetResponse

Inside a do block the error in catch is already that concrete type, so you can switch on the endpoint's declared cases without casting. Every enum carries .other(BackendError) for everything the endpoint did not declare — transport failures, unknown codes, decode problems — so the enum is always exhaustible and nothing is lost.

do {
    let todo = try await pb.todos.get(id: id)
    show(todo)
} catch {
    // error is a TodosGetError — no `as?` needed
    switch error {
    case .other(let backend):
        showAlert(backend.localizedDescription)
    }
}

See Error Handling for the whole model.

The pb.call escape hatch

When there is no generated method — prototyping against an endpoint you just pushed, or addressing a route dynamically — pb.call invokes an endpoint by path with types you supply. Both overloads are public, and both take a method::

@discardableResult
func call<I: Encodable & Sendable, O: Decodable & Sendable>(
    _ name: String,
    _ input: I,
    as: O.Type = O.self,
    method: PBMethod = .post,
    headers: [String: String] = [:]
) async throws(BackendError) -> O

@discardableResult
func call<O: Decodable & Sendable>(
    _ name: String,
    as: O.Type = O.self,
    method: PBMethod = .post,
    headers: [String: String] = [:]
) async throws(BackendError) -> O   // no-input variant
struct CreateTodoInput: Encodable, Sendable { let title: String }
struct Todo: Decodable, Sendable {
    let id: String
    let title: String
    let completed: Bool
    let createdAt: String   // wire key `created_at` — no CodingKeys needed
}

let todo: Todo = try await pb.call("todos", CreateTodoInput(title: "Try Palbase"))
let list: [Todo] = try await pb.call("todos", method: .get)

name is the endpoint's path — routing is file-based, so the name is the path, with no /rpc/ prefix — and a leading slash is tolerated ("todos" and "/todos" are the same). The default verb is POST; pass method: for anything else. Extra headers: are merged onto the request, and a non-blank value you set wins over the SDK's own.

Note: When you hand-roll Codable types for pb.call, do not add snake_case CodingKeys. The SDK already converts between camelCase Swift and snake_case JSON, and explicit snake_case keys fight that conversion and silently produce nil fields. Name properties in camelCase and let the coders map them.

Hand-rolling a request

The description layer generated code uses is public, so you can build the same request yourself — path segments, body, query and headers — and get the same transport behavior:

public struct PBRequest: Sendable {
    public let method: PBMethod
    public let path: [PBPathSegment]
    public let body: (any Encodable & Sendable)?
    public let query: (any Encodable & Sendable)?
    public let headers: [String: String]
}

public enum PBPathSegment: Sendable, ExpressibleByStringLiteral {
    case literal(String)
    case param(String)
}

public protocol PBEndpoint: Sendable {
    associatedtype Response: Decodable & Sendable
    associatedtype Failure: PBError
    var pbRequest: PBRequest { get }
}

public protocol PBVoidEndpoint: Sendable {
    associatedtype Failure: PBError
    var pbRequest: PBRequest { get }
}
struct ArchiveTodo: PBVoidEndpoint {
    typealias Failure = TodosArchiveError
    let id: String
    var pbRequest: PBRequest { PBRequest(.post, ["todos", .param(id), "archive"]) }
}

try await pb.call(ArchiveTodo(id: todo.id))

pb.call(_ endpoint:) infers both the decoded success type and the typed error from the endpoint itself — no as: argument, no cast. A type conforms to PBEndpoint or PBVoidEndpoint, never both, so the compiler picks the overload unambiguously; the void one has no @discardableResult because there is nothing to discard.

Nullable<T> — the three-way PATCH field

public enum Nullable<Wrapped: Codable & Sendable>: Codable, Sendable {
    case value(Wrapped)
    case null
}

A PATCH field has three intentions and Swift's Optional only expresses two. nil omits the key (leave it alone), .null sends JSON null (clear it), .value(x) sets it. It has an init(_ wrapped: Wrapped?) and a var wrapped: Wrapped? for moving between the two worlds.

AnyCodableValue — open-ended JSON

public enum AnyCodableValue: Codable, Sendable, Equatable {
    case string(String), int(Int), double(Double), bool(Bool)
    case array([AnyCodableValue]), object([String: AnyCodableValue]), null
}

Wherever your contract declares a free-form field, this is the Swift type — the same one realtime payloads and analytics properties use, and the one an error's open details array arrives in.

Encoding rules

Both directions are fixed by the SDK's shared coders: bodies out convert camelCase → snake_case, responses in convert snake_case → camelCase, and dates are ISO 8601 both ways.

Query strings are deterministic — the query object is encoded to JSON and emitted in sorted key order. The SDK's own golden assertions, as input and wire:

Query objectRendered
{ name: "Turkey", page: 2 }?name=Turkey&page=2
{ q: "a b" }?q=a%20b
{ active: true }?active=true
no query(empty)
  • Scalars become one key=value; an array of scalars becomes repeated key=value in array order.
  • Nested objects and null are skipped — they are not representable as query parameters.
  • Booleans render as true / false, and integral numbers render integrally (page=2, never page=2.0). A live page: 0 once rendered as ?page=false, which is why boolean detection is explicit.
  • A space is %20, never +.

Paths: .literal segments go through verbatim; .param segments are percent-encoded with the encodeURIComponent allowed set (A–Za–z0–9-_.!~*'()), matching the web SDK exactly:

Path segmentsRendered
["todos", .param("a/b"), "share"]/todos/a%2Fb/share
["x", .param("a b&c")]/x/a%20b%26c

Reserved endpoint names

Nine namespaces on pb are SDK-owned. Codegen skips any endpoint whose operation id starts with one of them and leaves a // codegen: skipped reserved namespace "<ns>" (SDK-owned) comment in the generated file, so the loss is visible in the diff rather than at run time:

ReservedOwned by
authpb.auth
analytics, perfpb.analytics and pb.perf
flagspb.flags
realtimepb.realtime
notificationspb.notifications — push token registration
messaging, debug, purchasespb.messaging, pb.debug, and the purchases surface

messaging is skipped like the rest. Name a controller so its class produces a different namespace — the skip is not something you can opt out of.

What the SDK does on every request

You configure none of this; it is the behavior of the single transport path all calls share.

BehaviorDetails
Wire formatJSON, camelCase ↔ snake_case both ways, ISO 8601 dates
AuthThe signed-in user's bearer token is attached automatically, refreshed before it expires, and a 401 triggers one refresh-and-replay (Auth)
IdempotencyAn Idempotency-Key on every non-GET request, reused across the transport's retries and across the App Attest retry — a retried create never double-writes
Rate limitsA generic 429 is retried with backoff honoring Retry-After. A 429 your backend throws with its own error code surfaces as .server, so its typed case still matches (Error Handling)
CorrelationA fresh X-Request-Id per request, plus SDK version, app version, platform and OS version
Analytics identityX-Distinct-Id carries the current analytics identity so server traces stitch anonymous and signed-in activity (Analytics)

The full header set:

HeaderValue
apikeythe publishable key — never sent as Authorization
Authorization: Bearer …the session access token, unless the path is on the unauthenticated allowlist
Content-Typeapplication/json on body-carrying requests
Idempotency-Keya fresh key per non-GET request, reused across retries
X-Request-Idreq_<uuid>, fresh per request
X-Palbase-Sdk-Version · X-Palbase-Client-Versionthe SDK's version, and your app's CFBundleShortVersionString
X-Platform · X-OS-Versionios / macos, and major.minor.patch
X-Palbase-Bundle · X-Distinct-Idthe app's bundle identifier, and the analytics identity
DPoPan RFC 9449 proof, unless a service-role key is in use

A header is either correct or absent, never emptyX-Palbase-Client-Version is omitted entirely when the host app has no short version string, rather than sent blank. A non-blank header you pin yourself (through pb.call's headers:) wins over the SDK's own.

Three behaviors of that path are worth knowing even though they have no API:

  • The unauthenticated allowlist. Eleven /auth/* paths — sign-up, log-in, token refresh, password reset and confirm, magic link and verify, verify-email, resend-verification, anonymous, and the OAuth credential exchange — never carry a bearer, so a stale token cannot break a sign-in. Three of them (/auth/login, /auth/signup, /auth/token/refresh) still carry a DPoP proof, because they bind the session.
  • Sealed bodies. Requests under /auth/, /v1/db, /v1/docs, /v1/user-flags, /v1/notifications and /v1/analytics are sealed in an envelope (X-Palbase-Sealed). A request that requires sealing and cannot be sealed is never sent — it fails as .transport, with the body still on the device. Your own backend routes are not in that set.
  • Proof of work. A 403 carrying pow_required is solved transparently and the request retried with X-PoW-Challenge-ID and X-PoW-Nonce. There is no app API; it explains an occasional latency spike on an auth call.

The request ID, for support

The SDK records the X-Request-Id of the most recently completed request — success or failure — so you can attach it to crash reports and support tickets:

let requestId = await PalbeCorrelation.shared.lastRequestID
// e.g. "req_0d5e7c3a-…" — quote this when reporting an issue

Server-side errors also carry their own requestId in the thrown error; see Error Handling.

Note: A brand-new project can answer 503 for a short while after it is created. The SDK surfaces it like any other error; the CLI waits it out at link and push time, so an app built from a linked checkout does not meet it.

Debug logging

To see every request and response while developing, enable the SDK's HTTP trace for a run — set PALBASE_DEBUG=1 or add the launch argument -PalbaseDebug YES in your Xcode scheme. Logs go to the unified logging system (subsystem studio.palbase.sdk, category http) with method, URL, status, duration and request id; apikey, Authorization and attestation headers are redacted and bodies truncated. Read them in Console.app, or from a Simulator:

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

The switch is read once at boot and there is no API to toggle it at run time. For a console you can read inside the app, see Debug Console.

  • Codegen — how the typed methods get into your project
  • Error HandlingBackendError, typed failure enums, and what each status maps to
  • Uploads — the one multipart POST an @Upload route takes
  • Auth — sessions, refresh, and the listeners
  • Controllers & Routing — the backend side of these calls
  • Responses & Errors — declaring typed errors the client can switch on