Skip to main content

REST API

The Quonfig REST API lets scripts, CI jobs, and agents manage flags and configs over plain HTTPS: list and inspect flags, read the git-backed audit trail, create flags and configs, update what an environment serves, set a service's log levels, and read or replace the raw stored document — including any earlier version of it. It is the same control plane the app and CLI use — a change made here shows up everywhere, with full attribution in your workspace's git history.

Base URL: https://api.quonfig.com/v1

The API is described by an OpenAPI 3.1 spec at https://api.quonfig.com/v1/openapi.json — that spec is the published contract, and you can generate a typed client from it. Prefer to browse it? There's an interactive reference built from the same spec.

A first call to check your key works:

curl https://api.quonfig.com/v1/whoami \
-H "Authorization: Bearer $QUONFIG_API_KEY"
{
"workspaceId": "01J...",
"keyId": "01J...",
"principal": { "type": "user", "id": "user_...", "name": "Ada Lovelace" }
}

Authentication​

Every request needs a Bearer API key:

Authorization: Bearer <key>

Two kinds of key work, and they differ only in who the change is attributed to:

KeyLooks likeActs asMint it in the app
Personal API keyqf_uk_...You — changes are attributed to your userWorkspace → Environments → CLI & API keys tab
Service-account keyqf_sa_...A bot identity with its own name and roleWorkspace → Settings → Service accounts → Mint key

Use a personal key for your own scripts; use a service account for anything shared (CI, integrations, agents), so the audit trail says deploy-bot (service account) instead of a person who left the team. History and activity responses flag bot changes with isServiceAccount: true.

Keys are workspace-scoped: the workspace every call acts on is inferred from the key, so paths never contain a workspace id. To act on two workspaces, mint two keys.

SDK keys don't work here

qf_sk_... / qf_pk_... SDK keys authenticate your application to the delivery API — they cannot manage config. See Keys & Credentials for how the credential kinds relate.

Endpoints​

The v1 surface is deliberately small: read everything, one focused write for the everyday case, and a raw document read/write for everything else. All requests and responses are JSON.

MethodPathWhat it does
GET/v1/whoamiIdentify the calling key: principal + workspace
GET/v1/workspacesThe workspace(s) this credential can act on
GET/v1/environmentsThe workspace's environments: names, types, and which are protected
GET/v1/flagsList feature flags (filter with ?tag= and ?status=)
POST/v1/flagsCreate a feature flag (see below)
GET/v1/flags/{key}Full flag detail: default rules, per-environment rules, rollouts, variants
GET/v1/flags/{key}/historyGit commits that changed this flag, most recent first
PATCH/v1/flags/{key}/environments/{env}Update what one environment serves (see below)
GET/v1/flags/{key}/documentThe raw stored JSON — any commit with ?at= (see below)
PUT/v1/flags/{key}/documentReplace the raw stored JSON wholesale (see below)
GET/v1/configsList configs (filter with ?tag=)
POST/v1/configsCreate a config (see below)
GET/v1/configs/{key}Full config detail
GET/v1/configs/{key}/historyGit commits that changed this config
PATCH/v1/configs/{key}/environments/{env}Set what one scope of a config serves (see below)
GET/v1/configs/{key}/documentThe raw stored JSON for a config
PUT/v1/configs/{key}/documentReplace a config's raw stored JSON
GET/v1/log-levelsEvery service's log-level document, projected per scope (see below)
PATCH/v1/log-levels/{key}Set a service's log level — for one logger prefix, or as the fallback (see below)
GET/v1/log-levels/{key}/documentThe raw stored JSON for a log level
PUT/v1/log-levels/{key}/documentReplace a log level's raw stored JSON
GET/v1/segmentsList segments — the reusable membership rule sets targeting rules point at
GET/v1/segments/{key}A segment's membership rules: who it actually matches
GET/v1/segments/{key}/historyGit commits that changed this segment
GET/v1/activityWorkspace-wide change feed, or one item's translated history

List responses return the whole workspace by default (hundreds of items, not millions). /v1/flags and /v1/configs additionally accept opt-in pagination, and /v1/activity takes ?limit= (1–100, default 30). The flag, config, and segment lists each carry a total — how many rows matched your filters.

Two asymmetries worth noticing in that table. Log levels have a list, a surgical PATCH, and the document pair, but no /v1/log-levels/{key} detail route — the list already carries each document's per-scope projection, and the raw JSON is one document GET away. Segments are read-only over the API: list, detail, and history, but no create, PATCH, or document endpoints.

Reading flags and configs​

