Palbase
Sign inGet started

Web SDK

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:

BackendClient
Controller class name minus a trailing Controller, first letter lowercasedThe namespace on pbTodosControllerpb.todos
Controller method nameThe operation — listpb.todos.list
{id}-style path parametersLeading call arguments, in path order
@Body schemaThe input argument, sent as the request body
@QueryParams schemaThe input argument, serialized into the URL
The method's return typeThe resolved value's type

Warning: The namespace comes from the class name, never from the base path. @Controller("/todos") export class InboxController generates pb.inbox, not pb.todos — the mount path does not enter the namespace at all. If a call you expected is not on pb, 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_value before the request is built — an object would otherwise hit the wire as [object Object].
  • undefined and null values 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 QUERY verb (RFC 10008) for reads that genuinely need a body. The web runtime gives it no special handling: it treats every method that is not literally GET as a body method, and QUERY is not in the mutating set, so such a call ships its input as a JSON body without an Idempotency-Key. Whether the contract web codegen consumes ever emits QUERY is 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;
}
OptionPurpose
headersExtra headers, merged over the SDK's defaults. Supplying your own Idempotency-Key in any casing suppresses the generated one.
signalCancels 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 failed fetch as a network error, retries with backoff, and after the last attempt throws with code network_error and kind network. The aborted code belongs to pb.upload alone, which cancels its own XMLHttpRequest. To tell a cancellation from a real network failure on a typed call, check your own controller.signal.aborted.

The request pipeline

You never build a request by hand. Every call goes through the same path:

  1. Standard headers. Content-Type: application/json; your publishable key on the apikey header, never on Authorization; X-Client-Info: palbe-web/8.0.0; X-Platform: web in a browser; X-Distinct-Id from analytics; X-Palbase-Bundle carrying the browser origin as a diagnostic; and, when a user is signed in, Authorization: Bearer <accessToken>.
  2. Automatic Idempotency-Key. A random UUID on POST, PUT, PATCH and DELETE — unless you supplied one yourself.
  3. Transparent retries. Up to 3 attempts, 200 ms exponential backoff, capped at 10 s per sleep. A 429 honours Retry-After under that same clamp. Retries reuse the same Idempotency-Key, so a retried mutation is never applied twice.
  4. Proof of work. A 403 answering pow_required with a challenge is solved and the request repeated once, carrying X-PoW-Challenge-ID and X-PoW-Nonce. A network retry deliberately drops those headers — the server may already have spent the single-use challenge — while a 429 retry keeps them, because a 429 is issued instead of the work. See Overview.
  5. Reactive token refresh. A 401 triggers one refresh and one retry with the same Idempotency-Key. If the refresh fails terminally the session is cleared locally and the original 401 is thrown. This step is skipped entirely when the 401's code is session_revoked: that is a dead session, and the SDK tears the local one down instead of retrying.
  6. Decode or throw. A 2xx resolves with the decoded body; anything else throws — see Error Handling.

Note: Using pb before the palbase/client barrel has been imported rejects every call with a notConfigured error naming palbase link. Import the barrel once at app startup; the wrapper-module pattern makes that impossible to forget.

Note: A brand-new project can answer 503 for 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' }],
});
Signaturepb.call<O = unknown>(name: string, input?: unknown, options?: CallOptions): Promise<O>
MethodAlways POST
Path/<name> — a leading / is optional and normalized in
TypingNone. You supply O.

Warning: pb.call is always a POST. The typed namespaces are the only way to reach GET, PATCH, PUT and DELETE endpoints. Reaching for pb.call against an endpoint you already have types for buys nothing — it goes through the identical pipeline and loses the typing. Regenerate palbe.gen.ts instead.

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 nameWhyWhat a colliding controller does
auth, analytics, flags, realtimeThe built-in modules the emitter knows about (matched case-insensitively)Skipped at generation
call, uploadThe built-in escape hatchesSkipped at generation
then and the Object.prototype namesA then property would make await pb.<ns> hangSkipped at generation
calls, messaging, perfThe built-in modules the emitter does not know aboutGenerated, 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, messaging and perf are the dangerous three. They are reserved by the runtime but absent from the emitter's skip set, so a CallsController, MessagingController or PerfController is generated into __registerNamespaces(...) like any other namespace. That call sits at the top level of palbe.gen.ts, and it throws BackendError('validation') with code reserved_namespace the 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:

codekindTrigger
not_configurednotConfiguredpb used before the palbase/client barrel was imported
invalid_endpoint_namevalidationEmpty name passed to pb.call
missing_path_paramvalidationA path parameter that is not a string or number
unexpected_argumentvalidationAn extra argument on a no-input endpoint
invalid_query_valuevalidationA query value that is not a string, number or boolean
unsupported_get_inputvalidationBody 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.

  • Error Handling — the seven BackendError kinds and the generated typed error classes
  • Overview — the pb singleton, the header table, and proof of work in full
  • Uploads — multipart uploads, and how they differ from this pipeline
  • Codegen — keeping palbe.gen.ts in sync with your deployed backend
  • Next.jspbServer() and the per-request client
  • Controllers & Routing — the backend side of this mapping
  • Responses & Errors — declaring typed errors your client can catch