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

RouteVerbWhat it doesBilling
/v1/flags/setPOSTCreate a flag, or update it when you pass versionfree
/v1/flags/listGETPaginated definitions with cursor, limit, totalfree
/v1/flags/get/{key}GETOne full definition including rollout and tagsfree
/v1/flags/get_value/{key}GET{ value, variant } for a user_id / context you passfree
/v1/flags/get_allGET{ flags: { key: value } } for the whole accountfree
/v1/flags/toggle/{key}POSTFlip enabled — the kill switch; needs enabled and versionfree
/v1/flags/rollout/{key}POSTStore percentage, salt and sticky_unitfree
/v1/flags/is_enabled/{key}GETBoolean shorthand; 404s on a key that doesn’t existfree
/v1/flags/delete/{key}DELETEIdempotent — returns deleted: false if absentfree

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_unituser_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

NeedInfrai flagsLaunchDarklyUnleash (self-hosted)PostHog
Targeting rules evaluated for youyes, pass user_id / contextyesyesyes
Client-side SDK for browser codeno, backend onlyyesyesyes
Streaming SDK, sub-second propagationno (poll)yesyesyes
Environments, approvals, audit historyversion counter onlyyespartialpartial
Experiment stats tied to the flagnoyesnoyes
Cost at 20 engineersfree routesper seathosting onlyfree tier then usage
Same key as your DB, queue, email, AIyesnonono

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.

References

Browse more flags developer guides