GET /v1/flags summarizes each flag: identity and tags, the current commitSha, when it last changed, and its derived lifecycle status. Filter server-side with ?tag= and ?status=. GET /v1/configs is the same summary for configs — sendToClientSdk and schemaKey in place of a lifecycle status — and filters with ?tag=.

GET /v1/flags/{key} returns the flag in full: default.rules, per-environment environments[].rules (including percentage rollouts), variants, and the flag's current commitSha — the version handle you can later pass as expectedCommitSha when writing. Configs have the same shape at /v1/configs/{key}.

This is a projection of the stored file, not the file: it drops access, $schema, and type, so it can't be written back as-is. When you need the stored JSON verbatim — or an earlier version of it — use the document endpoints.

Two things to know about the values you'll see:

  • Open value sets. Fields like valueType and rule operator are strings with documented values (for example bool, string, int, double, json, string_list, duration, log_level), not closed enums — Quonfig may add values over time, and generated clients should tolerate unknown ones.
  • Encrypted values stay encrypted. For secrets, value is the stored ciphertext and decryptWith names the config holding the decryption key. Decryption happens in your runtime — Quonfig servers never decrypt.

When something last changed​

Flag and config rows — on both the list and the detail responses — carry lastModified:

{ "date": "2026-07-07T17:07:41-04:00", "author": "Jeff Dwyer" }

It describes the same commit commitSha names: date is the ISO 8601 commit author date, author the name on that commit. That makes "which of these hasn't been touched in six months?" a single list call rather than one history request per item.

Two caveats. A service-account write carries the service account's own name, so use history — which has isServiceAccount — when you need to tell a bot from a human. And the field is absent rather than fabricated when git couldn't attribute the file's last commit; a synthesized timestamp would read as "changed just now", which is worse than a missing field.

Flag lifecycle status​

Flag list rows carry two status maps, both keyed by environment name:

FieldCoversUse it for
statusesProduction environments onlyWhat ?status= filters on
environmentStatusesEvery active environmentTelling an environment gate from a finished rollout

statuses is a subset of environmentStatuses, so the two can never disagree about an environment they share. The narrow one exists because ?status= has always matched against production, and widening it would silently change what a shipped query returns. Read the wide one before calling a flag fully rolled out: live in production and pre_rollout in staging is an environment gate, not a finished rollout.

Status is a pure function of the stored document — never of traffic, evaluations, or any other telemetry. For one boolean flag in one environment, evaluated against that environment's own rules (or the flag's default.rules when the environment has no entry of its own):

Stored stateStatus
readyForCleanup is trueready_for_cleanup — short-circuits everything below
No rules at allpre_rollout
Any rule serves a real splitrollout
No catch-all; every rule serves falsepre_rollout
No catch-all; some rule serves truerollout
Catch-all present; every rule serves truelive
Catch-all present; every rule serves falsepre_rollout
Catch-all present; mixedrollout

A "catch-all" is a rule whose only criterion is ALWAYS_TRUE — without one, unmatched contexts fall through, so the flag can't be live whatever the rules serve. A rule serves true or false when its value is a plain boolean, or a weighted rollout in which one boolean value carries all of the non-zero weight; anything else counts as a split. Non-boolean flags have no lifecycle: both maps come back empty.

readyForCleanup is the one manual input. An owner sets it by hand in the Quonfig app to say "this flag's job is done, remove the references at your convenience" — it is not derived from usage, evaluation counts, or age. It's always present on both the list and detail flag responses (false when nobody has marked the flag), and when true it forces every entry in both maps to ready_for_cleanup regardless of what the rules say.

Segments​

Targeting rules reference segments by key instead of inlining them, so a rule like

{ "operator": "IN_SEG", "valueToMatch": { "type": "string", "value": "beta-users" } }

names a segment without saying a word about who's in it. The segment endpoints resolve that: valueToMatch.value is the segment's key, so pass it straight through as {key}.

GET /v1/segments lists each segment with key, name, description, ruleCount, and commitSha. GET /v1/segments/{key} returns the membership rules themselves:

{
"key": "beta-users",
"name": "Beta users",
"default": {
"rules": [
{
"criteria": [{ "propertyName": "user.plan", "operator": "PROP_IS_ONE_OF", "valueToMatch": { "type": "string_list", "value": ["beta"] } }],
"value": { "type": "bool", "value": true }
}
]
},
"commitSha": "1a2b3c4..."
}

Segments are cross-environment: there's exactly one rule set, which is why it lives under default and there's no environments array. The rule shape is identical to a flag's or config's, so the same client code reads it. A context is in the segment when the first matching rule serves true.

GET /v1/segments/{key}/history is the git audit trail, same shape as the flag and config twins.

Environments​

GET /v1/environments lists the workspace's active environments:

