Palbase
Sign inGet started

Backend SDK

Flags

The Flags service reads feature flags and writes per-user overrides from backend code. A flag's value is resolved on the server, from the Environment's own flag store, so the value a web or iOS client sees for a user is the value your handler sees for that same user. Flag definitions — the key, its type, its Environment-wide default and, for string flags, its variants — live on the stack rather than in your repository: they are written through the Environment's Management API and take effect immediately, with nothing to commit and no deploy to wait for.

Quick example

// modules/checkout/checkout.controller.ts
import { Controller, Flags, Get, z } from "@palbase/backend";

export const CheckoutConfig = z.object({ newCheckout: z.boolean(), buttonColor: z.string() });
export type CheckoutConfig = z.infer<typeof CheckoutConfig>;

@Controller("/checkout")
export class CheckoutController {
  @Get("/config")
  async config(): Promise<CheckoutConfig> {
    // `isEnabled` answers a PLAIN BOOLEAN. The other three still answer an envelope.
    const newCheckout = await Flags.isEnabled("new_checkout");
    const { data: variant } = await Flags.getVariant("new_checkout");

    return { newCheckout, buttonColor: variant?.name ?? "blue" };
  }
}

The flag key is typed. PalbaseFlagKey is the union palbase build renders from the Environment's own flag store, so Flags.isEnabled("new_chekout") is a compile error rather than a flag that silently reads as off.

Note what the example does not pass: a user id. Every read resolves for the user this request is being served for.

Whose flags you are reading

The user a flag resolves for comes from the SDK's own request scope — the verified claims the engine wrote when it authenticated the request — and never from anything the caller sent on the wire. A handler cannot be tricked into resolving somebody else's flags by a header or a body field.

You can override that with a context, and an explicit userId wins:

import { Flags } from "@palbase/backend";

// This request's user (the default).
await Flags.isEnabled("new_checkout");

// A specific user.
await Flags.isEnabled("new_checkout", { userId: "user_123" });

// A deliberate ANONYMOUS read: what the resolver looks at is whether the KEY is
// present, so an explicitly-set `undefined` wins over the request's user — which
// is how you ask "what would a signed-out visitor see?"
await Flags.isEnabled("new_checkout", { userId: undefined });
interface PalbaseFlagContext {
  userId?: string;
  properties?: Record<string, unknown>;
}

Note: userId is typed string | undefined, so { userId: null } does not compile even though the resolver's own branch is context.userId ?? null. Write { userId: undefined } — present key, no value — and omit the key entirely when you want the request's user.

Under the hood a read fetches the merged snapshot for that user — GET /v1/user-flags/users/{uid} when a user resolves, GET /v1/user-flags when none does — so several reads in one handler are several requests. Read once into a local when you need the same flag twice.

Reading flags

import { Flags } from "@palbase/backend";

// A PLAIN boolean. There is no envelope to unwrap and no `.data`.
const on = await Flags.isEnabled("new_checkout");

// The other three DO answer an envelope.
const { data: variant } = await Flags.getVariant("new_checkout");
const { data: all } = await Flags.getAll();

// A value with a fallback — the fallback also applies when the flags service
// is unreachable, so a lookup that times out hides the feature instead of
// failing the request.
const { data: limit } = await Flags.get("new_checkout", 25);
MethodReturnsDescription
isEnabled(key, context?)boolean — no envelopeWhether the flag is on for the resolved user.
get(key, default?, context?)PalbaseResult<value>The resolved value. The default is substituted when the flag is absent and when the flags service cannot be reached.
getVariant(key, context?)PalbaseResult<PalbaseFlagVariant>The active variant — only for a string-valued flag.
getAll(context?)PalbaseResult<Array<{ name, enabled, variant? }>>Every flag the Environment resolves for that user.

Warning: isEnabled changed shape in 41.0.0. It used to answer Promise<PalbaseResult<boolean>>, which made if (await Flags.isEnabled("new_checkout")) always true — an object is truthy — in this package's own hover example. It now answers the boolean itself, a flag it cannot read counts as off, and the reason is logged: there is no fail-open on a flag path. Code written against the old shape stops compiling, which is the point.

