# 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.

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

The body is an envelope:

```json
{
  "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:

```json
{"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](https://openfeature.dev/specification/appendix-c):

- `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`:

```json
{"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>`.