This guide is the human-readable companion to openapi.json
(OpenAPI 3.1.0). It covers what a machine-readable contract does not express well:
the authentication model, the SSE contract, ETag/conditional-request semantics,
custom response headers, and the error format. For the exact shape of every
request and response, use openapi.json; this document points at it rather
than duplicating it.
The contract and this guide describe the current surface of build_router
(crates/flaps-server/src/lib.rs). A dedicated backlog issue will decide the
versioning scheme of the admin surface (prefix, media type, or header) before
1.0; /ofrep/v1 and /sync/v1 already carry a /v1 prefix because that is
what their respective external protocols (OFREP, and flaps's own sync channel)
require, not because of an admin API versioning decision.
flaps-server exposes three families of routes:
- Public:
POST /login. No authentication. - Admin: everything under
/projects/**, including SDK key management. Requires a session bearer token minted byPOST /login. - SDK (data plane):
GET /sdk/whoami, the OFREP evaluation endpoints, and the/sync/v1/*routes. Requires an SDK key bearer token.
The admin surface is a straightforward CRUD API over four aggregates (Project, Environment, Flag, Segment) plus a fifth join aggregate (FlagEnvConfig: a flag's targeting rules and rollout weights within one environment) and SDK key management. The data plane is read-only and evaluation-only: SDK keys can never mutate anything.
flaps hosts two credential systems in the same server, and they are intentionally not interchangeable:
| Admin session | SDK key | |
|---|---|---|
| Minted by | POST /login (username + password) |
POST /projects/{project}/environments/{env}/keys (an admin action) |
| Carried as | Authorization: Bearer <session-token> |
Authorization: Bearer <sdk-key> |
| Scope | The whole instance (subject to the account) | One (project, environment) pair |
| Grants | Full CRUD on /projects/** |
Read-only evaluation and sync |
| OpenAPI security scheme | adminSession |
sdkKey |
A session token never works on an SDK route and vice versa: each is resolved
against a different store lookup (resolve_session vs find_sdk_key), and a
mismatched token is rejected as 401 Unauthorized rather than silently
downgraded.
An SDK key additionally carries a kind: server or client. Client keys are
meant for SDKs embedded in untrusted runtimes (browsers, mobile apps); server
keys are meant for backend runtimes. openapi.json models both under the
single sdkKey security scheme because OpenAPI has no first-class way to
express "same scheme, but only one sub-kind is accepted here". The actual rule
is:
GET /sdk/whoamiand both OFREP evaluation endpoints accept either kind.GET /sync/v1/rulesetandGET /sync/v1/eventsaccept server keys only. A client-kind key on either sync route gets403 Forbiddenwith aproblem+jsonbody explaining the requirement.
GET /sync/v1/events is a server-sent events (text/event-stream) endpoint,
which OpenAPI 3.1 does not model well (no first-class streaming media type),
so its exact frame contract lives here instead of in the schema.
Each SSE frame is a JSON-encoded EventPayload:
{ "environment": "production", "version": 42 }That is the entire payload: no flag data, no ruleset content, nothing beyond
which environment changed and its new version number. A subscriber that wants
the actual compiled ruleset must call GET /sync/v1/ruleset after receiving
the notification. This is a deliberate simplification of the transport: SSE
carries a cheap "something changed" signal, and the bulkier document travels
over an ordinary cacheable GET with ETag support.
Every event is emitted after the corresponding ruleset has been written to
the in-memory cache (inside install_in_cache). Concretely: the write to the
cache and the broadcast happen in that order, never the reverse. This gives a
subscriber a hard guarantee: if you receive an event announcing version N
and immediately call GET /sync/v1/ruleset, you will observe a ruleset whose
version is >= N. You can never observe a stale ruleset that is older than
the version just announced.
The reverse race is not eliminated, and does not need to be: a client that
calls GET /sync/v1/ruleset before subscribing to /sync/v1/events may miss
the notification for a version it already has (or briefly precede a
notification for a newer one). This is normal for any pull-then-subscribe
sequence and is why clients should also re-sync periodically, independent of
the event stream.
- The connection sends periodic keep-alive comments so intermediaries do not time it out.
- Events are filtered server-side to the
(project, environment)scope bound to the caller's SDK key; a subscriber never sees another environment's notifications. - The event channel is a bounded broadcast buffer. A slow subscriber that
falls behind has its lagged ticks silently skipped rather than the
connection being torn down; skipping is not fatal because the client's
periodic re-sync via
GET /sync/v1/rulesetwill catch up regardless of how many intermediate versions it missed.
GET /sync/v1/events opens a long-lived connection, so it is bounded
separately from the ordinary per-request token bucket, which only limits the
rate of new requests and does not bound resources held for the lifetime of a
stream: a compromised key, a reconnect storm, or a defective client could
otherwise hold an unbounded number of open connections.
Two ceilings apply, checked in this order:
- A global ceiling, shared across every SDK key.
- A per-key ceiling, scoped to the caller's own SDK key prefix.
Both are non-blocking: an over-quota request never queues, it is rejected
immediately with 429 Too Many Requests, the same status, Retry-After
header, and problem+json body shape used by the ordinary rate limiter (see
section 5 and 6.1). This endpoint does not additionally apply the token
bucket, so a 429 from /sync/v1/events always means the concurrency quota,
never the request rate.
Clients should treat a 429 here as a signal to back off (e.g. full-jitter
exponential backoff on the reconnect loop) rather than reconnect immediately;
reconnecting in a tight loop only prolongs the quota being exhausted. A slot
frees as soon as any held connection closes, for any reason (client
disconnect, client-initiated cancellation, or server shutdown).
flaps uses strong ETags computed as the hex SHA-256 of the canonical
(key-sorted) JSON serialization of a resource. Canonical ordering matters
because some resources (Flag.variants, for instance) are backed by a
HashMap internally; without sorting, two serializations of the same logical
value could hash differently.
Three independent mechanisms use this ETag, and they are not applied everywhere: optimistic-concurrency writes (4.1), a create-only guard (4.2), and conditional reads on the data plane (4.3).
PUT and DELETE on Project, Environment, Flag, Segment and FlagEnvConfig all
accept an optional If-Match request header, evaluated per
RFC 7232 §3.1. When
present, the server compares it against the current resource's ETag
atomically with the write (see 4.4) before writing:
- Missing
If-Match: no precondition, the write proceeds unconditionally. - A single ETag that matches the current one: the write proceeds.
- A comma-separated list of ETags (e.g.
If-Match: "a", "b", "c"): the write proceeds if any listed value matches the current ETag. If-Match: *: the write proceeds only if the resource currently exists.- No match (including
*against a resource that does not exist):412 Precondition Failed, nothing is written. This applies even when the resource does not exist at all: anIf-Matchheader (specific value or*) against a missing resource is a412, not a404.
All comparisons use the strong comparison function: every ETag this API
emits or accepts is strong (see the intro to section 4), so a weak-tagged
value (W/"...") is never treated specially and simply never matches. Quoted
("abc123") and unquoted (abc123) forms are both accepted; surrounding
quotes are stripped before comparison.
A client may send If-Match as several repeated field-lines instead of one
comma-separated line; per
RFC 7230 §3.2.2 the
server joins every field-line into a single comma-separated value before
evaluating it, exactly as if the client had sent one line. A header value
(any field-line) that is not valid ASCII fails the request with 422 Unprocessable Entity rather than being silently treated as absent: a
malformed precondition must never fail open.
This is the only conditional mechanism the admin CRUD routes support for
existing resources. Note in particular: the single-resource GET routes
(GET /projects/{project} and its siblings) always return 200 with an
ETag response header; they do not support If-None-Match / 304.
Conditional reads are only implemented where the payload is large and polled
frequently (see 4.3).
PUT on Project, Environment, Flag, Segment and FlagEnvConfig additionally
accepts an optional If-None-Match request header, evaluated per
RFC 7232 §3.2. Only the
* value is supported: the general listed-ETags form of If-None-Match is a
well-defined RFC 7232 precondition for non-GET methods too, but this API
does not implement it here, since the only write-side use case this guard
serves is the "create, never overwrite" idiom:
If-None-Match: *: the write proceeds only if the resource does not currently exist (a "create, never overwrite" guard). If it already exists, the request fails with412 Precondition Failedand nothing is written.- Header absent: no precondition from this guard.
- Any other value (the unsupported listed-ETags form): the request fails with
422 Unprocessable Entity. This unsupported form is rejected rather than silently ignored, since ignoring it would let a write proceed while the client believes it sent an active precondition.
Like If-Match (4.1), repeated If-None-Match field-lines are joined per
RFC 7230 §3.2.2 into one comma-separated value before evaluation, and a
header value that is not valid ASCII fails with 422 Unprocessable Entity
rather than being silently treated as absent.
This is independent of If-Match (4.1): a request may carry either, both, or
neither. Carrying both is unusual but well-defined, since both are evaluated
against the same current-ETag lookup taken atomically with the write.
POST /ofrep/v1/evaluate/flags (bulk evaluation) and GET /sync/v1/ruleset
both accept an optional If-None-Match request header, compared against the
current ruleset's content hash. An exact match short-circuits to
304 Not Modified with no body, which matters because SDK clients typically
poll these endpoints frequently and the compiled document can be large; a 304
avoids re-serializing and re-transferring it.
Every admin PUT/DELETE above serializes against every other admin
PUT/DELETE targeting the same project (an in-process, per-project lock
held for the whole mutation: read the current resource, evaluate 4.1/4.2,
write, recompile affected environments, install into the ruleset cache).
Two consequences follow directly from this:
- Atomic preconditions (4.1, 4.2): the ETag comparison and the write
happen without any other in-scope mutation for the same project able to
run in between, so two concurrent writes racing on the same
If-Matchvalue can never both succeed (a lost update): exactly one observes the ETag it expects and proceeds, the other observes the now-changed ETag and gets412. - A cache that is never stale relative to an acknowledged write: the
ruleset served by
GET /sync/v1/rulesetand the OFREP evaluation routes is always recompiled from the store state committed by the last acknowledged write, never from a snapshot a differently-timed concurrent write could make stale. Two concurrent writes to different resources within the same environment (for example, two different flags' configs) both end up reflected in the ruleset once both requests complete.
This is an in-process guarantee: it holds across concurrent requests handled
by one flapsd instance, which is the only supported topology today (each
instance owns its own cache and event stream). It does not coordinate writes
issued by two separate flapsd processes sharing one database; that is a
tracked follow-up (database-level compare-and-swap) for a future
multi-instance deployment.
| Header | Where | Meaning |
|---|---|---|
ETag |
Admin single-resource GET/PUT 200/201; OFREP bulk 200; sync ruleset 200 | Strong ETag of the returned resource, see section 4. |
X-Flaps-Version |
Sync ruleset 200 | Monotone version counter of the compiled ruleset, matches the version field a subsequent SSE EventPayload would announce. |
X-Flaps-Warning |
Project/Environment PUT 200/201, only when managed_by is federated |
Warns that the edit may be overwritten by the next federation sync; Flag, Segment and FlagEnvConfig carry no managed_by field and never set this header. |
Retry-After |
Any 429 response |
Seconds to wait before retrying: computed by the token-bucket rate limiter, or a fixed documented value for the /sync/v1/events concurrency quota (see 3.4). |
Two distinct error body shapes exist, depending on which world produced them.
Every admin route and both /sync/v1/* routes report errors as
application/problem+json:
{
"type": "https://flaps.dev/problems/not-found",
"title": "Resource not found",
"status": 404,
"detail": "The addressed resource does not exist."
}All four fields are always present. type is a stable URI suffix identifying
the error category (unauthorized, forbidden, invalid-body,
validation-error, not-found, conflict, precondition-failed,
too-many-requests, internal-error); see openapi.json's Problem schema
and each operation's declared response codes for which categories a given
route can produce.
Two categories are worth calling out because they are easy to conflate:
422 invalid-body: the request failed structural or key-format validation (a path key is not valid kebab-case, or a path key does not match the body's key), or a precondition header (If-Match/If-None-Match, see 4.1 and 4.2) was malformed: not valid ASCII, or anIf-None-Matchvalue other than*. This never touches the database.400 validation-error: the request is well-formed, but applying it would produce a ruleset that fails to compile (for example, a targeting rule referencing a segment key that does not exist). flaps validates every mutation by compiling it before writing, so a400here means the write was refused, not that a partially-applied change is sitting in the store.
The two OFREP evaluation endpoints intentionally do not use
problem+json: they follow the OFREP 0.3.0 protocol
error format instead, so that OFREP-compliant client SDKs (which expect this
shape) work against flaps unmodified. Single-flag evaluation errors carry the
evaluated key alongside errorCode / errorDetails; bulk evaluation
failures (which are not about one specific flag) omit key. See
SingleErrorResponse and EvaluationFailureResponse in openapi.json.
One consequence worth documenting explicitly: on the two OFREP endpoints, an
authentication failure is always reported as 401 regardless of its
underlying cause (a genuinely missing/invalid key, or an internal store
error while resolving it). This differs from every other authenticated route,
where an internal error while resolving credentials is reported as 500
rather than folded into 401. This is deliberate for OFREP: a third-party
OFREP client should never need to distinguish those cases.
Flag and Environment both carry an optional metadata object: arbitrary
keys mapping to a bare boolean, string or number (see the Metadata schema in
openapi.json). The field is optional on every admin request and response;
omitting it is equivalent to an empty map, and an empty map is never
serialized back (the field is absent, not {}).
At evaluation time, the two levels are merged into a single metadata object
on the OFREP response: flag-set (environment) metadata is the base, and flag
metadata is applied on top, so a key present at both levels resolves to the
flag's value. This mirrors flagd's own flag-set/flag metadata model. The
merged metadata field on SingleSuccessResponse (single and bulk
evaluation, since BulkFlagEntry::Success wraps SingleSuccessResponse) is
omitted entirely when the merge is empty, never emitted as {}.
The admin surface (/projects/**, /sdk/whoami, SDK key management) carries
no version prefix today. /ofrep/v1 and /sync/v1 carry /v1 because that is
what OFREP and flaps's own sync channel expect, not because of an admin API
versioning decision. A dedicated backlog issue will settle how the admin
surface itself gets versioned (path prefix, a media-type parameter, or a
dedicated header) ahead of 1.0. Until that is decided, treat the current shape
of every admin endpoint as subject to change without a version bump.