Docs / SDK
Write the if, ship it dark.
One env key, one import, one line. The flag registers itself the first time your code runs, off in every environment, and you flip it from the dashboard without a deploy.
Install
#Terminal
$ npm i @launchflag/sdk
Node 18+. No dependencies. React is an optional peer dependency, needed only for the @launchflag/sdk/react components.
One command setup
#The SDK ships a small init command. It writes your key into .env.local, downloads the agent skill, and configures the MCP server for Claude Code and Cursor.
Terminal
$ npx launchflag init
It prompts for the dev key and the API token, or takes them as flags: --key, --token, --url. Use --dry-run to see what it would write, --no-mcp and --no-skill to skip those steps. Running it twice changes nothing: existing env values are never overwritten.
The integration
#Your code
import { flag } from "@launchflag/sdk";
if (await flag("checkout_v2", user.id)) return newCheckout(cart);
return oldCheckout(cart);That is the whole thing. Keep the old path intact and reachable: the flag is what you flip, not the deploy. Pass the same identifier you target people by, usually a user id or an email. Code with no user, like a cron job or a webhook, passes nothing and only sees flags targeted at everyone.
LAUNCHFLAG_KEY is a secret. Call the SDK from Server Components, route handlers, server actions or middleware, never from the browser. With no key set, flag() logs once and returns false.
React components
#Flag and Variant are async server components. React 19+.
The same two checks, called directly
import { flag, variant } from "@launchflag/sdk";
const on = await flag("checkout_v2", user.id);
const arm = await variant("pricing_test", user.id);Variants
#A multivariate flag has 2 to 5 arms with integer weights summing to 100. Each user keeps the same arm forever, bucketed from the user id, so the split is stable across servers and deploys.
Your code
import { variant } from "@launchflag/sdk";
const arm = await variant("pricing_test", user.id); // "a" | "b" | null
const arm2 = await variant("pricing_test", user.id, "a"); // "a" when off or unknownnull means the flag is off for this user, unknown, or a plain boolean flag. Variants share the same snapshot and cache as flag(), so they cost no extra request. LaunchFlag serves the arm and records nothing else: measure it wherever you already measure things.
Local evaluation
#The SDK fetches one document, /api/config, and decides every flag inside your process. That is one request per ttlMs for the whole server, not per user: 200 users across 40 flags cost the same single fetch. The document holds no user data, so it caches at the edge and your app never waits on us.
GET /api/config · the rules for one environment
curl -i "https://launchflag.dev/api/config?key=$LAUNCHFLAG_KEY"
cache-control: public, max-age=0, s-maxage=5, stale-while-revalidate=60
etag: "v42-prod"
{"v":42,"env":"prod","emergencyOff":false,"archived":false,
"flags":{"checkout_v2":{"type":"boolean","failMode":"closed","essential":false,"enabled":true,
"targeting":"percentage","percentage":25,"limit":100,"allowlist":[],"variants":[]}}}Every refresh sends If-None-Match. An unchanged config comes back 304 and costs nothing. Every change you make bumps v, which is the ETag, so the next refresh picks it up within ttlMs.
First N targeting is the one exception: admission is a row in our database, not arithmetic, so those flags call /api/eval and the answer is cached for 60 seconds per user. Percentage buckets, allowlists, logged-in checks, variant picks and emergency off all resolve locally.
What you evaluate is buffered per key and posted to /api/counts every 10 seconds, or on flush() before a short-lived process exits. A failed post keeps its counts for the next attempt and never surfaces on the flag path. Pass mode: "remote" to createLaunchFlag to go back to server-side evaluation, one snapshot per user.
Ops note for self-hosting behind a CDN: set CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN and every flag change purges the cached config for all three keys, so a flip is live in a second instead of waiting out s-maxage. With either one unset nothing is purged and the edge copy just expires.
When LaunchFlag is unreachable
#- One call, cached 5 seconds. The rule set is fetched once per
ttlMs, 5000 by default, and shared by every user in the process. A render that checks eight flags costs no request at all. Concurrent calls share the one fetch in flight. - A slow API never blocks a render. Requests abort at
timeoutMs, 800 ms by default. - A down API never flips a flag. A failed fetch keeps serving the last known config and retries after the next TTL window. With nothing cached yet,
flag()returnsdefaults[key]if you passed one, then its third argument, else false.flag(),variant()andall()never throw. - Fail closed is the default. A flag has a
failModeof closed or open, and self-registered flags are closed. Closed is the right call for money, auth, deletion and anything with an external side effect. Open is for cosmetics.
failMode is the declared intent that travels with the flag, and it is what the dashboard and your team read. The lever inside the SDK is defaults: a cold process that cannot reach the API serves those values, so a cosmetic flag you want on by default belongs there.
Explicit config
import { createLaunchFlag } from "@launchflag/sdk";
const lf = createLaunchFlag({
key: process.env.LAUNCHFLAG_KEY!,
baseUrl: "https://launchflag.dev",
mode: "local", // "remote" to evaluate server side instead
ttlMs: 5000, // config cache window
timeoutMs: 800, // abort a slow request
defaults: { new_nav: true, checkout_v2: false },
});
await lf.flag("checkout_v2", user.id); // boolean
await lf.flag("new_nav", user.id, true); // third argument: only when nothing is known
await lf.variant("pricing_test", user.id); // the arm
await lf.all(user.id); // every flag as { key: boolean }
await lf.why("checkout_v2", user.id); // { enabled, reason, variant }, uncached, throws on error
await lf.flush(); // report buffered counts now
lf.clear(); // drop the cached configEnvironments and keys
#Every project has three environments and one key each. The key decides which environment is evaluated, so the same code returns different answers on your laptop and in production.
| Key | Environment | Where it belongs |
|---|---|---|
| lf_dev_… | dev | Your machine. .env.local, gitignored. |
| lf_staging_… | staging | The staging host only. |
| lf_prod_… | prod | The production host only. Never in the repo. |
| lfk_… | management | Your API token for the MCP server and /api/v1. Per user, not per project. |
Flip a flag in dev, test it, then copy that environment forward with promote: dev to staging, staging to prod. A human does the last step. Keys are on the dashboard Install page and can be regenerated from Settings.
No dependency, 15 lines
#If you would rather not add a package, this is the whole client. Same self registration, same 5 second cache, and it reports its own evaluation counts back so the dashboard stays honest.
flag.ts
const KEY = process.env.LAUNCHFLAG_KEY!, API = "https://launchflag.dev/api/flags";
const counts = new Map<string, [number, number]>();
let cache: { at: number; user?: string; flags: Record<string, { enabled: boolean; variant: string | null }> } | null = null;
export async function flag(name: string, user?: string, fallback = false): Promise<boolean> {
if (!cache || cache.user !== user || !(name in cache.flags) || Date.now() - cache.at > 5000) {
const c = [...counts].map(([k, v]) => `${k}:${v[0]}:${v[1]}`).join(",");
const q = `?user=${encodeURIComponent(user ?? "")}&keys=${encodeURIComponent(name)}&c=${c}`;
try { const res = await fetch(API + q, { headers: { authorization: `Bearer ${KEY}` }, signal: AbortSignal.timeout(800) });
if (res.ok) (cache = { at: Date.now(), user, flags: (await res.json()).flags }), counts.clear(); } catch { /* keep the last snapshot */ }
}
const on = cache?.flags[name]?.enabled ?? fallback, v = counts.get(name) ?? [0, 0];
counts.set(name, [v[0] + 1, v[1] + (on ? 1 : 0)]);
return on;
}
export const variant = async (n: string, u?: string) => { await flag(n, u); return cache?.flags[n]?.variant ?? null; };Raw API
#Five endpoints, all authenticated by an environment key sent as Authorization: Bearer lf_dev_…. They also accept the key as ?key=, which is what /api/config uses: a CDN will not cache a request that carries an Authorization header.
GET /api/eval · one flag, with the reason
#curl "https://launchflag.dev/api/eval?flag=checkout_v2&user=u_123" \
-H "Authorization: Bearer $LAUNCHFLAG_KEY"
{"enabled":false,"reason":"disabled","variant":null}reason says why you got that answer: disabled, everyone, logged_in, anonymous, allowlist, not_in_allowlist, in_rollout, out_of_rollout, first_n, first_n_full, emergency_off, unknown_flag or registered.
GET /api/flags · every flag, one call
#curl "https://launchflag.dev/api/flags?user=u_123&keys=checkout_v2&c=checkout_v2:12:5" \
-H "Authorization: Bearer $LAUNCHFLAG_KEY"
{"flags":{"checkout_v2":{"enabled":false,"failMode":"closed","variant":null,"type":"boolean"}},
"emergencyOff":false,"env":"dev"}This is what the SDK calls. keys is the list of flags your code has asked for, registered if they do not exist yet. c reports what you evaluated locally since the last refresh, as key:evals:on pairs, so cached evaluations still show up in your counts. The snapshot fetch itself is not counted as an eval.
GET /api/config · the rule set, no user, cacheable
#curl -i "https://launchflag.dev/api/config?key=$LAUNCHFLAG_KEY"
cache-control: public, max-age=0, s-maxage=5, stale-while-revalidate=60
etag: "v42-prod"
{"v":42,"env":"prod","emergencyOff":false,"archived":false,
"flags":{"checkout_v2":{"type":"boolean","failMode":"closed","essential":false,"enabled":true,
"targeting":"percentage","percentage":25,"limit":100,"allowlist":[],"variants":[]}}}The document local mode evaluates against. One per project and environment, versioned by v, which is also the ETag. Counts are not accepted here, they go to /api/counts.
POST /api/counts · what you evaluated locally
#curl -X POST "https://launchflag.dev/api/counts" \
-H "Authorization: Bearer $LAUNCHFLAG_KEY" -H "content-type: application/json" \
-d '{"c":{"checkout_v2":{"evals":12,"on":5}}}'
204 No ContentAt most 50 keys per call, each count an integer up to 1,000,000. Keys the project does not know are dropped rather than rejected. Answers 204 and nothing else.
POST /api/register · create flags without evaluating them
#curl -X POST "https://launchflag.dev/api/register" \
-H "Authorization: Bearer $LAUNCHFLAG_KEY" -H "content-type: application/json" \
-d '{"keys":["checkout_v2","onboarding_ai_summary"]}'
{"created":["checkout_v2"],"skipped":["onboarding_ai_summary"]}The management API at /api/v1/flags creates, patches, promotes and deletes flags. It takes an lfk_… token instead, and it is what the MCP server talks to.
Rate limits
#Sliding one minute windows, counted per key rather than per IP, so one busy server keeps its whole allowance. Over the limit you get a 429 with a retry-after header in seconds.
| Bucket | Limit | Applies to |
|---|---|---|
| Environment key | 600 / minute | /api/config, /api/counts, /api/eval, /api/flags, /api/register |
| API token | 120 / minute | /api/v1/*, /mcp |
| IP, failed auth only | 120 / minute | Wrong or missing key. A valid key never burns this budget. |
Plan limits are separate: 1,000,000 evaluations a month on Pro, 5,000,000 on Launch. The SDK cache means a page view that checks the same flag five times counts once.
What registers a flag
#You never have to create a flag before using it. A key is registered the first time LaunchFlag sees it, off in dev, staging and prod, fail closed, described as registered by the SDK.
flag()orvariant()asking for a key the project does not have. The SDK sends it in?keys=on its next snapshot fetch.GET /api/evalwith an unknown flag.POST /api/registerwith a list of keys, when you want them on the dashboard before the code path ever runs.create_flagover MCP, orPOST /api/v1/flags, when you want a real description, afailModeof open, or variants from the start.
Keys must match [a-z][a-z0-9_]{0,63}: lowercase, snake_case, starting with a letter. Anything else is ignored rather than registered, and the evaluation still returns false. Registration never breaks an evaluation: if it fails, the key stays unknown and your old path runs.