Reference
Edge HTTP API
On this page
Base URL and authentication
https://edge.nanoflags.ioAuthenticate 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.
curl -H "Authorization: Bearer $NANOFLAGS_SDK_KEY" https://edge.nanoflags.io/v1/manifest| Endpoint | Client nf_pub_ | Server nf_srv_ | CI nf_ci_ | Admin nf_adm_ |
|---|---|---|---|---|
/v1/bundle, /v1/live, /v1/session | yes | yes | 403 | 403 |
/v1/stream, /v1/events | yes | yes | not supported | 403 |
/v1/manifest | yes | yes | yes | 403 |
/v1/ci/*, /v1/admin/export | 403 | 403 | yes | yes |
/v1/admin/publish, /v1/admin/flags | 403 | 403 | 403 | yes |
/v1/keys, /healthz | no auth | no auth | no auth | no auth |
/v1/streamalso accepts?key=, for client keys only (EventSourcecannot send headers). Such URLs end up in request logs; client keys are public by design, but treat logs accordingly./v1/liveaccepts 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:
{ "error": { "code": "unauthorized", "message": "Unknown or revoked SDK key." } }| Status | code | When |
|---|---|---|
| 400 | invalid_request, invalid_json | The body or a header does not meet the endpoint's rules. |
| 401 | unauthorized | Missing, malformed, unknown or revoked key (WWW-Authenticate: Bearer realm="nanoflags"). |
| 403 | forbidden | The key's kind may not use this endpoint. |
| 404 | not_found, session_not_found | Unknown endpoint; unknown, expired or revoked test session. |
| 405 | method_not_allowed | Wrong method (Allow header lists the right one; HEAD is accepted for GET). |
| 409 | DESTINATION_CHANGED, NO_CHANGES | Publish raced or has nothing to do. |
| 413 | payload_too_large | Body above the endpoint's limit. |
| 422 | CHANGE_REQUIRED, SCENARIOS_REQUIRED, INVALID_STATE, … | Refused by NanoFlags (codes from the database, in capitals). |
| 426 | upgrade_required | /v1/live called without a WebSocket upgrade. |
| 500 | internal_error | Unexpected error. |
| 503 | origin_unavailable, live_unavailable | NanoFlags 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.
GET /v1/bundle HTTP/1.1
Host: edge.nanoflags.io
Authorization: Bearer nf_srv_…
If-None-Match: "92574874cee3f91a0a4cc026142e35df-server"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-SignatureandX-NanoFlags-Key-Idare present only whenX-NanoFlags-Signed: true.X-NanoFlags-Environment-Idis 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.
{ "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 withnanoflags.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/streamor polling.
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:
{
"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
}bundleis a string: the exact signed JSON text. Verify it as described under Signatures withenvironmentId,revision,checksum,kidandsignature; client-key sockets receive the client view and its signature.committedAtandsentAtare epoch milliseconds.committedAt(the database commit time) can benull.- An environment with nothing published greets with
{ "t": "hello", "revision": 0, "bundle": null, … }. - Client to server: the text
ping, answered withpong. - 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.
retry: 3000
event: revision
data: {"revision":108,"checksum":"92574874cee3f91a0a4cc026142e35df"}
: hb
event: revision
data: {"revision":109,"checksum":"0b6c2a…"}- The first
revisionevent 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.
{
"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"
}
]
}{ "accepted": 1, "capped": false }| Field | Rules |
|---|---|
service | Required, 1–60 characters. Self-declared. |
sdk | Required string, at most 40 characters. |
revision | Required, the revision the process has loaded (non-negative integer). |
events[].flagKey | Required, 1–63 characters. |
value, reason (≤ 30), ruleIndex, revision | Optional. |
context | Optional object. Whatever you send is stored: redact before sending. |
requestId (≤ 64), at (ISO 8601) | Optional. |
sessionId | Optional 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: trueand 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).
GET /v1/session HTTP/1.1
Authorization: Bearer nf_srv_…
X-NanoFlags-Session: nf_ts_…
X-NanoFlags-Service: checkout-api{
"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.
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "wQu1EsaE-yTjqpUbibGyBrajy3IiRb7LyaiqeYRxG4U", "kid": "k1", "alg": "EdDSA", "use": "sig", "status": "active" } ] }statusisactivefor the key that signs new bundles, orretiringfor a previous key that stays listed during a rotation so bundles signed with it still verify. Select the key bykid, 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
{ "source": "draft", "commit": "3b1f0c2…", "runUrl": "https://github.com/acme/shop/actions/runs/123" }{
"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) orrevision.commit≤ 64 characters;runUrlhttp(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. checksumis the draft's fordraftand the published revision's forrevision.source: revisionwith nothing published → 422INVALID_STATE. If the run cannot be recorded the call answers 503.
PUT /v1/ci/code-refs
{
"repo": "acme/shop",
"commit": "3b1f0c2…",
"refs": [ { "flag": "checkout-v2", "path": "src/cart.ts", "line": 42, "snippet": "if (flags.boolean(\"checkout-v2\", false, ctx)) {" } ]
}{ "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).
{ "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.
{ "expectedRevision": 12, "note": "enable checkout v2 for staff" }{ "revision": 13, "checksum": "9c41…" }expectedRevisionis required: the revision you believe is published now.note≤ 500 characters.- 409
DESTINATION_CHANGED(someone published first), 409NO_CHANGES, 422CHANGE_REQUIRED(the environment requires a reviewed change; keys cannot bypass review), 422SCENARIOS_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.
{ "key": "new-checkout", "type": "boolean", "default": false, "description": "New checkout flow", "name": "New checkout" }{ "id": "…", "key": "new-checkout", "name": "New checkout", "type": "boolean", "default": false, "environments": ["development", "staging", "production"] }key,type(boolean,string,number,json) anddefaultare required;description≤ 2000 andname≤ 120 characters (default: the key).- 409
CONFLICT(the key exists in the project), 422INVALID_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:
nanoflags-bundle-v1
<environment id>
<revision>
<checksum>
<sha256 hex of the bundle JSON text, exactly as sent>- Take
X-NanoFlags-Environment-Idfrom the headers andrevision,checksumfrom the body (orenvironmentId,revision,checksumfrom a live message). - Hash the bundle text: on
/v1/bundle, the raw bytes between"bundle":and the final}of the body; on/v1/live, thebundlestring. Do not re-serialize the JSON (JavaScript may hashJSON.stringify(body.bundle)of the parsed body, which reproduces the same text). - Join the five lines with
\n(no trailing newline) and verify the signature with the key from/v1/keyswhosekidequalsX-NanoFlags-Key-Id.
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");/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.
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: 86400Only client keys belong in browser code. GET /healthz answers { "ok": true, … } for liveness checks.