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:
kid = first 16 chars of lowercase hex SHA-256(UTF-8 "kid:" + K); find the entry inkeyswith thatkid(none: this key is not a recipient; it was probably just revoked).KEK = HKDF-SHA256(IKM = UTF-8 K, salt = UTF-8 "flag-snapshot-v1", info = UTF-8 envId, length 32).- base64-decode
wrapped(standard alphabet, padded): 12-byte nonce, then ciphertext with the 16-byte GCM tag. AES-256-GCM-decrypt it withKEKand additional data UTF-8"flag-snapshot-v1:dek:" + envId + ":" + version(version in decimal): the 32-byte data key. - Decrypt
ciphertextthe same way with the data key and additional data"flag-snapshot-v1:" + envId + ":" + version: the snapshot JSON ofevaluation.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/flagswith{"context": {...}}:{"flags": [{"key", "value", "reason", "variant", "metadata": {"flagVersion"}}]}with anETag; the sameIf-None-Matchgets304while neither the snapshot nor the context changed.POST /ofrep/v1/evaluate/flags/{key}:{"key", "value", "reason", "variant", "metadata"}, or404{"key", "errorCode": "FLAG_NOT_FOUND"}, or400{"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
429carriesRetry-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