Featrs Integration Guide
How to integrate your application with the Featrs API, feature by feature. Every section ends with Integrator notes — behaviour observed in the implementation that you need to know about, including sharp edges the reference docs don't call out.
- Base URL:
https://api.featrs.com(local dev:http://localhost:8080) - Format: JSON in, JSON out (
Content-Type: application/json) - Auth: Bearer JWT on every endpoint except
/healthand the auth endpoints themselves
Errors
All errors share one shape:
{ "traceId": "0198f2b1-…", "message": "Failed to create flag" }
traceId is unique per request — include it when reporting a problem so the
team can find the exact server-side log line. Messages are intentionally generic; the
HTTP status carries the semantics (401 bad/expired credentials,
403 wrong principal type or missing org, 404 not found,
400 invalid input, 502 upstream provider failure,
500 everything else).
Integrator notes
- Validation failures you might expect to be
409/422often surface as500with a generic message — e.g. creating a flag whose key already exists trips the unique constraint and returns500 Failed to create flag. Don't branch on message text; treat non-2xx as failure and retry only idempotent reads. - Malformed JSON is rejected by the framework before the handler runs and returns a
non-JSON error body — always check
Content-Typeof error responses before parsing.
Authentication
Machine-to-machine auth is a two-step: create an API key once in the dashboard, then exchange it for a short-lived JWT at runtime.
# Exchange key for a token (1 hour TTL)
curl -X POST https://api.featrs.com/auth/token \
-H "Content-Type: application/json" \
-d '{"api_key_id": "<uuid>", "secret": "<secret>"}'
# → { "access_token": "eyJ…", "exp": 1711929600 }
# Use it
curl https://api.featrs.com/flags -H "Authorization: Bearer eyJ…"
exp is a Unix timestamp (seconds). There is no refresh endpoint — when the
token nears expiry, do the exchange again. Cache the token for its lifetime; don't
exchange per request (the secret check is Argon2 and deliberately expensive).
Integrator notes
- Keep tokens server-side. A token grants full read/write access to the organisation. There is no browser-safe/client-side key type today, so evaluation from web or mobile clients must go through your own backend.
- Scopes are not enforced yet. You pass
scopeswhen creating a key and they're embedded in the JWT, but no endpoint currently checks them — every valid token can call every org endpoint. Don't design around read-only keys until enforcement lands. - Revocation is immediate. API-key tokens are re-validated against the database on every request, so revoking a key kills its outstanding tokens instantly (not just at the 1-hour expiry).
- Failed exchanges return
401with distinct messages for invalid credentials, revoked, and expired keys.
API keys
| Method | Path | Notes |
|---|---|---|
| POST | /api-key |
Body {name, scopes: [string], expiration?: epoch-seconds}.
Response includes secret — shown exactly once. |
| GET | /api-keys |
List (id, name, created_at, expiration, last_used_at). Never returns secrets. |
| POST | /api-key/{id}/revoke |
Immediate, permanent. |
Integrator notes
- Store the
secretfrom the create response immediately; it cannot be retrieved again (only an Argon2 hash is stored). last_used_atis stamped on each successful token exchange (not on every API call), so it tells you when a key last minted a token —nullmeans the key has never been used.- These endpoints require a token like any other, so bootstrap the first key through the dashboard UI.
Feature flags
CRUD
| Method | Path | Notes |
|---|---|---|
| POST | /flag |
{key, value, description?, activation_date_time?, expiration_date_time?, percentage?} → 200 with the flag |
| GET | /flags |
All flags for your org |
| POST | /flag/{flag_id} |
Toggle: {value: bool} |
| PUT | /flag/{flag_id} |
Full replace of every field |
| POST | /flag/{flag_id}/dates |
Set schedule window |
| DEL | /flag/{flag_id} |
→ 204 |
Keys are dot-separated hierarchies (platform.auth.signup). Validation rejects
empty keys, leading/trailing dots, and ..; anything else — spaces, slashes,
unicode — is accepted.
Integrator notes
PUT /flag/{id}andPOST /flag/{id}/datesare full replaces, not patches. Any optional field you omit is set toNULL. To clear an expiration date, send the body without it; to keep it, you must send it back. Read-modify-write if you only want to change one field.- Creating
a.b.cauto-creates missing ancestorsaanda.bas enabled flags. If you don't want a live parent switch you didn't ask for, create parents explicitly first with the value you intend. - Renaming a key via
PUTdoes not rename children — hierarchy is purely string-based, soplatform→coreorphans everyplatform.*child from its old parent. Segment assignments and experiment links survive renames (they reference the flag UUID). - Avoid
/in keys: it makes the flag impossible to evaluate via the path-based GET endpoint (see below). percentageis not range-checked at the API layer; stick to 0–100 yourself.
Evaluation
Three endpoints, all authenticated:
# 1. Simple boolean by key
curl https://api.featrs.com/flag/evaluate/checkout.new-flow \
-H "Authorization: Bearer $TOKEN"
# → { "key": "checkout.new-flow", "enabled": true }
# 2. With user context (segment targeting)
curl -X POST https://api.featrs.com/flag/evaluate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"key": "checkout.new-flow", "context": {"country": "ZA", "plan": "enterprise"}}'
# → { "key": "checkout.new-flow", "enabled": true, "segment_match": true }
# 3. Batch (e.g. app boot)
curl -X POST https://api.featrs.com/flags/evaluate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"keys": ["checkout.new-flow", "platform.dark-mode"], "context": {"country": "ZA"}}'
# → [ { "key": "…", "enabled": true }, … ]
Evaluation order for a key:
- Circuit breaker — if the root of the hierarchy
(
checkoutforcheckout.new-flow) is tripped in thecircuit_breakerstable, the answer isfalseimmediately. - Exact flag lookup — if the key exists: check its schedule window, then its value and percentage. Parents are not consulted.
- Hierarchy fallback — only if the key does not exist, walk up
(
a.b.c→a.b→a) and use the first ancestor found. A missing root resolves tofalse. - Segments (context endpoints only) — if the flag is on and you
sent a non-empty context, the flag's attached segments are matched;
enabledis true only if at least one matches (or none are attached).
Integrator notes
- Percentage rollouts are not sticky. Each evaluation is an independent random roll — there is no bucketing by user or context. A user under a 25% flag will see it flip on and off across requests. If you need a stable experience per user, evaluate once per session and cache the result yourself (and re-evaluate on your own cadence), or keep the flag at 0/100 and use segments for targeting instead.
- An empty or omitted
contextskips segment matching entirely and reportssegment_match: true. Segments only gate users when you send at least one context attribute — "no context" is treated as "don't target", not "not in any segment". Always send context if you rely on segment gating. - Evaluating a nonexistent key is not an error — it walks the
hierarchy and ultimately returns
enabled: false. Typos fail silently; watch/flags/statsfor keys you don't recognise. segment_matchis only meaningful from the single-flag context endpoint. The batch endpoint applies segment gating toenabledbut doesn't return the flag-levelsegment_matchbreakdown.- Batch evaluation runs keys sequentially server-side and fails the whole request if any single key errors; results come back in request order. Keep batches to what you actually need at boot.
- Every evaluation writes an analytics row (asynchronously). At very high evaluation rates prefer the batch endpoint plus client-side caching over hammering per-request evaluation — evaluations are billed against the same capacity as the rest of the API.
- Circuit breakers exist in evaluation but there is no API to trip or reset them — they're operated directly in the database today. Treat "everything under a root suddenly false" as a possible deliberate trip.
Scheduling (time windows)
activation_date_time/expiration_date_time (RFC 3339, UTC) bound
when a flag can be true. Outside the window the flag evaluates false; the
stored value is untouched. Boundaries are exclusive: a flag whose activation
time is exactly now is still inactive, and it expires at the instant of
expiration_date_time.
Statistics
GET /flags/stats → per-key totals from the evaluation log:
[ { "flag_key": "checkout.new-flow", "total": 1523, "enabled_count": 1200,
"last_24h": 245, "last_7d": 1102 } ]
Integrator notes
- Stats are keyed by the string that was evaluated, not by existing flags: deleted flags and typo'd keys appear here too. Sorted by total, descending.
Segments
Segments are named rule sets matched against the context you send at
evaluation time.
| Method | Path | Notes |
|---|---|---|
| POST | /segment |
{name, description?, rules} → 201 |
| GET | /segments |
List |
| PUT | /segment/{id} |
Full replace |
| DEL | /segment/{id} |
→ 204, removed from all flags |
| POST | /flag/{flag_id}/segments |
{segment_ids: [uuid]} — replaces the
assignment set; [] clears |
| GET | /flag/{flag_id}/segments |
Current assignments |
Rule semantics — all rules in a segment must match (AND); a flag with multiple segments is enabled if any segment matches (OR):
{ "country": "ZA", "plan": ["enterprise", "scale-up"] }
- string value → exact, case-sensitive equality
- array value → context value must equal one of the entries
- context values are always strings; numbers in rules are compared after stringification — send strings on both sides to avoid surprises
Integrator notes
- Matching is case-sensitive (
"ZA" ≠ "za"). Normalise casing in one place before sending context. - A segment with empty rules
{}matches every non-empty context — useful as an "everyone" variant, dangerous as an accident. rulesaccepts any JSON object; nothing validates that values are strings/arrays at create time. A malformed rule silently never matches.- Assignment is attached to the flag UUID, so it does not travel
down the key hierarchy: a segment on
platformhas no effect when you evaluateplatform.auth(which resolves via its own flag or hierarchy fallback). Attach segments to the exact keys you evaluate.
Experiments
Lightweight A/B tests built on flags + segments. Endpoint-level detail is in the API reference.
| Method | Path | Notes |
|---|---|---|
| POST | /experiment |
{name, description?, hypothesis?, prediction?, flag_ids: [uuid], segment_ids: [uuid], funnel_steps: [string], goal_event?} → 201 |
| GET | /experiments |
List |
| GET | /experiment/{id} |
Detail incl. attached flags + segments |
| POST | /experiment/{id}/status |
{"status": "running"|"paused"|"completed"} — transitions validated |
| POST | /experiment/{id}/event |
Log one event (only while running) → 201 |
| GET | /experiment/{id}/metrics |
Per-variant conversion + Z-test winner verdict |
| GET | /experiment/{id}/funnel |
Per-step drop-off |
| DEL | /experiment/{id} |
Cascades events/links → 204 |
Integration loop: create (attaching variant segments and observed flags) →
status: running → log events from your app with event_name, a
stable user_id, and the user's context → read
/metrics for the verdict.
Integrator notes
- Flags attach by
flag_ids(UUIDs), not keys — the examples in older docs showingflag_keysare wrong. Unknown fields in the body are silently ignored, so aflag_keyspayload "succeeds" with no flags attached. Look up ids viaGET /flagsfirst. - Variant assignment happens at event-write time from the event's
context, against the experiment's segments (first match wins, in attachment order). Send the same context attributes you use for flag evaluation, or your variants and your rollout won't line up. - Events logged while the experiment is
draft/paused/completedare rejected with400— buffer-and-retry on your side loses data unless the experiment is running again, so gate event emission on experiment state. user_idis your identifier and is deduplicated per variant (COUNT(DISTINCT user_id)); conversion needs the same id on funnel and goal events.- Event names must exactly match
goal_event/funnel_stepsstrings to be counted; unknown names are stored but ignored by analytics. - Cross-org references are rejected atomically — attaching another org's
segment/flag id rolls the whole create back with a
400.
Organisation & users
| Method | Path | Notes |
|---|---|---|
| GET | /me |
Caller's user id, email, active org. User tokens only —
API-key tokens get 403. |
| GET | /users |
Members of the org |
| POST | /user/invite |
{email} — the user must already have a Featrs account;
404 otherwise |
| DEL | /user/{user_id} |
Remove from org |
Integrator notes
- "Invite" is really "add an existing account to my org" — there is no email invitation flow for people without accounts yet.
- Plan limits are coming: on the Free tier, flag creation caps at 50 flags and
invites are blocked (
403with an upgrade message); the Basic plan lifts both. Handle403fromPOST /flaggracefully.
Health
GET /health (no auth) → {"status": "Ok", "git_sha": "…"}. Use it
for uptime checks; git_sha tells you which build is live.
Rate limits & capacity
There is no formal rate limit today, but the API is modestly provisioned. Be a good
citizen: cache tokens for their full hour, batch flag evaluation at startup, cache
evaluation results client-side for your session length, and back off on
5xx.