FlagBeta

SDK protocol, version 1

How SDKs talk to flag’s data plane at https://flag.nightroll.app, under /sdk/v1/ and /ofrep/v1/. These paths serve compiled snapshots, change streams, remote evaluation and event intake; they take no sign-in cookie and never reach the dashboard or the REST API (GET /sdk/v1/healthz answers ok <version>). Evaluation itself is defined in evaluation.md.

Freshness. A change reaches streaming SDKs in about a second. Polling SDKs, remote evaluation and OFREP see it within about 70 seconds. A revoked key stops resolving within about 90 seconds, and its open streams close at once.

Keys

Key Looks like Where Secret
Server SDK key srv_ + 40 letters/digits Back ends. Reads the full (encrypted) snapshot. Yes
Client ID cli_ + 24 letters/digits Browsers and apps. Reads client-visible flags only. No

Each key belongs to one environment. Server keys go in the Authorization header, bare or as Bearer <key>. Client IDs go in the URL path (browsers’ EventSource can’t send headers); OFREP also accepts them as Authorization: Bearer cli_.... A revoked or unknown key gets 401.

Server snapshots

GET /sdk/v1/server/snapshot[?v={version}] with the server key. Send If-None-Match with the last ETag to get 304. Pass the version from the change stream as v: the response is then at least that version even while edge caches lag behind the stream.

200 OK
Content-Type: application/json
ETag: "42"
Cache-Control: no-cache

The body is an envelope:

{
  "format": "flag-snapshot-v1",
  "envId": "env_...",
  "version": 42,
  "keys": [{"kid": "16 hex chars", "wrapped": "base64"}],
  "ciphertext": "base64"
}

To open it with server key K:

  1. kid = first 16 chars of lowercase hex SHA-256(UTF-8 "kid:" + K); find the entry in keys with that kid (none: this key is not a recipient; it was probably just revoked).
  2. KEK = HKDF-SHA256(IKM = UTF-8 K, salt = UTF-8 "flag-snapshot-v1", info = UTF-8 envId, length 32).
  3. base64-decode wrapped (standard alphabet, padded): 12-byte nonce, then ciphertext with the 16-byte GCM tag. AES-256-GCM-decrypt it with KEK and additional data UTF-8 "flag-snapshot-v1:dek:" + envId + ":" + version (version in decimal): the 32-byte data key.
  4. Decrypt ciphertext the same way with the data key and additional data "flag-snapshot-v1:" + envId + ":" + version: the snapshot JSON of evaluation.md.

Every version has a fresh data key, wrapped for every active server key of the environment, so caches and CDNs can hold envelopes without being able to read them. spec/vectors/envelope.json is a worked example.

Client snapshots

GET /sdk/v1/client/{clientId}/snapshot[?v={version}]: the client-safe snapshot, plain JSON (not an envelope): only flags with clientSide: true (plus the prerequisites and segments they use), with hashedTargets: true. ETag and If-None-Match work as above. With v equal to the current version the response is immutable (Cache-Control: public, max-age=31536000, immutable); otherwise public, max-age=0, must-revalidate. Pass the version from the change stream as v so CDNs can cache each version forever. Every /sdk/v1/client/... and /ofrep/... endpoint allows any origin (CORS, including preflights); browsers don’t need to send User-Agent or If-None-Match (the HTTP cache revalidates on its own).

Change stream

GET /sdk/v1/server/stream (server key in Authorization) or GET /sdk/v1/client/{clientId}/stream: Server-Sent Events.

retry: 5000

event: version
data: {"version":42}

event: heartbeat
data: {"t":1760000000000}

version is sent on connect and whenever a new snapshot is published; fetch the snapshot when it is newer than yours. heartbeat comes every 30 seconds: if nothing arrives for 90 seconds, reconnect. At most 100 streams per environment; beyond that the stream gets 429 and SDKs poll the snapshot every 30 seconds instead.

Remote evaluation