{
"environments": [
{ "name": "development", "environmentType": "development", "protected": false },
{ "name": "staging", "environmentType": "staging", "protected": false },
{ "name": "production", "environmentType": "production", "protected": true }
]
}

name is the identifier every other endpoint uses — the env path segment of the PATCH, and the id of an entry in a flag's or config's environments array. Ask rather than guessing: prod and production are both plausible names and only one of them is yours.

protected tells you up front whether writing to that environment needs an elevated role, so you can check before a write comes back 403. environmentType is an open string set (production, staging, test, development today) and is what decides whether an environment appears in a flag's statuses map. Archived environments are omitted, and the list is ordered development, test, staging, production — alphabetically within a type.

Pagination​

GET /v1/flags and GET /v1/configs are unpaginated by default and that isn't changing: omit both parameters and you get every row in one response, exactly as before. There is no implicit page size.

Pass limit (1–100) to bound the response:

curl "https://api.quonfig.com/v1/flags?limit=50" \
-H "Authorization: Bearer $QUONFIG_API_KEY"

A bounded response carries nextCursor while rows remain. Pass it back as cursor for the next page, and stop when it's absent:

curl "https://api.quonfig.com/v1/flags?limit=50&cursor=v1_YWktc3VtbWFyaWVz" \
-H "Authorization: Bearer $QUONFIG_API_KEY"

Three things to know:

  • Ordering applies only when you paginate. A paginated result is ordered ascending by key. The default response is in no defined order and never has been, so don't try to page over it by hand.
  • The cursor is a position, not a saved query. Resend the same ?tag=/?status= with every page. Filters are applied before paging, so a page holds limit rows of the filtered set.
  • The cursor is opaque. Round-trip it verbatim — anything else is a 400. Because it keys on key rather than an offset, a flag created or deleted between two pages can't shift the window and make you skip a neighbor.

Counting rows: total​

GET /v1/flags, GET /v1/configs, and GET /v1/segments each carry a total alongside their rows — how many rows matched the request's filters, counted before limit and cursor take a page out of them:

{
"flags": [ "..." ],
"total": 412,
"nextCursor": "v1_YWktc3VtbWFyaWVz"
}

Two properties make it the right way to answer "how many":

  • It counts the filtered set, not the page. ?tag=checkout&limit=50 returns at most 50 rows and a total of every checkout-tagged flag in the workspace. Filters are applied before paging, so the two always describe the same set. On an unpaginated response total is simply the length of the array.
  • It doesn't move as you page. Every page of one paginated walk reports the same total, and it is already correct on a partial page — so counting never requires exhausting nextCursor, and a script or agent can report a number from the first bounded response.

total is a non-negative integer and is always present: an empty result is "total": 0, never an omitted field.

History and activity​

GET /v1/flags/{key}/history (and the configs twin) answers "who changed this and when" straight from git: commit SHA, message, author, date, and isServiceAccount.

GET /v1/activity is the workspace-wide feed with changes translated into human-readable messages ("production: enabled set to false"). Pass ?type=&key= together (type is one of feature_flag, config, log_level, segment, schema) to get one item's full translated history instead.

Both are metadata: they tell you which commits touched an item and who made them, never what the item looked like at those commits. To read the document itself at one of those shas, pass it to ?at= on the document endpoint.

Updating a flag​

PATCH /v1/flags/{key}/environments/{env} is the everyday write. It's deliberately narrow, and it stays that way — anything it can't express (multi-rule targeting, variants, metadata, and undo) goes through the document endpoints instead.

It sets the environment's fallback — the unconditional rule at the end of its rule list, what users receive when no targeting rule matches — expressed as exactly one of three operations:

OperationBodyFor
Toggle{"enabled": true}Boolean flags
Serve one value{"value": "gpt-5"}Any flag: everyone gets this value
Percentage rollout{"rollout": [{"value": true, "percent": 25}, {"value": false, "percent": 75}]}Splitting traffic across values

{env} is an environment name from GET /v1/environments — or the literal default, which writes the flag's default.rules: what every environment without an entry of its own inherits, and for many flags the only place a value is stored. Writing an environment shadows the default in that one environment and leaves the rest alone; writing default changes what every inheriting environment serves. default is a reserved name, so it can never collide with a real environment.

curl -X PATCH \
https://api.quonfig.com/v1/flags/checkout-redesign/environments/production \
-H "Authorization: Bearer $QUONFIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
{
"key": "checkout-redesign",
"environment": "production",
"changed": true,
"commitSha": "8c1f2ab...",
"previousCommitSha": "3d9e017...",
"rules": [{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "bool", "value": false } }]
}

