Feature flag kill switch for incident response: the one call to go dark

One POST to Infrai's flags toggle route darkens a broken feature and the next read reflects it — with a version guard so two responders can't clobber each other.

Turning a broken feature off on Infrai is one call: POST /v1/flags/toggle/{key} with {"enabled": false, "version": N}. The next GET /v1/flags/get_value/{key} returns the off value, so every process that reads the flag goes dark within its cache interval. No redeploy, no SDK upgrade, no console login — a curl your on-call can run from a phone at 3am.

The version in that body is the part to understand before the incident rather than during it. It’s a compare-and-swap guard: send the number you just read, and if a colleague already flipped the same flag you get an HTTP 409 instead of quietly undoing their change. Two people typing the same command into an incident channel is the normal case, not the edge case.

The kill path

Take a bool flag guarding a payments write path, sitting at version: 2 with default_value: true. One call darkens it:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/flags/toggle/kb_payments_kill_switch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false, "version": 2}'

The full flag record comes back with the counter advanced:

{
  "ok": true,
  "data": {
    "key": "kb_payments_kill_switch",
    "type": "bool",
    "default_value": true,
    "enabled": false,
    "version": 3,
    "description": "Kill switch guarding the payments write path (docs example)",
    "rules": [],
    "tags": { "owner": "kb-guides" }
  }
}

Read those two fields together. default_value is still true — that’s what the flag serves when it’s on. enabled: false is the gate in front of it, and evaluation honours the gate, so the route your application actually calls has already changed its answer:

curl -sS "https://api.infrai.cc/v1/flags/get_value/kb_payments_kill_switch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"value":false,"variant":null}}

GET /v1/flags/is_enabled/{key} is the same answer in a shape that’s easier to assert on in a runbook script — it echoes the key back alongside the boolean:

curl -sS "https://api.infrai.cc/v1/flags/is_enabled/kb_payments_kill_switch" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"key":"kb_payments_kill_switch","enabled":false,"value":false}}

Ask it about a key that doesn’t exist and you get a 404 FLAG_NOT_FOUND, not a false that looks like a successful kill. That distinction matters more than it sounds: a mistyped key is exactly the failure mode that makes a runbook step appear to have worked.

Send a stale version and the write is refused:

{
  "ok": false,
  "error": {
    "code": "FLAG_VERSION_CONFLICT",
    "message": "kb_payments_kill_switch is at version 3",
    "retryable": false
  }
}

Re-read, re-decide, re-send. That’s three seconds, and it’s cheaper than discovering an hour later that your kill re-enabled somebody else’s.

Off, versus a different value

toggle answers one question: should this feature serve at all. For a bool flag that’s the whole story. For a string, number or json flag, “off” isn’t self-evident — a config flag holding a timeout or a vendor name has no natural dark state — and that’s where POST /v1/flags/set earns its place, rewriting default_value under the same optimistic lock:

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_payments_kill_switch",
    "type": "bool",
    "default_value": false,
    "description": "Kill switch guarding the payments write path (docs example)",
    "version": 3
  }'

Note that type and description have to be resent; a partial body is rejected, and a description shorter than ten characters comes back as FLAG_DESCRIPTION_TOO_SHORT.

Here’s the comparison that belongs in the runbook:

MechanismWhat it’s forReversibleTime to take effect
POST /v1/flags/toggle/{key}darken a feature entirelyyes, toggle backnext poll
POST /v1/flags/set + versionchange the value a non-bool flag servesyesnext poll
DELETE /v1/flags/delete/{key}retire a flag you’re done withnoreads 404 afterwards
Redeploy with the branch removedpermanent removal of the code pathslowminutes, not seconds

Deleting is the tempting third option and it’s the wrong instrument during an incident. Reads then 404 or fall through to whatever your resolver’s fallback declares, which is a kill switch only if you’ve already decided that missing means off.

Killing it for everyone except the person debugging

Rules are evaluated server-side against the context you pass, so “dark for users, live for the on-call” is one write. Turn the gate back on, set the default to false, and add a rule:

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_payments_kill_switch",
    "type": "bool",
    "default_value": false,
    "description": "Kill switch guarding the payments write path (docs example)",
    "rules": [{"if": {"user_id": {"in": ["u_oncall_7712"]}}, "then": true}],
    "version": 4
  }'

Then the same read route answers differently depending on who’s asking:

curl -sS "https://api.infrai.cc/v1/flags/get_value/kb_payments_kill_switch?user_id=u_oncall_7712" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"value":true,"variant":null}}

Predicates take operator objects — eq, in, gte, matches and friends — keyed by a context field path, so user_id and context.country are both addressable. Check the reference for the full operator list before you write anything clever; a rule that never matches fails open, which during an outage is the direction you don’t want.

Time to dark

Nothing pushes. There’s no streaming connection and no webhook on flag change, so propagation is your polling interval plus one request.

That’s the number to design around. A 30-second cache means a bad feature keeps running for up to 30 seconds after the flip — fine for a gradual rollout, much too slow for a payment bug. Drop the interval to 5 seconds on the handful of flags that guard genuinely dangerous paths. GET /v1/flags/get_all is free and rate-limited, and one process polling every 5s is 12 requests a minute against a route that answered in single-digit milliseconds in our testing. Cheap insurance.

An incident CLI

One command, no console login, safe to run twice.

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 auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function call(path, init = {}) {
  const res = await fetch(`${BASE}${path}`, { ...init, headers: auth });
  const body = await res.json();
  if (!body.ok) {
    throw new Error(`${path} -> HTTP ${res.status} ${body.error?.code}: ${body.error?.message}`);
  }
  return body.data;
}

export async function darken(key) {
  const current = await call(`/v1/flags/get/${encodeURIComponent(key)}`);
  if (current.enabled === false) {
    console.log(`${key} already dark at version ${current.version} — nothing to do`);
    return current;
  }
  const next = await call(`/v1/flags/toggle/${encodeURIComponent(key)}`, {
    method: "POST",
    body: JSON.stringify({ enabled: false, version: current.version }),
  });
  const check = await call(`/v1/flags/get_value/${encodeURIComponent(key)}`);
  console.log(`${key}: dark at version ${next.version}, get_value now ${JSON.stringify(check.value)}`);
  return next;
}

const target = process.argv[2];
if (!target) throw new Error("usage: node darken.mjs <flag_key>");
await darken(target);

It re-reads the version rather than trusting a cached one, exits quietly if someone already pulled the switch, and confirms through the read path instead of trusting its own write. Wire the same function to a chat command and the fix stops depending on who has console access.

Confirm the state afterwards:

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

Limits, and when to buy the specialist

Every flags route is free and rate-limited, and none of them draw down the $2 credit a new account starts with (verified 2026-07-26). Rates move, usually downward, so read the live figure rather than trusting a paragraph:

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

The honest limitation for incident response is propagation and governance. Evaluation is backend-only — there’s no browser SDK, so a flag that guards client-side code needs your own endpoint in front of it — and with no push channel you’re left with seconds of exposure that no amount of tuning removes. The flag record carries updated_by and updated_at, but there’s no approval step and no diff history of who changed what to what. If an auditor will ask that question about the payments kill switch, buy LaunchDarkly: streaming SDKs cut propagation under a second and the approval workflow is the actual product. PostHog is the better pick when the same switch has to be tied to the funnel it moved, and Unleash if residency is contractual and you’d rather run it yourself.

What you get here instead is that the switch, the error tracker that alerted you, the queue draining the backlog and the metrics you watch during recovery all answer to one credential. During an incident, that’s one fewer login to find.

References

Browse more flags developer guides