Debugging 400s on a feature flag API: set, toggle and rollout payloads

Every invalid flag payload on Infrai returns 400 or 409 — never 422. The full error-code catalogue, the one misnamed code, and a Node 22 handler that reads them.

Short version: Infrai’s flags routes never return 422. Every malformed or invalid payload comes back as HTTP 400 with a machine-readable error.code, and every conflict — a key that already exists, a stale version number — comes back as 409. If you’re writing a retry wrapper and branching on 422, that branch is dead code.

The codes are specific enough to act on, which is the useful part. We ran a bad-payload sweep across POST /v1/flags/set, POST /v1/flags/toggle/{key} and POST /v1/flags/rollout/{key} on Infrai and catalogued what each failure mode actually produces.

The catalogue

What you sentStatuserror.codeparam
Body that isn’t valid JSON400INVALID_FILTER_SYNTAX
description missing or under 10 chars400FLAG_DESCRIPTION_TOO_SHORTdescription
type not a known flag type400FLAG_TYPE_MISMATCHtype
default_value doesn’t match type400FLAG_TYPE_MISMATCHdefault_value
toggle without enabled / version400INVALID_ARGUMENTversion
percentage outside 0–100400FLAG_RULE_INVALIDpercentage
percentage sent as a string400FLAG_RULE_INVALIDpercentage
Unrecognised sticky_unit400FLAG_RULE_INVALIDsticky_unit
Rule missing if or then400FLAG_RULE_INVALID
Creating a key that exists409FLAG_ALREADY_EXISTSkey
Stale version on set or toggle409FLAG_VERSION_CONFLICTexpected_version
Any of these against a deleted key404FLAG_NOT_FOUNDkey

Every one of those carries retryable: false. Take that seriously — none of them get better on a second attempt, so a blind exponential backoff just burns your rate limit before surfacing the same message.

The one misnamed code

Send a truncated body and the error you get is INVALID_FILTER_SYNTAX:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -w '\nHTTP %{http_code}\n' -X POST "https://api.infrai.cc/v1/flags/set" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key": "kb_broken", "type": '
{
  "ok": false,
  "error": {
    "code": "INVALID_FILTER_SYNTAX",
    "http_status": 400,
    "message": "request body must be valid JSON",
    "docs_url": "https://docs.infrai.cc/errors",
    "retryable": false,
    "trace_id": "trc_75eb0a04ba2c4b08be82d4be"
  }
}

The message is correct and the code is not — there’s no filter involved in a flag write. Match on message or on the 400 itself for this case; if you grep your logs for parse failures by code name you’ll miss them. It’s the sort of thing worth flagging to anyone building alerting on top of these responses.

Note also what doesn’t fail. Unknown top-level keys are accepted silently rather than rejected, so a typo like defualt_value returns HTTP 200 and creates a flag with the wrong default instead of telling you. The exception is enabled, which is a real field on set and is honoured. There’s no strict-mode switch to turn that leniency off — that’s a genuine drawback of the API, and the reason the validation below runs client-side before anything is sent.

Payload rules that bite

type and default_value are checked against each other, and the message names both sides:

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_probe_typed","type":"bool","default_value":"yes","description":"type mismatch demo"}'
# {"ok":false,"error":{"code":"FLAG_TYPE_MISMATCH","http_status":400,
#   "message":"default_value must be bool; got str","param":"default_value","retryable":false}}

toggle is the one that catches people out, because an empty body feels reasonable for something called a toggle. It isn’t — the route needs an explicit target state and the version you’re moving from:

curl -sS -X POST "https://api.infrai.cc/v1/flags/toggle/kb_pricing_experiment" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{}'
# {"ok":false,"error":{"code":"INVALID_ARGUMENT","http_status":400,
#   "message":"flag.toggle needs 'enabled' (bool) and 'version' (int)","param":"version"}}