previousCommitSha is the version this write moved off — the one to name in the undo recipe if the change turns out to be wrong. It's present whenever changed is true, and absent when changed is false, because a no-op write commits nothing and so has no version to revert to. If the server retried after losing a race, it names the parent of the commit that actually landed, not the version the losing attempt read.

Values are bare JSON (true, "foo", 42, arrays, objects) and are checked against the flag's valueType — sending a string to a bool flag is a 400 naming the expected shape. Rollout percents take up to 3 decimal places and must sum to exactly 100; for non-boolean flags each rollout value must match one of the flag's defined variants. Re-ramping a rollout keeps user bucketing sticky — the same users stay in the same bucket as the percentage moves.

The write is permission-checked exactly like the UI: the key's principal needs edit permission for this flag in this environment, or the request fails with 403 and details.code: "PERMISSION_DENIED".

Targeting rules are kept​

The PATCH changes one rule — the environment's fallback. Targeting rules and percentage rollouts above it are kept, in order, and the response says how many:

  • preservedTargetingRuleCount — how many targeting rules the write kept. Omitted when there are none. The trailing unconditional fallback is the value you just set, not a targeting rule, so it is never counted.

If the environment has no rules of its own it inherits the flag's default rules. Those rules are copied into the environment first, then the fallback is set — so the targeting it was inheriting keeps working, the same shape the app writes when an environment stops inheriting. Writing the "default" scope itself never needs the copy.

To set the value for everyone, including users matched by targeting rules, send:

{ "enabled": false, "replaceTargeting": true }
replaceTargeting deletes rules this endpoint can't rewrite

That collapses the environment to one unconditional rule and deletes its targeting rules. No PATCH — not this one, not another — can reconstruct them, because this endpoint only ever writes that single rule. Treat replaceTargeting: true as a confirm-with-a-human step, not a flag to reach for by default: without it the write keeps the targeting and tells you it did.

It is recoverable, and the response tells you exactly what you need:

  • replacedTargetingRuleCount — how many rules the single unconditional rule replaced. Present only when real targeting was destroyed; a plain re-toggle over an existing catch-all doesn't count and omits it.
  • previousCommitSha — the commit those rules were last stored in.

Feed that sha to the undo recipe: the document endpoint reads the flag as it stood at that commit, and PUT puts it back, targeting rules and all. Scripts and agents should still surface both fields to the person who asked — the recipe is a deliberate step someone takes, not an automatic rollback.

Concurrency: expectedCommitSha​

On the PATCH, expectedCommitSha is optional. (On the document endpoints it is required — a full replacement can't be safely re-applied to a state you never read.)

By default, concurrent writes are safe without any extra work: if another change lands mid-write, the server re-applies your operation to the fresh state and commits (your operation is absolute, so re-applying is always correct).

For check-then-act flows — "disable this flag only if it's still the version I just read" — pass the commitSha from a prior GET as expectedCommitSha. That makes the write single-shot compare-and-set: you get 409 with details.code: "STALE_COMMIT_SHA", and nothing is written, if the flag's content differs from its content at that commit. Re-read and decide again; the server never retries a pinned write.

The comparison is by content, not by activity. Two consequences worth knowing:

  • A commit that did not touch this flag does not invalidate your token. Any commitSha you read for this flag stays usable until the flag's own content changes — including a change that was later reverted back to the bytes you read.
  • A 409 means the stored content is genuinely not what you based your write on. It is not a report that "something happened here"; the history is still the place to look for that.

A token that is not a full 40-character commit sha — a branch or ref name, an abbreviated sha, anything else — is also a 409. Send the commitSha a read gave you, verbatim, or omit the field; a present-but-empty expectedCommitSha is a 400.

No-op writes​

If the environment already matches the requested state, the response has changed: false, the current commitSha, and no commit is made — no audit noise, no metered config update. Retrying a timed-out PATCH is therefore safe: the retry converges to a no-op instead of double-writing.

Updating a config​

PATCH /v1/configs/{key}/environments/{env} is the flag PATCH's twin. A config has no toggle and no rollout, so the body is always value:

curl -X PATCH \
https://api.quonfig.com/v1/configs/checkout.timeout/environments/production \
-H "Authorization: Bearer $QUONFIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value": "PT30S"}'
{
"key": "checkout.timeout",
"environment": "production",
"changed": true,
"commitSha": "8c1f2ab...",
"previousCommitSha": "3d9e017...",
"rules": [{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "duration", "value": "PT30S" } }]
}

