Calling Your Backend
Every endpoint you write in your Palbase backend becomes a fully typed method on pb. Codegen reads your deployed backend's contract and writes palbe.gen.ts, which registers a descriptor tree and augments the PB interface — one namespace per controller, one method per endpoint, with request types, response types, path parameters and declared errors all flowing from your controller signatures. This page covers the typed call surface, the argument rules the runtime enforces before anything leaves the browser, and what the SDK does on the wire for you.
Quick example
import { pb } from '@/lib/palbe'; // the wrapper module that imports the palbase/client barrel
// POST /todos — input typed from the controller's @Body schema,
// response typed from the method's return type:
const todo = await pb.todos.create({ title: 'Ship the docs', priority: 'high' });
// GET /todos/{id} — path params are leading arguments:
const fetched = await pb.todos.get(todo.id);
// GET /todos?done=false&limit=20 — a @QueryParams schema is serialized into the URL:
const list = await pb.todos.list({ limit: 20, done: false });
// PATCH /todos/{id} — path params first, then the input:
await pb.todos.update(todo.id, { completed: true });
// DELETE /todos/{id}:
await pb.todos.remove(todo.id);
Calls resolve with the decoded response value and throw on failure — there is no { data, error } envelope anywhere on this surface. See Error Handling for what gets thrown.
How your backend maps to pb
// modules/todos/todos.controller.ts — in your backend
import { Body, Controller, Get, Param, Patch, Post, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { z } from "zod";
export const TodoSchema = z.object({ id: z.string(), title: z.string(), completed: z.boolean() });
export type TodoSchema = z.infer<typeof TodoSchema>;
export const CreateTodoBody = z.object({ title: z.string().min(1), priority: z.enum(["low", "normal", "high"]).optional() });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
@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> { /* … */ }
@Patch("/{id}")
async update(@Param("id") id: string, @Body(CreateTodoBody) body: CreateTodoBody): Promise<TodoSchema> { /* … */ }
}
const todos = await pb.todos.list(); // GET /todos
const todo = await pb.todos.create({ title: 'Hi' }); // POST /todos
await pb.todos.update(todo.id, { completed: true }); // PATCH /todos/{id}
The rules:
| Backend | Client |
|---|---|
Controller class name minus a trailing Controller, first letter lowercased | The namespace on pb — TodosController → pb.todos |
| Controller method name | The operation — list → pb.todos.list |
{id}-style path parameters | Leading call arguments, in path order |
@Body schema | The input argument, sent as the request body |
@QueryParams schema | The input argument, serialized into the URL |
| The method's return type | The resolved value's type |
Warning: The namespace comes from the class name, never from the base path.
@Controller("/todos") export class InboxControllergeneratespb.inbox, notpb.todos— the mount path does not enter the namespace at all. If a call you expected is not onpb, check the class name first.
Return types are the response schema, so a controller method must return a named zod-inferred type — Promise<TodoSchema> or Promise<TodoSchema[]>, resolving to a same-named exported zod const. An inline object return type is a hard deploy error, not a warning. void or no annotation means no response body, and the generated method's return type is Promise<void>.
Argument order
Always path params → input → options.
await pb.todos.share(id, { email: 'friend@example.com' }, { signal });
// ^ path ^ input ^ CallOptions
Endpoints with no input — a GET with no query schema, a DELETE with no body — skip the input slot entirely, so options come straight after the path params:
const todo = await pb.todos.get(id); // input: none
const gone = await pb.todos.remove(id, { signal }); // options right after the path param
When an operation declares required headers, the trailing argument becomes required too and its type narrows to CallOptions & { headers: { … } }.
Query input
A @QueryParams schema serializes into the URL instead of a body, and the serializer is strict:
// GET /discover/weather?city=Berlin&units=metric
const weather = await pb.discover.weather({ city: 'Berlin', units: 'metric' });
- Values must be strings, numbers or booleans. Anything else throws
invalid_query_valuebefore the request is built — an object would otherwise hit the wire as[object Object]. undefinedandnullvalues are dropped.- Keys are sorted lexicographically, then both key and value are
encodeURIComponentd. An empty or absent input produces no query string at all.
Path parameter values must be strings or numbers, and are URL-encoded for you.
GET takes no body, and reads are GET
A read generated from a @QueryParams endpoint is a real HTTP GET with the input in the query string — no body, and therefore no Idempotency-Key, since GET is not in the mutating set. Passing a body-style input to a GET descriptor throws unsupported_get_input rather than silently dropping it.
Note: The backend also supports the HTTP
QUERYverb (RFC 10008) for reads that genuinely need a body. The web runtime gives it no special handling: it treats every method that is not literallyGETas a body method, andQUERYis not in the mutating set, so such a call ships its input as a JSON body without anIdempotency-Key. Whether the contract web codegen consumes ever emitsQUERYis not something this SDK decides.
Under the hood: the descriptor
Each generated operation is one entry in the tree palbe.gen.ts registers:
export interface EndpointDescriptor {
method: string; // uppercased at registration
path: string; // may contain {name} placeholders
pathParams?: string[]; // consumed as LEADING call args, in order
input?: 'body' | 'query' | 'none'; // 'body' is the default when absent
errors?: Record<string, (e: BackendError) => Error>;
}
Nothing about a call is decided at runtime by inspecting your arguments — the descriptor decides, and the arguments are checked against it.
Call options
Every generated method, plus pb.call and pb.upload, takes an optional CallOptions last:
export interface CallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
| Option | Purpose |
|---|---|
headers | Extra headers, merged over the SDK's defaults. Supplying your own Idempotency-Key in any casing suppresses the generated one. |
signal | Cancels the in-flight request. An aborted call rejects with a network-kind BackendError whose code is network_error — see the warning below. |
const controller = new AbortController();
const promise = pb.todos.list({ limit: 20 }, { signal: controller.signal });
controller.abort(); // promise rejects
Warning: do not branch on
code === 'aborted'here. The transport does not special-case an abort: it treats the failedfetchas a network error, retries with backoff, and after the last attempt throws with codenetwork_errorand kindnetwork. Theabortedcode belongs topb.uploadalone, which cancels its ownXMLHttpRequest. To tell a cancellation from a real network failure on a typed call, check your owncontroller.signal.aborted.
The request pipeline
You never build a request by hand. Every call goes through the same path:
- Standard headers.
Content-Type: application/json; your publishable key on theapikeyheader, never onAuthorization;X-Client-Info: palbe-web/8.0.0;X-Platform: webin a browser;X-Distinct-Idfrom analytics;X-Palbase-Bundlecarrying the browser origin as a diagnostic; and, when a user is signed in,Authorization: Bearer <accessToken>. - Automatic
Idempotency-Key. A random UUID onPOST,PUT,PATCHandDELETE— unless you supplied one yourself. - Transparent retries. Up to 3 attempts, 200 ms exponential backoff, capped at 10 s per sleep. A
429honoursRetry-Afterunder that same clamp. Retries reuse the sameIdempotency-Key, so a retried mutation is never applied twice. - Proof of work. A
403answeringpow_requiredwith a challenge is solved and the request repeated once, carryingX-PoW-Challenge-IDandX-PoW-Nonce. A network retry deliberately drops those headers — the server may already have spent the single-use challenge — while a429retry keeps them, because a 429 is issued instead of the work. See Overview. - Reactive token refresh. A
401triggers one refresh and one retry with the sameIdempotency-Key. If the refresh fails terminally the session is cleared locally and the original401is thrown. This step is skipped entirely when the 401's code issession_revoked: that is a dead session, and the SDK tears the local one down instead of retrying. - Decode or throw. A 2xx resolves with the decoded body; anything else throws — see Error Handling.
Note: Using
pbbefore thepalbase/clientbarrel has been imported rejects every call with anotConfigurederror namingpalbase link. Import the barrel once at app startup; the wrapper-module pattern makes that impossible to forget.
Note: A brand-new project can answer
503for a short while after creation. The CLI waits that out at link and push time, so an app built from a linked checkout never meets it.
The transport internally parses a PostgREST-style Content-Range header into a row count, but that count is not surfaced to pb callers — a generated method resolves with the decoded body and nothing else. Return the total in your response schema if you need it.
pb.call — the untyped escape hatch
const result = await pb.call<{ count: number }>('todos/bulk', {
items: [{ title: 'one' }, { title: 'two' }],
});
| Signature | pb.call<O = unknown>(name: string, input?: unknown, options?: CallOptions): Promise<O> |
| Method | Always POST |
| Path | /<name> — a leading / is optional and normalized in |
| Typing | None. You supply O. |
Warning:
pb.callis always aPOST. The typed namespaces are the only way to reachGET,PATCH,PUTandDELETEendpoints. Reaching forpb.callagainst an endpoint you already have types for buys nothing — it goes through the identical pipeline and loses the typing. Regeneratepalbe.gen.tsinstead.
An empty name, or a bare /, rejects with a validation error:
invalid_endpoint_name: Endpoint name must be a non-empty path like "todos/create"
That check runs before the runtime is resolved, so an empty name fails as validation even when pb was never configured.
For multipart file uploads use pb.upload instead. It is a different code path on purpose: it streams multipart bodies and therefore bypasses the shared transport, which means no proof-of-work retry, no reactive-401 retry, no perf trace, and none of the Content-Type, X-Platform or X-Distinct-Id headers listed above.
Reserved namespace names
Nine top-level names are fixed surfaces on pb and can never become generated namespaces. But the emitter and the runtime disagree about which of them it catches, and the gap decides whether you get a missing method or a crash on import.
| Reserved name | Why | What a colliding controller does |
|---|---|---|
auth, analytics, flags, realtime | The built-in modules the emitter knows about (matched case-insensitively) | Skipped at generation |
call, upload | The built-in escape hatches | Skipped at generation |
then and the Object.prototype names | A then property would make await pb.<ns> hang | Skipped at generation |
calls, messaging, perf | The built-in modules the emitter does not know about | Generated, then throws at import |
A skipped controller is not an error. The emitter drops it and leaves a comment in the generated file:
// codegen: skipped reserved namespace "auth"
The namespace simply never appears on pb, and the build succeeds. Rename the class.
Warning:
calls,messagingandperfare the dangerous three. They are reserved by the runtime but absent from the emitter's skip set, so aCallsController,MessagingControllerorPerfControlleris generated into__registerNamespaces(...)like any other namespace. That call sits at the top level ofpalbe.gen.ts, and it throwsBackendError('validation')with codereserved_namespacethe moment the module is imported. You do not get a missing method — you get an app that fails to start on every page. Rename the controller.
Errors thrown before the wire
These come from the SDK itself, with no request sent:
code | kind | Trigger |
|---|---|---|
not_configured | notConfigured | pb used before the palbase/client barrel was imported |
invalid_endpoint_name | validation | Empty name passed to pb.call |
missing_path_param | validation | A path parameter that is not a string or number |
unexpected_argument | validation | An extra argument on a no-input endpoint |
invalid_query_value | validation | A query value that is not a string, number or boolean |
unsupported_get_input | validation | Body input passed to a GET descriptor |
The full list of codes the SDK can throw, including the upload and Next.js ones, is on Error Handling.
Related
- Error Handling — the seven
BackendErrorkinds and the generated typed error classes - Overview — the
pbsingleton, the header table, and proof of work in full - Uploads — multipart uploads, and how they differ from this pipeline
- Codegen — keeping
palbe.gen.tsin sync with your deployed backend - Next.js —
pbServer()and the per-request client - Controllers & Routing — the backend side of this mapping
- Responses & Errors — declaring typed errors your client can catch