Three behaviours are easy to get wrong:

  • isEnabled is truthiness, not just booleans. A boolean true is on. For anything else the flag is on when the value is not null, not 0 and not "" — so a string flag set to "blue" reads as enabled.
  • getVariant yields a variant only for a string value. A boolean, number or JSON flag resolves to null on a perfectly successful read — null here does not mean "the lookup failed". The type is { name: string; payload?: unknown }; the resolver fills in name from the string value and never sets payload.
  • get's second argument is a default unless it looks like a context. An object in that position is treated as a context only when it carries a userId or a properties key; otherwise it is a default value. The four forms are get(key), get(key, default), get(key, ctx) and get(key, default, ctx). A plain object default such as { theme: "dark" } is read as a default; one that happens to contain userId is not.

Result envelope

Every Flags method resolves to an envelope instead of rejecting on a wire failure:

interface PalbaseResult<T> {
  data: T | null;
  error: { message?: string; code?: string } | null;
  status?: number;
}

Handle the null case and pick a sensible fallback (enabled ?? false). A flag lookup that fails should degrade the feature, not the request.

Warning: The envelope covers wire failures, not argument errors. A flag name that does not match /^[A-Za-z0-9_.-]+$/ makes isEnabled, getVariant and get throw synchronously, before any request is made: Invalid flag name: "…". Names are the one input these methods refuse rather than envelope.

Overriding a flag for the current user

Flags.setOverride(key, value) writes a per-user override for the current request user — no userId argument and no admin power. The override shadows the Environment-wide value for that user until it is cleared.

// modules/onboarding/onboarding.controller.ts
import { Controller, Flags, Post } from "@palbase/backend";

@Controller("/onboarding")
export class OnboardingController {
  @Post("/complete")
  async complete(): Promise<void> {
    // From now on, this user sees the post-onboarding experience:
    await Flags.setOverride("new_checkout", true);
  }
}

The result's data is { key, value, source }, where source is "user" for an override and "system" for the Environment-wide value. An override value may be any JSON value — boolean, number, string, null, array or object.

Warning: On an anonymous request setOverride does not silently no-op and does not write for nobody. It returns an envelope with status: 400 and the message setOverride requires a signed-in user; use Flags.$asService().setOverrideForUser(userId, key, value) for cross-user writes. On an auth: false route, either require sign-in for the write or use the service surface below with an explicit user id.

Cross-user writes — Flags.$asService()

Writing an override for an arbitrary user is a privileged operation, so it lives behind Flags.$asService() — mirroring the Database.$asService() model. The explicit call is what makes every cross-user write greppable.

import { Flags } from "@palbase/backend";

// Enrol one user in a beta:
await Flags.$asService().setOverrideForUser("user_123", "new_checkout", true);

// Set several flags for one user at once:
await Flags.$asService().setOverridesForUser("user_123", {
  new_checkout: true,
});

// Roll a cohort into an experiment (max 1000 operations per call):
declare const betaUsers: string[];
await Flags.$asService().batchSetOverrides(
  betaUsers.map((id) => ({ userId: id, values: { new_checkout: true } })),
);

// Back out:
await Flags.$asService().clearOverrideForUser("user_123", "new_checkout");
await Flags.$asService().clearAllOverridesForUser("user_123");
MethodReturns (data)Description
setOverrideForUser(userId, key, value){ key, value, source }Set or replace one override for one user.
setOverridesForUser(userId, values){ values }Set several overrides for one user in a single call.
clearOverrideForUser(userId, key){ key, value, source }value is what the user falls back toClear one override.
clearAllOverridesForUser(userId){ deleted: number }Clear every override for a user.
batchSetOverrides(operations){ applied: number }Override writes across many users in one request. Each operation is { userId, values }. Max 1000.

These map to PUT/DELETE /v1/user-flags/users/{userId}[/{key}] and POST /v1/user-flags/batch, and every one of those routes is gated on the verified service_role claim — the backend's own service-role credential satisfies it, and a holder of the publishable key cannot reach them at all. batchSetOverrides translates userId to user_id on the wire for you.

Note: Use the default setOverride whenever the target is the requesting user — it needs no special access and cannot touch the wrong account. Reach for $asService() only when an admin flow or a background process writes flags for someone else.

Where a flag definition comes from

Create and manage definitions in Studio or with the CLI. Changes are saved directly to the selected environment and reach clients on their next sync. An application deployment does not publish flag definitions. See Stack Settings.

The store is written through the Environment's Management API:

VerbRouteWhat it does
GET/v1/management/flagsEvery declared flag with its type, value and description
PUT/v1/management/flags/<key>Declare or replace the whole definition
DELETE/v1/management/flags/<key>Remove the definition — it is gone, with no file to fall back to

The CLI's palbase flags list, palbase flags add and palbase flags remove are the intended door onto those three, and palbase flags user set|unset|list|clear writes the per-user overrides described above. Their arguments and output are on Stack Settings.

The Management API accepts the same type vocabulary as the CLI and translates it for the flags module:

{
  "type": "boolean",
  "value": true,
  "variants": null,
  "conditions": null,
  "description": "Roll out the new dashboard"
}

type is one of boolean, number, string, json. The module API at /v1/user-flags/system/<key> uses value_type with bool, number, string, object instead. value must match its type. variants applies to string flags. conditions is an ordered list of { when, value } branches evaluated against the caller's client context, first match wins; null means unconditional. Studio reads full definitions from the module API so editing a value preserves variants and targeting.

Two rules hold wherever the write comes from:

  • Keys are dot-separated segments, each starting with a letter and continuing with letters, digits or underscores — ai_features, new_checkout. The dotted form exists for the reserved palbase. namespace below; palbase flags add refuses a dot outright.
  • A PUT is the whole definition. It is not a patch, and a write that omits variants, conditions or description drops them.

Palbase-managed flags

Keys under palbase. are owned by the platform, and only two exist. Any other palbase.* key is refused with reserved_key_prefix, so nobody can mint an undeletable row in that namespace.

KeyTypeDefault when no row existsWhat it gates
palbase.debug_consoleboolabsent — reads as offThe in-app debug console: pb.debug.view, pb.debug.startLiveSession(), pb.debug.isEnabled
palbase.force_updateboolabsent — reads as offThe platform's kill switch for builds you no longer support

Note: Neither key exists on a fresh Environment. There is no synthetic row: nothing serves a derived default, GET /v1/management/flags lists only real rows, and until somebody creates one the flag simply resolves as absent. For debug_console that is fail-closed — the console stays off. Older documentation described a default that flipped between "production" and every other Environment; that derivation was removed, because a stack is one stack and there is no sibling Environment to compare it with.

They behave like any other flag with three differences, each enforced by the module:

  • You cannot delete one. DELETE on a palbase. key is refused (managed_flag_not_deletable), because deleting the row would cascade-tombstone every user's override of it.
  • The type is locked. A write that would change a managed flag's value_type is refused (managed_flag_type_locked) — our own SDKs decode these keys with a fixed type.
  • Targeting support is specific to each flag. palbase.force_update supports conditions; palbase.debug_console only accepts an environment default or a per-user value. Unsupported conditions return managed_flag_conditions_not_allowed. The full definition exposes supports_conditions; Studio keeps managed targeting unavailable when an older runtime does not advertise support.

For force_update, configure a false environment default and a true condition for app versions below your minimum. A missing definition remains absent; the SDK only reports the resolved value and the application decides what to display.

The per-user override is the intended production path for debug_console: leave the Environment-wide value off and open the console for one account.

palbase flags user set   usr_01a0… palbase.debug_console true
palbase flags user unset usr_01a0… palbase.debug_console

The same two writes from a handler are Flags.$asService().setOverrideForUser(userId, key, true) and clearOverrideForUser(userId, key). Note that the key has to exist on the Environment first: PalbaseFlagKey is rendered from the flag store by palbase build, and neither managed key is there until somebody creates it — so on a fresh Environment the typed call does not compile, which is the same fact as "neither key exists" said by the compiler.

Limits

LimitValue
Flag definitions per Environment200
Value size8 KiB of JSON
Object nesting depth3
Key length128 characters
Operations per batchSetOverrides / POST /v1/user-flags/batch1000

A write that changes a flag's value_type while any user holds an override for that key is refused with 409 rather than silently invalidating the stored overrides.

How overrides and clients fit together

  • The Environment-wide value comes from the definition on the stack (source: "system").
  • A per-user override shadows it for that user (source: "user") until it is cleared.
  • Client SDKs read through the same server-side resolution, so an override your backend writes is what pb.flags on the web and iOS flags observe for that user.

That makes Flags plus overrides a straightforward rail for gradual rollouts, beta cohorts and per-user kill switches: declare the flag once on the Environment, then flip it per user from your backend.