Everything else is as for flags: value is bare JSON checked against the config's valueType (a duration is an ISO 8601 string such as PT30S, PT5M, PT1H30M or P1DT6H; only seconds may have a fraction); {env} is an environment name or the default scope; targeting rules above the fallback are kept and counted in preservedTargetingRuleCount, with a scope that has no rules of its own seeded from the default rules first; replaceTargeting: true collapses the scope and is recoverable through previousCommitSha and the undo recipe; expectedCommitSha makes the write compare-and-set; and a write that changes nothing commits nothing.

Two config-specific points:

  • Schema-bound configs are validated. If the config has a schemaKey, the value is checked against that schema, and a violation is a 400 with details.code: "SCHEMA_VIOLATION", naming the schemaKey and listing the violations.
  • Secrets are configs too. There is no special case for encrypted configs: a secret is a config whose stored value is ciphertext, and the access tier governs who can write it. This endpoint writes exactly the value you send, so it is the wrong tool for rotating a secret — use qfg secret, which encrypts locally before it writes.

Creating a flag or config​

POST /v1/flags and POST /v1/configs add a document. They are deliberately minimal: a create writes one unconditional default rule and an empty environments array, so every environment inherits that default until one is given an entry of its own. Anything richer — variants, targeting rules, sendToClientSdk, a non-standard access tier — is a follow-up PUT to the document endpoint, using the commitSha the create returns as expectedCommitSha.

curl -X POST https://api.quonfig.com/v1/flags \
-H "Authorization: Bearer $QUONFIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "checkout-redesign", "description": "New checkout flow", "tags": ["checkout"]}'
{
"key": "checkout-redesign",
"valueType": "bool",
"commitSha": "5e7a9c1...",
"document": { "key": "checkout-redesign", "type": "feature_flag", "valueType": "bool", "default": { "rules": [ "..." ] }, "environments": [], "variants": [] }
}

document is the complete JSON as written to git, the same shape GET .../document returns.

Flag body. key is required. type defaults to bool and may be string, int, double, string_list, json, or duration (boolean and string-list are accepted as aliases). A bool flag is created off in every environment and takes no value — creation is atomic and inert, and turning it on is a separate PATCH. Every other type requires value, which the default rule serves. description and tags are optional.

Config body. key, valueType, and value are all required. valueType is never inferred — 42 is an int or a double, "PT90S" is a string or a duration, and whatever a create guessed would be what every later write is validated against. The value is stored plain: this endpoint never encrypts, the config is created at the standard access tier, and sendToClientSdk is false. Secrets belong in qfg secret.

Keys are pooled case-insensitively across flags, configs, segments, and log levels, because they become filenames. A key any of them holds fails with 409 and details.code: "ALREADY_EXISTS", carrying details.collidingType (feature_flag, config, segment, or log_level) and details.collidingKey — the key as stored, which differs from yours only on a case-variant collision. Branch on those fields; never retry a create with a mutated key. The document written is validated in full, so a rejected create is a 400 with details.code: "VALIDATION_FAILED" and the failing field paths.

Raw documents​

Quonfig stores each flag, config, and log level as one JSON file in your workspace's git repo. The document endpoints hand you that file — not a projection of it — and take it back:

GET  /v1/flags/{key}/document         PUT  /v1/flags/{key}/document
GET /v1/configs/{key}/document PUT /v1/configs/{key}/document
GET /v1/log-levels/{key}/document PUT /v1/log-levels/{key}/document

Reach for them when the PATCH can't express what you want: multi-rule targeting, variants, tags and other metadata, readyForCleanup, access — or when you need to put a previous version back. For a plain toggle, value, or rollout, the PATCH is still the easier call and the safer default.

Reading a document​

curl https://api.quonfig.com/v1/flags/checkout-redesign/document \
-H "Authorization: Bearer $QUONFIG_API_KEY"
{
"commitSha": "8c1f2ab3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9",
"document": {
"$schema": "https://api.quonfig.com/schemas/v1/stored-config.json",
"key": "checkout-redesign",
"type": "feature_flag",
"valueType": "bool",
"name": "Checkout redesign",
"access": "standard",
"tags": ["checkout"],
"readyForCleanup": false,
"default": {
"rules": [
{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "bool", "value": false } }
]
},
"environments": [
{
"id": "production",
"rules": [
{
"criteria": [
{ "propertyName": "user.email", "operator": "PROP_ENDS_WITH_ONE_OF", "valueToMatch": { "type": "string_list", "value": ["@acme.com"] } }
],
"value": { "type": "bool", "value": true }
},
{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "bool", "value": false } }
]
}
],
"variants": []
}
}