POST /sdk/v1/client/{clientId}/evaluate with {"context": {...}} evaluates every client-visible flag on the server:

{"version": 42, "flags": {"new-checkout": {"value": true, "variation": 1, "reason": {"kind": "FALLTHROUGH"}, "flagVersion": 7}}}

Each call is one request; contexts are used to answer and not stored.

OFREP

The OpenFeature Remote Evaluation Protocol:

  • POST /ofrep/v1/evaluate/flags with {"context": {...}}: {"flags": [{"key", "value", "reason", "variant", "metadata": {"flagVersion"}}]} with an ETag; the same If-None-Match gets 304 while neither the snapshot nor the context changed.
  • POST /ofrep/v1/evaluate/flags/{key}: {"key", "value", "reason", "variant", "metadata"}, or 404 {"key", "errorCode": "FLAG_NOT_FOUND"}, or 400 {"key", "errorCode": "TARGETING_KEY_MISSING" | "INVALID_CONTEXT"}.

Authenticate with Authorization: Bearer <server key or client ID> (or X-API-Key). Client IDs see only client-visible flags. Contexts map as in evaluation.md, “OpenFeature contexts”. A flag that serves no value (off without an off variation) has "value": null and reason DISABLED: use the default.

Events

POST /sdk/v1/events (server key) or POST /sdk/v1/client/{clientId}/events:

{"batchId": "a UUID, new per batch, reused when retrying it", "events": [ ... ]}

202 {"accepted": n}. At most 1,000 events and 1 MB per batch; contexts must be redacted first (evaluation.md, “Private attributes”). Times are Unix milliseconds. Unknown kinds are ignored. In this beta only summary events are used (per-flag counts for flag insights); the other kinds are accepted and discarded, no contexts are stored, and retried batches are not deduplicated.

kind Fields Sent
summary startDate, endDate, features: {flagKey: {"default": value, "counters": [{"variation", "version", "count"}, {"unknown": true, "count"}]}} Every flush: counts of evaluations since the last one; feeds flag insights. "variation": null counts evaluations of a known flag that served the default; "unknown": true those of a missing flag.
exposure creationDate, flagKey, variation, version, experimentId, contextKeys: {kind: key} Once per context, flag and variation per flush, when the result has inExperiment.
custom creationDate, key, contextKeys, metricValue? (number), data? track() calls.
identify creationDate, context (redacted) Client-side SDKs, once per context per session; counts toward monthly active users.
feature creationDate, flagKey, variation, version, value, reason, contextKeys Per evaluation, only for flags or rules with trackEvents.

Flush every 5 minutes by default (configurable; each upload counts as at least 100 events, see Usage), when 500 events are queued, on close, and in browsers when the page is hidden (visibilitychange to hidden, or pagehide), sending with keepalive so the request outlives the page. Drop the oldest events beyond 10,000 queued rather than grow without bound.

Usage

Every request to /sdk/v1/ and /ofrep/v1/ except OPTIONS and healthz counts as one request on the organization’s usage, an open stream as one request per started hour, and an event batch as its number of events, at least 100.

Errors and back-off

  • 429 carries Retry-After (seconds). Wait at least that long before the next request of that kind.
  • Other failures: retry with exponential back-off (1 s doubling to 60 s, with jitter). A batch of events is retried once, then dropped.
  • 401: stop polling and streaming, log once, keep serving the last snapshot.

SDK behaviour

  • Start by fetching the snapshot, waiting up to a configurable timeout (default 5 s); then stream changes and fall back to polling every 30 s while the stream is down.
  • Keep the last good snapshot in memory (and optionally on disk) and keep evaluating with it while the service is unreachable. Before any snapshot arrives, every evaluation returns the caller’s default with error FLAG_NOT_FOUND (OpenFeature: PROVIDER_NOT_READY).
  • Never throw from an evaluation: errors return the default.
  • Send User-Agent: flag-<language>/<version>.

This page as Markdown: /docs/sdk-protocol.md · All docs for agents: /llms.txt