Feature flags
Flags per project — percentage rollouts, property targeting, per-environment switches, a user allowlist and JSON payloads — evaluated over one public endpoint.
A feature flag is a named switch with a rollout. Your app asks one endpoint which flags are on for this user, and the answer is a map of booleans — plus, for a flag that is on, an optional JSON payload. No SDK to install, no local state to sync.
A flag
Each flag has a key (new_checkout — lowercase, 2–64 characters), an on/off switch, and four kinds of targeting, applied in this order:
- Environments —
production,preview, or both. A flag that is off for production returnsfalsethere no matter what else says. - Allowed user ids — up to 100 ids that always get the flag, ahead of everything below.
- Rules (
match) — up to 20 property rules, all of which must hold:plan is one of pro, team,seats is at least 5,email ends with @acme.com. No rules means everybody. - Rollout percent — 0 to 100 of the people the rules let through. Users are bucketed deterministically on a hash of the project, the flag and the user's id, so the same person always lands on the same side of a rollout, and adding a rule never reshuffles who is in.
A project can hold 50 flags. Create them in the console under Analytics → Flags and open Targeting on a flag to edit its rules, share and payload — or use the session API at GET/POST /v1/projects/<slug>/flags, PATCH/DELETE /flags/:id.
Rules
A rule is { "property", "op", "value" }. Property names are letters, digits, _ and . (up to 64 characters).
op | Value | Holds when the property… |
|---|---|---|
eq · neq | string, number or boolean | equals / does not equal it (numbers compare as numbers) |
in · not_in | list of 1–50 of those | is / is not one of them |
contains · starts_with · ends_with | string | contains / starts / ends with it, ignoring case |
gt · gte · lt · lte | number | is more / at least / less / at most |
exists · not_exists | none | was sent / was not sent |
userId and distinctId are targetable too, and always mean the ids the request carried — a property named userId cannot stand in for them. A rule on a property the request did not send does not hold (except not_exists).
Properties come from your app, so targeting on them is a product decision — who sees the new checkout — never an access control. Anything that must be enforced belongs in your server code.
Payloads
A flag can carry any JSON value up to 4 KB — a headline, a price, a list of enabled plans. It is returned only while the flag is on for that caller, never for a flag that evaluated off.
Evaluating
Without properties, a GET:
GET /v1/flags/i?userId=user_123&distinctId=anon_abc
x-camplax-analytics-key: cak_live_…
With properties for rules, a POST (body up to 8 KB, at most 50 scalar properties):
POST /v1/flags/i
x-camplax-analytics-key: cak_live_…
content-type: application/json
{ "userId": "user_123", "distinctId": "anon_abc", "properties": { "plan": "pro", "seats": 8 } }
Both answer:
{ "flags": { "new_checkout": true, "beta_nav": false }, "payloads": { "new_checkout": { "headline": "Faster checkout" } }, "ttlSec": 30 }
From a browser, send the project's public ingest key (cak_live_…) as x-camplax-analytics-key — the same key analytics and errors use. It is publishable, so it is safe in a page. On a Camplax app the page can also call its own /camplax/v1/flags/i (GET or POST), which adds the key for you.
From your server, send the app's own token instead: Authorization: Bearer $CAMPLAX_PROJECT_TOKEN. Camplax injects it into every environment, so there is nothing to set, and it names the environment it belongs to — a preview's server always gets preview flags.
Pass userId when the user is signed in — evaluation prefers it, so signing in does not rebucket a person. Anonymous visitors are bucketed on distinctId. Send both when you have both.
What the endpoint guarantees: the environment is decided by Camplax, never by the caller — from the server's token, or from the address of the page that called (<slug>.camplax.app or a live custom domain is production, a branch--slug.camplax.app preview is preview). A call that proves neither — a server sending only the ingest key, with no token — gets 400 environment_unproven rather than a guess. Flags are not secrets, though: the ingest key is public, and a script that sets a page's Origin header itself reads what that page would. That is why targeting is never access control. The allowlist and the rules are never returned. The response is cacheable for ttlSec (30s). It is rate-limited to 120 evaluations per minute per key and IP.
From the SDK
import { createCamplax } from "@camplax/sdk";
// On your server there is nothing to pass — it reads what Camplax injects.
// In a browser, add analyticsKey: "<your public ingest key>".
const cx = createCamplax({
flags: { getProperties: () => ({ plan: user.plan, seats: team.seats }) },
});
if (await cx.flags.get("new_checkout")) {
const copy = await cx.flags.payload<{ headline: string }>("new_checkout");
}
With getProperties the SDK sends a POST; without it, a GET. Different properties are a different answer, so they are part of the SDK's 30-second cache key. On a server the SDK also sends CAMPLAX_PROJECT_TOKEN from the environment, so it reads the flags of the environment it runs in.
From cx.js
If your app already serves the analytics snippet, booleans are one call away — cx.js fetches /v1/flags/i for you and caches for the TTL:
if (await window.cx.flags.get("new_checkout")) { /* … */ }
const all = await window.cx.flags.all();
cx.js does not send properties or read payloads yet; use the SDK for a flag with rules.
What flags are not
They are not experiments: there is no variant split beyond on/off, no holdout and no significance maths. And a payload is configuration for a feature, not a place for secrets — anyone who gets the flag gets the payload.