Three properties make this different from GET /v1/flags/{key}:

  • Verbatim. access, $schema, type, and everything else the detail endpoints project away come back exactly as stored. Whatever you read is a valid body for the PUT.
  • Never validated. Reads aren't checked against the current schema, so a version written long ago still reads even when it would no longer pass validation today. Seeing history is the point.
  • Nothing is resolved. An encrypted value is the stored ciphertext with its decryptWith key name; an ENV_VAR value is the stored source and lookup pair. Quonfig servers never decrypt and never read an environment variable on your behalf — that happens in your runtime.

commitSha is the version handle: pass it straight back as the PUT's expectedCommitSha.

Reading an earlier version​

Add ?at=<sha> to read the document as of any commit instead of the current one:

curl "https://api.quonfig.com/v1/flags/checkout-redesign/document?at=3d9e017" \
-H "Authorization: Bearer $QUONFIG_API_KEY"

The sha is a full or abbreviated git SHA (4–40 hex characters) — from a history response, from /v1/activity, or from the previousCommitSha a write handed back. The response's commitSha echoes the sha you asked for, and document is what was stored at that commit.

Because this reads git rather than the current state, it tells two 404s apart: an unknown key ("Flag x not found") and a key that exists today but didn't exist yet at that commit ("Flag x did not exist at commit 3d9e017"). If the file at that commit isn't parseable JSON — a hand-edited history, a bad merge — you get 422, not a 500: the request was fine, the stored bytes just aren't a document.

Replacing a document​

PUT writes a document back. Two rules do most of the work.

It is a full replacement, not a merge. What you send is what gets stored, so a field you leave out is deleted. Always start from a GET of the document and edit that — never hand-build the body. (Deletion being expressible is the point: an undo has to be able to remove a field the bad write added.)

expectedCommitSha is required, and must come from a fresh GET of the same document. A full replacement built on a stale read would silently discard whatever landed in between, so read-before-write is enforced by contract here rather than left to you. If the document's content differs from its content at that commit, the write fails with 409 and details.code: "STALE_COMMIT_SHA", and nothing is written — re-read, re-apply your edit, and send again. Never retry the same body. A token that is not a full 40-character commit sha is a 409 too.

curl -X PUT \
https://api.quonfig.com/v1/flags/checkout-redesign/document \
-H "Authorization: Bearer $QUONFIG_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"expectedCommitSha": "8c1f2ab3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9",
"document": { "...": "the document you read, with your edit applied" }
}
JSON
{
"key": "checkout-redesign",
"changed": true,
"commitSha": "5f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a",
"previousCommitSha": "8c1f2ab3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9"
}

The rest of the contract:

  • Identity is fixed by the path. document.key must equal the key in the URL, and document.type must match the family — feature_flag, config, or log_level. A mismatch is a 400: this endpoint replaces one document in place, it can't rename or move it.
  • $schema is server-stamped. Send it, omit it, or send a stale one — the stored value is the same either way.
  • Writes are validated even though reads aren't. The body has to pass the current stored-config schema, so restoring a very old version can come back 400 naming the field that no longer validates. Fix the JSON forward and PUT again.
  • Changing access is permission-checked, exactly as in the app: you need edit permission for the tier the document is in today and for the tier you're moving it to. See Authorization.
  • Update-only. The endpoint never creates and never deletes — a PUT to an unknown key is a 404. Creating and deleting items stays in the app and the CLI.
  • No-op writes cost nothing. If your document is deep-equal to what's stored, the response is changed: false with the current commitSha and no commit is made. Key order and a re-stamped $schema don't count as changes, and previousCommitSha is absent because nothing was written.
  • No targeting guard. Unlike the PATCH, there is no fail-closed check on targeting rules here. That's deliberate: this endpoint can write any valid document, which is exactly what lets it put replaced rules back. Its safety is git — expectedCommitSha forces read-before-write, every version is retained, and every write names the version it moved off.

Undoing a write​

Restore is a recipe, not an endpoint. Every write that changed something — the PATCH and the PUT alike — returns previousCommitSha, the version it moved off. Read that version, then put it back.

KEY=checkout-redesign
DOC="https://api.quonfig.com/v1/flags/$KEY/document"
AUTH="Authorization: Bearer $QUONFIG_API_KEY"

# The bad write's response named the version it replaced:
# { "changed": true, "commitSha": "b0b0b0b...", "previousCommitSha": "a1b2c3d..." }
BAD_WRITE_REPLACED=a1b2c3d

# 1. The document as it stood before the bad write.
OLD=$(curl -s "$DOC?at=$BAD_WRITE_REPLACED" -H "$AUTH" | jq .document)

# 2. Where the flag is RIGHT NOW — this is what the PUT pins to.
NOW=$(curl -s "$DOC" -H "$AUTH" | jq -r .commitSha)