And rollout rejects three separate things: a percentage outside [0,100], a percentage that arrived as a string ("25" is not 25 — JSON.stringify on a form value is the usual culprit), and any sticky_unit outside user_id, session_id and device_id. Sending tenant_id returns unknown sticky_unit 'tenant_id', which means multi-tenant products have to map a tenant onto one of the three accepted units before calling — a real constraint if your bucketing is per-account rather than per-user.

Reading the envelope in Node 22

The response shape is consistent enough to write one handler for. ok is a boolean at the top level, errors live under error, and param tells you which field to point the developer at.

import process from "node:process";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const FLAG_TYPES = new Set(["bool", "string", "json", "number"]);

class FlagApiError extends Error {
  constructor(status, err) {
    super(`${err?.code ?? "UNKNOWN"}: ${err?.message ?? "no message"}`);
    this.name = "FlagApiError";
    this.status = status;
    this.code = err?.code;
    this.param = err?.param;
    this.retryable = err?.retryable === true;
    this.traceId = err?.trace_id;
  }
}

function validateSet(payload) {
  const problems = [];
  if (!payload.key) problems.push("key is required");
  if (!FLAG_TYPES.has(payload.type)) problems.push(`type must be one of ${[...FLAG_TYPES].join(", ")}`);
  if (typeof payload.description !== "string" || payload.description.length < 10) {
    problems.push("description must be at least 10 characters");
  }
  if (payload.type === "bool" && typeof payload.default_value !== "boolean") {
    problems.push("default_value must be a boolean for type=bool");
  }
  if (problems.length) throw new Error(`invalid flag payload: ${problems.join("; ")}`);
}

async function request(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  });
  let body;
  try {
    body = await res.json();
  } catch {
    throw new FlagApiError(res.status, { code: "NON_JSON_RESPONSE", message: await res.text() });
  }
  if (!body.ok) throw new FlagApiError(res.status, body.error);
  return body.data;
}

export async function createFlag(payload) {
  validateSet(payload);
  try {
    return await request("/v1/flags/set", { method: "POST", body: JSON.stringify(payload) });
  } catch (err) {
    if (err instanceof FlagApiError && err.code === "FLAG_ALREADY_EXISTS") {
      console.warn(`${payload.key} already exists — refetching instead of recreating`);
      return await request(`/v1/flags/get/${encodeURIComponent(payload.key)}`);
    }
    throw err;
  }
}

const flag = await createFlag({
  key: "kb_pricing_experiment",
  type: "string",
  default_value: "control",
  description: "String-valued flag for the payload-validation docs example",
});
console.log(`${flag.key} = ${JSON.stringify(flag.default_value)} (version ${flag.version})`);

Client-side validation before the request is doing real work there, because it catches the two failures the server won’t help with: a misspelled field name that would be accepted silently, and a description one character too short that costs a round trip. Treating 409 FLAG_ALREADY_EXISTS as “fine, read it back” also makes provisioning scripts idempotent, which matters when the same bootstrap runs on every deploy.

Check the result directly:

curl -sS "https://api.infrai.cc/v1/flags/get/kb_pricing_experiment" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '{key: .data.key, type: .data.type, default_value: .data.default_value, version: .data.version}'

Cost, and where a specialist fits

All nine flags routes are free and rate-limited, and they don’t consume the $2 credit a new account opens with (verified 2026-07-26). Rates drift downward over time and campaigns run, so read the current answer rather than this line:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.namespace=="flags") | {id, free: .billing.free}]'

The trade-off in a lenient API with good error codes is that it catches structural mistakes well and semantic ones not at all. If you want a schema enforced on flag payloads, typed SDK clients that make a bad payload a compile error, and a UI that won’t let an operator submit one, LaunchDarkly and Flagsmith both sell that and it’s worth the money for a team where non-engineers change flags. Unleash gives you the same guardrails on hardware you control. Stick with the raw REST surface when flags are provisioned from code review and CI, which is where this validation belongs anyway — and where the same key already reaches your queues, storage and error tracking without another integration.

References

Browse more flags developer guides