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: TodosController → todos, 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.jsonwhenever the contract moves. If a method is missing or its types look stale, runpalbase specand 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 shape | Generated 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 headers | A headers: struct with a generated asHeaderDict() |
| No input | A zero-argument method: list() |
| Returns a value | The typed response struct: -> TodosGetResponse |
| Returns an array | A typed array with a named item struct: TodosListResponse = [TodosListResponseItem] |
Returns void | No return value, and no @discardableResult — there is nothing to discard |
@Upload | A different shape: create(file: PBFileSource, input: …, onProgress: …) — see Uploads |
A few properties of the generated types:
- Requests and responses are
Codable & Sendablewith public memberwise initializers, so.init(title: "…")works at the call site. - Properties are camelCase in Swift even though the wire is snake_case (
created_at→createdAt); 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
Codabletypes forpb.call, do not add snake_caseCodingKeys. The SDK already converts between camelCase Swift and snake_case JSON, and explicit snake_case keys fight that conversion and silently producenilfields. 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 object | Rendered |
|---|---|
{ 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 repeatedkey=valuein array order. - Nested objects and
nullare skipped — they are not representable as query parameters. - Booleans render as
true/false, and integral numbers render integrally (page=2, neverpage=2.0). A livepage: 0once 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 segments | Rendered |
|---|---|
["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:
| Reserved | Owned by |
|---|---|
auth | pb.auth |
analytics, perf | pb.analytics and pb.perf |
flags | pb.flags |
realtime | pb.realtime |
notifications | pb.notifications — push token registration |
messaging, debug, purchases | pb.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.
| Behavior | Details |
|---|---|
| Wire format | JSON, camelCase ↔ snake_case both ways, ISO 8601 dates |
| Auth | The signed-in user's bearer token is attached automatically, refreshed before it expires, and a 401 triggers one refresh-and-replay (Auth) |
| Idempotency | An 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 limits | A 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) |
| Correlation | A fresh X-Request-Id per request, plus SDK version, app version, platform and OS version |
| Analytics identity | X-Distinct-Id carries the current analytics identity so server traces stitch anonymous and signed-in activity (Analytics) |
The full header set:
| Header | Value |
|---|---|
apikey | the publishable key — never sent as Authorization |
Authorization: Bearer … | the session access token, unless the path is on the unauthenticated allowlist |
Content-Type | application/json on body-carrying requests |
Idempotency-Key | a fresh key per non-GET request, reused across retries |
X-Request-Id | req_<uuid>, fresh per request |
X-Palbase-Sdk-Version · X-Palbase-Client-Version | the SDK's version, and your app's CFBundleShortVersionString |
X-Platform · X-OS-Version | ios / macos, and major.minor.patch |
X-Palbase-Bundle · X-Distinct-Id | the app's bundle identifier, and the analytics identity |
DPoP | an RFC 9449 proof, unless a service-role key is in use |
A header is either correct or absent, never empty — X-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/notificationsand/v1/analyticsare 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
403carryingpow_requiredis solved transparently and the request retried withX-PoW-Challenge-IDandX-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
503for 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.
Related
- Codegen — how the typed methods get into your project
- Error Handling —
BackendError, typed failure enums, and what each status maps to - Uploads — the one multipart
POSTan@Uploadroute 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