# 3. Put the old document back.
jq -n --argjson document "$OLD" --arg sha "$NOW" \
'{document: $document, expectedCommitSha: $sha}' \
| curl -s -X PUT "$DOC" -H "$AUTH" \
-H "Content-Type: application/json" --data-binary @-
{
"key": "checkout-redesign",
"changed": true,
"commitSha": "cafe1234cafe1234cafe1234cafe1234cafe1234",
"previousCommitSha": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"
}

The one thing to get right is step 3 pins to the current sha, not the historical one. expectedCommitSha means "I have read the version I am about to overwrite" — that's the bad write, sitting at the top of the branch. The old sha only ever appears in ?at=.

Two consequences worth stating plainly:

  • Undo is a normal write. It commits forward, attributed to your key, with the bad commit still in history. Nothing is rewritten or lost, and qfg, the app, and this API all agree on what happened.
  • It recovers what the PATCH can't. A replaceTargeting: true PATCH is the only write that deletes targeting rules, and they come back through this recipe, because the document endpoint can write the multi-rule document the PATCH could only overwrite. That's the reason the pair exists.

Log levels​

Log levels have their own surface: a list, a surgical PATCH, and the document pair. There is one document per service (log-level.checkout-service), and individual loggers are targeting rules inside it — each a logger path prefix, matched on the quonfig-sdk-logging.key context property the SDKs populate from the logger's path, so a rule for Checkout.Payments covers that logger and everything under it.

GET /v1/log-levels lists every service's document, projected per scope — the default block first, then each environment with an entry of its own (an environment absent from scopes inherits default entirely):

{
"logLevels": [
{
"key": "log-level.checkout-service",
"tags": [],
"scopes": [
{ "scope": "default", "fallbackLevel": "INFO", "targets": [{ "target": "Checkout.Payments", "level": "DEBUG" }], "otherRuleCount": 0 },
{ "scope": "production", "fallbackLevel": "WARN", "targets": [], "otherRuleCount": 0 }
],
"commitSha": "a1b2c3d...",
"lastModified": "2026-09-08T18:37:00Z"
}
]
}
  • fallbackLevel — what the scope's unconditional catch-all serves, i.e. what a logger with no matching rule gets. Absent when the scope has no catch-all, which for an environment means its unmatched loggers fall through to default.
  • targets — the per-logger rules as target -> level pairs, in evaluation order.
  • otherRuleCount — rules that are neither a catch-all nor a logger-prefix rule (documents can be hand-written with any operator). Read the document when it isn't 0.

This is the stored targeting, not the answer for one logger name — "what level does logger X get" is resolved SDK-side over context the server doesn't have. The list is unpaginated: a workspace has one document per service, a handful rather than hundreds.

PATCH /v1/log-levels/{key} sets one level and preserves every rule it didn't name:

curl -X PATCH https://api.quonfig.com/v1/log-levels/checkout-service \
-H "Authorization: Bearer $QUONFIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"level": "DEBUG", "target": "Checkout.Payments", "environment": "production"}'
{
"key": "log-level.checkout-service",
"environment": "production",
"target": "Checkout.Payments",
"level": "DEBUG",
"created": false,
"changed": true,
"commitSha": "8c1f2ab...",
"previousCommitSha": "a1b2c3d..."
}
  • key names the service; the log-level. prefix is added when you omit it, and the response returns the full key.
  • level is one of TRACE, DEBUG, INFO, WARN, ERROR, FATAL.
  • target is a logger path prefix. With it, the write adds or overwrites just that prefix's rule. Without it, the write sets only the scope's fallback level, leaving the targeted rules above it in place.
  • environment is an environment name or default, and — unlike the flag and config PATCHes, where the scope is in the URL — it is optional and defaults to default, which for most log-level documents is the only place a level is stored. A scope with no rules of its own is seeded from the default rules first, so inherited per-logger targeting is kept.
  • There is no replaceTargeting. Nothing here is ever flattened; a repeated call converges instead of stacking duplicates.
  • If the service has no document yet, one is created with a single unconditional default rule serving level, and the response says created: true. The new key goes through the same pooled-key check as a create, so a key held by a flag, config, or segment is a 409 ALREADY_EXISTS. With expectedCommitSha set, a missing document is a 404 rather than a create.
  • A changed write returns previousCommitSha; the undo recipe applies unchanged.

The document pair is the same as for flags and configs. A log-level document is shaped like any other stored config, with type log_level and valueType log_level:

{
"$schema": "https://api.quonfig.com/schemas/v1/stored-config.json",
"key": "log-level.checkout-service",
"type": "log_level",
"valueType": "log_level",
"default": {
"rules": [
{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "log_level", "value": "INFO" } }
]
},
"environments": [
{
"id": "production",
"rules": [
{ "criteria": [{ "operator": "ALWAYS_TRUE" }], "value": { "type": "log_level", "value": "WARN" } }
]
}
],
"variants": []
}

