Skip to content
NanoFlags
Start free

Reference

Edge HTTP API

The edge serves published configuration to SDKs and offers the CI and admin API used by the CLI. SDKs evaluate locally, so the edge is never on the evaluation path. Use the SDK where you can; this page is for other languages, debugging, and your own tooling.

Base URL and authentication

text
https://edge.nanoflags.io

Authenticate with a key of one environment in the Authorization header. Keys and session tokens are only ever compared as SHA-256 hashes and are never logged by the edge.

sh
curl -H "Authorization: Bearer $NANOFLAGS_SDK_KEY" https://edge.nanoflags.io/v1/manifest
EndpointClient nf_pub_Server nf_srv_CI nf_ci_Admin nf_adm_
/v1/bundle, /v1/live, /v1/sessionyesyes403403
/v1/stream, /v1/eventsyesyesnot supported403
/v1/manifestyesyesyes403
/v1/ci/*, /v1/admin/export403403yesyes
/v1/admin/publish, /v1/admin/flags403403403yes
/v1/keys, /healthzno authno authno authno auth
  • /v1/stream also accepts ?key=, for client keys only (EventSource cannot send headers). Such URLs end up in request logs; client keys are public by design, but treat logs accordingly.
  • /v1/live accepts the header or, for browsers, the WebSocket subprotocol list; never a key in the URL.
  • Client keys receive the client view: only flags marked client-exposed, and only the segments those flags reference.
  • A validated key is cached at the edge for up to 60 s, so a revocation can take up to a minute to take effect for new requests (longer while the NanoFlags origin is unreachable; see Last-known-good).

Errors

Every error is JSON with a stable code:

json
{ "error": { "code": "unauthorized", "message": "Unknown or revoked SDK key." } }
StatuscodeWhen
400invalid_request, invalid_jsonThe body or a header does not meet the endpoint's rules.
401unauthorizedMissing, malformed, unknown or revoked key (WWW-Authenticate: Bearer realm="nanoflags").
403forbiddenThe key's kind may not use this endpoint.
404not_found, session_not_foundUnknown endpoint; unknown, expired or revoked test session.
405method_not_allowedWrong method (Allow header lists the right one; HEAD is accepted for GET).
409DESTINATION_CHANGED, NO_CHANGESPublish raced or has nothing to do.
413payload_too_largeBody above the endpoint's limit.
422CHANGE_REQUIRED, SCENARIOS_REQUIRED, INVALID_STATE, …Refused by NanoFlags (codes from the database, in capitals).
426upgrade_required/v1/live called without a WebSocket upgrade.
500internal_errorUnexpected error.
503origin_unavailable, live_unavailableNanoFlags cannot answer and no last-known-good copy exists; live socket not available. Retry-After is set. Nothing is reported as done unless it was recorded.

GET /v1/bundle

The published configuration of the key's environment. SDK keys only.

request
GET /v1/bundle HTTP/1.1
Host: edge.nanoflags.io
Authorization: Bearer nf_srv_…
If-None-Match: "92574874cee3f91a0a4cc026142e35df-server"
response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-cache
Vary: Authorization
ETag: "92574874cee3f91a0a4cc026142e35df-server"
X-NanoFlags-Signed: true
X-NanoFlags-Environment-Id: 18f1768e-840b-4a82-a9ab-437008b88ca9
X-NanoFlags-Key-Id: k1
X-NanoFlags-Signature: 31mqy6-tV4rRk1cf…ERAw

{"revision":108,"checksum":"92574874cee3f91a0a4cc026142e35df","bundle":{"schema":1,"project":"commerce","environment":"staging","revision":108,"flags":{…},"segments":{…}}}
  • X-NanoFlags-Signature and X-NanoFlags-Key-Id are present only when X-NanoFlags-Signed: true. X-NanoFlags-Environment-Id is present whenever the environment is known, also on unsigned answers.
  • Right after a publish, until signing has caught up, the edge may answer from the database with X-NanoFlags-Signed: false.
  • Errors: 401, 403 (CI or admin key), 503 origin_unavailable.

A bundle contains schema, project, environment, revision, flags (flag key → { type, enabled, rules, default, off, clientExposed }) and segments. See how a flag is evaluated.

GET /v1/manifest

The current revision without the bundle. SDK and CI keys.

200
{ "revision": 108, "checksum": "92574874cee3f91a0a4cc026142e35df", "environment": "staging" }

GET /v1/live (WebSocket)

Pushes every published revision, with the signed bundle in the message. SDK keys.

Connecting

  • Servers: Authorization: Bearer <sdk key> on the upgrade request.
  • Browsers: subprotocols ["nanoflags.v1", "key.<sdk key>"]; the server answers with nanoflags.v1.
  • Errors before the upgrade: 401 (no or unknown key, malformed protocol list, key in the URL), 403 (CI or admin key), 426 (not a WebSocket request), 503 live_unavailable (connection cap reached, or live updates unavailable): fall back to /v1/stream or polling.
browser
const ws = new WebSocket("wss://edge.nanoflags.io/v1/live", ["nanoflags.v1", "key.nf_pub_…"]);
ws.onmessage = (e) => {
  if (e.data === "pong") return;
  const m = JSON.parse(e.data);           // { t: "hello" | "rev", revision, bundle, … }
  if (m.bundle) apply(JSON.parse(m.bundle), m);
};
setInterval(() => ws.send("ping"), 30_000);

Messages

Server to client, JSON text. hello on connect (the current state, so a reconnecting client catches up in one message), rev on every publish:

json
{
  "t": "rev",
  "revision": 109,
  "checksum": "0b6c2a…",
  "signature": "base64url Ed25519 signature",
  "kid": "k1",
  "environmentId": "18f1768e-840b-4a82-a9ab-437008b88ca9",
  "bundle": "{\"schema\":1,\"project\":\"commerce\",…}",
  "committedAt": 1762770000123,
  "sentAt": 1762770000301
}
  • bundle is a string: the exact signed JSON text. Verify it as described under Signatures with environmentId, revision, checksum, kid and signature; client-key sockets receive the client view and its signature.
  • committedAt and sentAt are epoch milliseconds. committedAt (the database commit time) can be null.
  • An environment with nothing published greets with { "t": "hello", "revision": 0, "bundle": null, … }.
  • Client to server: the text ping, answered with pong.
  • A socket is authenticated when it opens. A socket older than 2 hours is closed with code 4001 (reauthenticate) at the next publish instead of receiving it; reconnect. The SDK replaces its socket about every 50 minutes.
  • When the environment or its project is deleted, open sockets are closed with code 4004 (environment_deleted). Do not reconnect; the key no longer works.
  • An environment accepts up to 10,000 live sockets; beyond that the edge answers 503. Revisions larger than about 1.9 MB are not pushed; clients get them by polling.

GET /v1/stream (SSE)

Server-sent events carrying only the revision; fetch the bundle yourself (conditionally). SDK keys; ?key= for client keys.

text/event-stream
retry: 3000
event: revision
data: {"revision":108,"checksum":"92574874cee3f91a0a4cc026142e35df"}

: hb

event: revision
data: {"revision":109,"checksum":"0b6c2a…"}
  • The first revision event is sent immediately; later ones on change. Comment lines (: hb) are heartbeats. The server closes the stream after about 5 minutes; reconnect.
  • A revoked key is noticed when the stream reconnects. The stream may also end with event: revoked.

POST /v1/events

Service report plus sampled evaluations. SDK keys. At most 64 KB and 50 events per request.

request body
{
  "service": "checkout-api",
  "sdk": "js/0.3.0",
  "revision": 108,
  "events": [
    {
      "flagKey": "checkout-v2",
      "value": true,
      "reason": "rule_match",
      "ruleIndex": 0,
      "revision": 108,
      "context": { "key": "string", "region": "string" },
      "at": "2026-11-10T09:30:00.000Z"
    }
  ]
}
202
{ "accepted": 1, "capped": false }
FieldRules
serviceRequired, 1–60 characters. Self-declared.
sdkRequired string, at most 40 characters.
revisionRequired, the revision the process has loaded (non-negative integer).
events[].flagKeyRequired, 1–63 characters.
value, reason (≤ 30), ruleIndex, revisionOptional.
contextOptional object. Whatever you send is stored: redact before sending.
requestId (≤ 64), at (ISO 8601)Optional.
sessionIdOptional test-session id (UUID) from /v1/session, never the token.
  • The service report is recorded on every accepted request. Events are stored until the environment's daily cap is reached; then capped: true and events are not stored until the next day (UTC). See daily event cap.
  • Telemetry is best-effort: on 503 drop the batch rather than retrying.

GET /v1/session

Resolves a test session. SDK keys. Never cached (Cache-Control: no-store).

request
GET /v1/session HTTP/1.1
Authorization: Bearer nf_srv_…
X-NanoFlags-Session: nf_ts_…
X-NanoFlags-Service: checkout-api
200
{
  "id": "6d0f…",
  "checksum": "…",
  "source": "draft",
  "revision": null,
  "context": { "region": "DE" },
  "expires_at": "2026-11-10T12:00:00Z",
  "bundle": { "schema": 1, "flags": { … }, "segments": { … } }
}

400 without the session header (or a service name above 60 characters), 401 unknown key, 403 CI/admin key, 404 session_not_found (unknown, expired, revoked, or this service is not part of the session), 503 origin_unavailable. Session bundles are not signed.

GET /v1/keys

Public signing keys as a JWKS-style list, no authentication, cacheable for 5 minutes.

200
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "wQu1EsaE-yTjqpUbibGyBrajy3IiRb7LyaiqeYRxG4U", "kid": "k1", "alg": "EdDSA", "use": "sig", "status": "active" } ] }
  • status is active for the key that signs new bundles, or retiring for a previous key that stays listed during a rotation so bundles signed with it still verify. Select the key by kid, whatever its status.
  • Every bundle also carries engine, the evaluation engine version it needs. A client that does not support that version should keep its previous configuration.

CI API

CI and admin keys. SDK keys get 403.

POST /v1/ci/scenarios/run

request body (all optional, ≤ 16 KB)
{ "source": "draft", "commit": "3b1f0c2…", "runUrl": "https://github.com/acme/shop/actions/runs/123" }
200
{
  "source": "draft",
  "checksum": "5e0d…",
  "publishedRevision": 12,
  "passed": 1,
  "failed": 1,
  "results": [
    { "id": "…", "name": "US signed-in gets v2", "flag": "checkout-v2", "expected": true, "actual": true, "passed": true, "reason": "rule_match" },
    { "id": "…", "name": "EU stays on v1", "flag": "checkout-v2", "expected": false, "actual": true, "passed": false, "reason": "rule_match" }
  ]
}
  • source: draft (default) or revision. commit ≤ 64 characters; runUrl http(s), ≤ 300 characters.
  • HTTP 200 also when scenarios fail: check failed. Comparison is JSON deep equality; a missing flag (flag_not_found) or an evaluation error never passes.
  • checksum is the draft's for draft and the published revision's for revision. source: revision with nothing published → 422 INVALID_STATE. If the run cannot be recorded the call answers 503.

PUT /v1/ci/code-refs

request body (≤ 4 MB, ≤ 5,000 refs)
{
  "repo": "acme/shop",
  "commit": "3b1f0c2…",
  "refs": [ { "flag": "checkout-v2", "path": "src/cart.ts", "line": 42, "snippet": "if (flags.boolean(\"checkout-v2\", false, ctx)) {" } ]
}
200
{ "stored": 1 }

Replaces the references stored for repo. commit and snippet are optional; snippets are cut to 240 characters.

GET /v1/ci/published

The published revision of the key's environment, for evaluating what SDKs receive without holding an SDK key (used by nanoflags eval --source published).

200
{ "revision": 108, "checksum": "92574874cee3f91a0a4cc026142e35df", "bundle": { "schema": 1, "flags": { … }, "segments": { … } } }

The full bundle (all flags), unsigned: signatures belong to /v1/bundle. Nothing published yet → 422 INVALID_STATE.

Admin API

GET /v1/admin/export

CI and admin keys. The project as JSON: flags (with permanent, archived, …), segments, environments, and the draft of every environment. This is what nanoflags export prints.

POST /v1/admin/publish

Admin keys only. Publishes the draft of the key's environment.

request body
{ "expectedRevision": 12, "note": "enable checkout v2 for staff" }
200
{ "revision": 13, "checksum": "9c41…" }
  • expectedRevision is required: the revision you believe is published now. note ≤ 500 characters.
  • 409 DESTINATION_CHANGED (someone published first), 409 NO_CHANGES, 422 CHANGE_REQUIRED (the environment requires a reviewed change; keys cannot bypass review), 422 SCENARIOS_REQUIRED.

POST /v1/admin/flags

Admin keys only. Creates a flag in the key's project; every environment's draft starts with default. Nothing is published. This is what nanoflags flags create calls.

request body
{ "key": "new-checkout", "type": "boolean", "default": false, "description": "New checkout flow", "name": "New checkout" }
201
{ "id": "…", "key": "new-checkout", "name": "New checkout", "type": "boolean", "default": false, "environments": ["development", "staging", "production"] }
  • key, type (boolean, string, number, json) and default are required; description ≤ 2000 and name ≤ 120 characters (default: the key).
  • 409 CONFLICT (the key exists in the project), 422 INVALID_VALUE (the default does not match the type), 400 for a malformed body.

Signatures

Published bundles are signed with Ed25519. The signature (base64url, 64 bytes, no padding) covers the UTF-8 bytes of this canonical string:

canonical string
nanoflags-bundle-v1
<environment id>
<revision>
<checksum>
<sha256 hex of the bundle JSON text, exactly as sent>
  1. Take X-NanoFlags-Environment-Id from the headers and revision, checksum from the body (or environmentId, revision, checksum from a live message).
  2. Hash the bundle text: on /v1/bundle, the raw bytes between "bundle": and the final } of the body; on /v1/live, the bundle string. Do not re-serialize the JSON (JavaScript may hash JSON.stringify(body.bundle) of the parsed body, which reproduces the same text).
  3. Join the five lines with \n (no trailing newline) and verify the signature with the key from /v1/keys whose kid equals X-NanoFlags-Key-Id.
verify.mjs (Node 20+)
import { createHash, createPublicKey, verify } from "node:crypto";

const base = "https://edge.nanoflags.io";
const res = await fetch(`${base}/v1/bundle`, { headers: { Authorization: `Bearer ${process.env.NANOFLAGS_SDK_KEY}` } });
const text = await res.text(); // keep the raw text: the signature covers the bundle bytes exactly as sent

if (res.headers.get("x-nanoflags-signed") !== "true") throw new Error("unsigned bundle");
const { revision, checksum } = JSON.parse(text);
const marker = '"bundle":';
const bundleText = text.slice(text.indexOf(marker) + marker.length, text.lastIndexOf("}"));
const canonical = [
  "nanoflags-bundle-v1",
  res.headers.get("x-nanoflags-environment-id"),
  revision,
  checksum,
  createHash("sha256").update(bundleText, "utf8").digest("hex"),
].join("\n");

const { keys } = await (await fetch(`${base}/v1/keys`)).json(); // or pin the key in your build
const jwk = keys.find((k) => k.kid === res.headers.get("x-nanoflags-key-id"));
const ok = verify(
  null,
  Buffer.from(canonical, "utf8"),
  createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, format: "jwk" }),
  Buffer.from(res.headers.get("x-nanoflags-signature"), "base64url"),
);
console.log(ok ? `r${revision} verified` : "signature mismatch");
What a signature does and does not prove
A valid signature proves the bundle is exactly what NanoFlags published for that environment and revision. It says nothing about freshness: it does not stop someone from serving an older signed revision or withholding updates. Keys fetched from /v1/keys are only as trustworthy as the edge that served them; pin the public key in your build if the edge is in your threat model.

ETag and 304

/v1/bundle answers with ETag: "<checksum>-<kind>" (kind is server or client, because the two views differ). Send it back in If-None-Match; when nothing changed the answer is 304 Not Modified without a body, carrying the same X-NanoFlags-* headers. Weak validators (W/) and * are accepted.

Last-known-good answers

If NanoFlags' origin is unreachable or slow, the edge keeps serving the last configuration it holds for keys it validated within the last 24 hours, and marks the answer with X-NanoFlags-Stale: true and X-NanoFlags-Stored-At. Without any stored copy it answers 503. A revoked key can therefore keep receiving configuration while the origin is down, for up to 24 hours. Only an explicit "unknown key" from the origin is treated as a revocation; an origin error never is.

CORS

Every endpoint is CORS-enabled for any origin. Preflight OPTIONS requests answer 204.

http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, If-None-Match, Cache-Control, Last-Event-ID, X-NanoFlags-Session, X-NanoFlags-Service
Access-Control-Expose-Headers: ETag, X-NanoFlags-Stale, X-NanoFlags-Stored-At, X-NanoFlags-Signed, X-NanoFlags-Signature, X-NanoFlags-Key-Id, X-NanoFlags-Environment-Id
Access-Control-Max-Age: 86400

Only client keys belong in browser code. GET /healthz answers { "ok": true, … } for liveness checks.