A LaunchDarkly alternative that is just a REST flag store
Nine free REST routes for feature flags, percentage rollout and targeting on Node and Next.js — plus where a per-seat vendor still earns its money.
If you want percentage rollouts and simple targeting without per-seat pricing, Infrai’s flags namespace is a plain REST flag store: nine routes under /v1/flags, free to call and rate-limited, reachable with the same project key that reaches AI inference, storage, email and queues. It holds flag definitions, versions, targeting rules and rollout configuration, and it evaluates a flag against the context you hand it.
What it doesn’t ship is the half LaunchDarkly actually charges for: streaming SDKs in eleven languages, an experiment engine, environments with approval workflows, and a console you’d hand to a product manager. That’s the real comparison, and it’s worth being concrete about it before any table.
The nine routes, and what each one really returns
| Route | Verb | What it does | Billing |
|---|---|---|---|
/v1/flags/set | POST | Create a flag, or update it when you pass version | free |
/v1/flags/list | GET | Paginated definitions with cursor, limit, total | free |
/v1/flags/get/{key} | GET | One full definition including rollout and tags | free |
/v1/flags/get_value/{key} | GET | { value, variant } for a user_id / context you pass | free |
/v1/flags/get_all | GET | { flags: { key: value } } for the whole account | free |
/v1/flags/toggle/{key} | POST | Flip enabled — the kill switch; needs enabled and version | free |
/v1/flags/rollout/{key} | POST | Store percentage, salt and sticky_unit | free |
/v1/flags/is_enabled/{key} | GET | Boolean shorthand; 404s on a key that doesn’t exist | free |
/v1/flags/delete/{key} | DELETE | Idempotent — returns deleted: false if absent | free |
Two of those repay a closer look. GET /v1/flags/get_value/{key} takes user_id and a context object on the query string and returns the value for that subject, so a targeted flag is one GET rather than a client-side rule engine. And toggle is a real gate, not a metadata field: flip enabled to false and get_value starts returning the off value on the next read, which is the property you want from an incident kill switch.
Targeting lives in each flag’s rules array. A rule is {"if": …, "then": …}, where the predicate keys are context field paths — user_id, context.country, $and / $or for composition — and the leaves are operator objects like {"in": [...]} or {"gte": 5}. The first matching rule decides the value; nothing matches, you get default_value. There’s a small mercy in the write path too: an unknown top-level field on set is rejected with a 400 rather than silently creating a flag around your typo.
Creating a flag
Descriptions are mandatory and the server wants at least ten characters — a short one comes back as FLAG_DESCRIPTION_TOO_SHORT with HTTP 400.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/flags/set" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"key": "kb_checkout_v2",
"type": "bool",
"default_value": false,
"description": "New checkout flow, knowledge-hub worked example",
"tags": {"owner": "kb-guides"}
}'
{
"ok": true,
"data": {
"key": "kb_checkout_v2",
"type": "bool",
"default_value": false,
"enabled": true,
"version": 1,
"rules": [],
"tags": {"owner": "kb-guides"},
"created_at": "2026-07-26T01:30:22.245013Z",
"updated_by": "system"
}
}
POST /v1/flags/set is create-only unless you pass the current version. Send it again without one and you get HTTP 409 FLAG_ALREADY_EXISTS; send it with a stale number and you get 409 FLAG_VERSION_CONFLICT carrying the current value, which is exactly the compare-and-swap you want when two deploy pipelines touch the same key.
Reading it back is a single free GET:
curl -sS "https://api.infrai.cc/v1/flags/get/kb_checkout_v2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Storing a percentage rollout
POST /v1/flags/rollout/{key} takes an integer percentage in [0,100], a salt used for hash bucketing, a sticky_unit — user_id, session_id, device_id or tenant_id, which is the one B2B products actually want — and the current version.
curl -sS -X POST "https://api.infrai.cc/v1/flags/rollout/kb_checkout_v2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key": "kb_checkout_v2", "percentage": 10, "salt": "checkout-2026-07", "sticky_unit": "tenant_id", "version": 1}'
The flag comes back with a rollout object and its version incremented. Rotate the salt when you want to reshuffle who’s in the bucket; keep it stable when you don’t, because a tenant that flips back and forth between two checkout flows will file a bug you can’t reproduce.
Keeping the hot path off the network
You can ask the API per check — get_value with a user_id is one free GET. In a request-path hot loop you probably don’t want to. Pull the definitions once, cache them for a few seconds, and do the hash locally: stable input, stable bucket, no network call per decision.
import { createHash } from "node:crypto";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
let cache = { at: 0, flags: {}, rollouts: {} };
const TTL_MS = 30_000;
async function refresh() {
const res = await fetch(`${BASE}/v1/flags/get_all`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`flags/get_all failed: HTTP ${res.status}`);
const { data } = await res.json();
const defs = await fetch(`${BASE}/v1/flags/list?limit=200`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!defs.ok) throw new Error(`flags/list failed: HTTP ${defs.status}`);
const { data: page } = await defs.json();
const rollouts = {};
for (const item of page.items) {
if (item.rollout) rollouts[item.key] = item.rollout;
}
cache = { at: Date.now(), flags: data.flags, rollouts };
}
function bucket(salt, unit) {
const digest = createHash("sha1").update(`${salt}:${unit}`).digest();
return digest.readUInt32BE(0) % 100;
}
export async function evaluate(key, subject, fallback = false) {
try {
if (Date.now() - cache.at > TTL_MS) await refresh();
} catch (err) {
console.error("flag refresh failed, serving last known state:", err.message);
}
if (!(key in cache.flags)) return fallback;
const rollout = cache.rollouts[key];
if (!rollout) return cache.flags[key];
const unit = subject[rollout.sticky_unit];
if (!unit) return fallback;
return bucket(rollout.salt, String(unit)) < rollout.percentage
? cache.flags[key]
: fallback;
}
console.log(await evaluate("kb_checkout_v2", { user_id: "u_8812" }));
One thing this local path deliberately doesn’t do is targeting. get_all returns each flag’s current value with no subject attached, so if a flag carries rules, ask GET /v1/flags/get_value/{key} with the user_id and let the server apply them — the cache can’t.
Thirty seconds of cache means a bad flag takes up to half a minute to drain, which is fine for a rollout and too slow for an outage. Drop TTL_MS to 5000 on the paths you’d actually want to kill fast; two calls every five seconds against a free, rate-limited route is not a budget problem.
In a Next.js route handler the same module works unchanged — keep it out of the edge runtime, since node:crypto isn’t available there.
Where the specialists still win
| Need | Infrai flags | LaunchDarkly | Unleash (self-hosted) | PostHog |
|---|---|---|---|---|
| Targeting rules evaluated for you | yes, pass user_id / context | yes | yes | yes |
| Client-side SDK for browser code | no, backend only | yes | yes | yes |
| Streaming SDK, sub-second propagation | no (poll) | yes | yes | yes |
| Environments, approvals, audit history | version counter only | yes | partial | partial |
| Experiment stats tied to the flag | no | yes | no | yes |
| Cost at 20 engineers | free routes | per seat | hosting only | free tier then usage |
| Same key as your DB, queue, email, AI | yes | no | no | no |
Buy LaunchDarkly if a release manager needs to approve a production flip and prove later who flipped it — that governance layer is the actual product, and rebuilding it around a REST store is months of work you didn’t plan. Unleash is the better answer when EU-only data residency is contractual, because you run it; Infrai’s flags route serves western and China regions from https://api.infrai.cc and https://api.cn.infrai.cc, with no separate EU-resident control plane, and that’s a limitation worth naming before a procurement review finds it. PostHog makes sense if the flag and the funnel it moves have to live in one tool. Flagsmith sits between Unleash and LaunchDarkly and is worth a look if you want self-hosting with a nicer console.
What it costs, and how to check
Every route in the flags namespace is free and rate-limited, and calling one does not consume the new-account trial credit — a new account starts with $2 free credit that stays intact while you build the flag layer. Rates and billing classes move, usually downward, and discount campaigns run, so read the current answer rather than trusting this paragraph (verified 2026-07-26):
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.namespace == "flags")
| {id, method, path, free: .billing.free}]'
The durable argument isn’t the zero. It’s that the flag store, the metrics you’ll use to judge the rollout, the queue that drains the migration behind it and the model call inside the new feature all sit on one credential and one invoice — so the second question after “is this flag on?” doesn’t start with a new vendor signup.