Everything in Raw documents applies unchanged: ?at= reads any past version, PUT is a full replacement with a required expectedCommitSha, and the document endpoints update only — creating a service's first document is the PATCH's job.

Errors​

Every non-2xx response is a JSON envelope:

{
"error": "NOT_FOUND",
"message": "Flag checkout-redesign not found",
"details": { }
}

error is a stable machine-readable code, message is human-readable, and details (present when useful) carries structured data — validation issues on 400, and a details.code discriminator where one status has several causes. Match on error (and details.code), never on message text.

HTTPerrordetails.codeWhen
400BAD_REQUEST—Malformed parameter or body; value doesn't match the item's valueType; bad rollout; a value sent to a bool flag create, or missing from any other create; limit out of range or a cursor this server didn't mint; a malformed ?at sha; a document whose key or type disagrees with the URL
400BAD_REQUESTVALIDATION_FAILEDThe document a create or write would store fails the stored-config schema — details lists the failing field paths
400BAD_REQUESTSCHEMA_VIOLATIONA schema-bound JSON config's new value breaks its schema — details.schemaKey names the schema and details.violations the failures
401UNAUTHORIZED—Missing, invalid, or revoked key; disabled service account
402BILLING_INACTIVE—The organization's subscription is inactive
403FORBIDDENPERMISSION_DENIEDThe key's principal can't edit this flag in this environment, or can't move a document between access tiers
404NOT_FOUND—Unknown flag/config/log-level key, environment, or path; a PUT to a key that doesn't exist; a key that didn't exist yet at the requested ?at commit; a log-level PATCH with expectedCommitSha for a service that has no document yet
409CONFLICTSTALE_COMMIT_SHAexpectedCommitSha no longer matches
409CONFLICTALREADY_EXISTSA create — or a log-level PATCH creating a service's first document — for a key already held by a flag, config, segment, or log level; details.collidingType and details.collidingKey say what holds it
422UNPROCESSABLE_CONTENTVERIFY_REJECTIONThe change was rejected by config validation
422UNPROCESSABLE_CONTENT—The content stored at the requested ?at commit isn't a JSON object
429——Rate limit exceeded — honor Retry-After
503SERVICE_UNAVAILABLE—Workspace provisioning in progress, or a transient storage failure — safe to retry

These responses are also declared per-operation in the OpenAPI spec, so generated clients know the envelope shape.

Rate limits​

Each key gets a generous per-key rate limit — on the order of a few requests per second sustained, with burst headroom well above that. It exists to stop runaway retry loops, not to squeeze legitimate use: a well-behaved script or agent should never see it. The exact numbers may be tuned over time, so don't hard-code them.

Over the limit, requests fail with 429 and a Retry-After header giving the number of seconds to wait. The contract is simple: wait Retry-After seconds, then retry. Clients that back off correctly recover immediately; clients that hammer through 429s stay throttled.

Versioning​

The API is versioned in the path, and v1 is stable:

  • Within v1, changes are additive only — new endpoints, new optional request fields, new response fields, new values in open string sets (valueType, operator, statuses). Your client should ignore fields and string values it doesn't recognize; generated clients from the spec below do this naturally.
  • Breaking changes mint /v2 — v1 keeps working. Removing or renaming a field, changing a type, or changing an endpoint's semantics never happens silently inside v1.

OpenAPI spec & client generation​

The machine-readable contract lives at:

https://api.quonfig.com/v1/openapi.json

Browse it as a rendered reference at https://api.quonfig.com/v1/docs — same spec, no client generation required.

The raw spec declares every operation, schema, and error response, so standard generators produce a complete typed client. For example:

# TypeScript (fetch-based)
npx @hey-api/openapi-ts \
-i https://api.quonfig.com/v1/openapi.json \
-o src/quonfig-client

# Any of openapi-generator's 50+ languages, e.g. Go
openapi-generator generate \
-i https://api.quonfig.com/v1/openapi.json \
-g go -o quonfig-client

Authentication is declared as the apiKey HTTP bearer scheme — supply your qf_uk_ / qf_sa_ key wherever your generated client takes a bearer token.

Agents and LLM tooling

The spec is also the right thing to hand to agent frameworks that consume OpenAPI directly — point them at /v1/openapi.json and scope them with a service-account key so their changes are attributed to the bot, not to you.

For MCP clients — Claude Code, Claude Tag, and anything else that speaks the protocol — you don't need the spec at all: this surface is already exposed as tools by the Quonfig MCP server.