Integration Guide
How to integrate your application with the Featrs API. Each section covers the recommended integration pattern, documents behaviour you should design around, and links to the corresponding section of the API reference for endpoint-level detail.
- 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:
| Status | Meaning |
|---|---|
400 | Invalid input — malformed key syntax, out-of-range percentage, invalid state transition |
401 | Missing, invalid, or expired credentials |
403 | Wrong principal type or no organisation on the token |
404 | Resource not found |
409 | Conflict — e.g. creating a flag whose key already exists |
502 | Upstream provider failure |
500 | Unexpected server error |
Notes
- Treat any non-2xx response as failure and retry only idempotent reads; do not branch on message text, which is intentionally generic and may change.
- Malformed JSON is rejected before the request reaches a handler, and the error
body is plain text rather than the JSON shape above. Check the
Content-Typeof an error response before parsing it as JSON.
Authentication
Authentication — API reference
Machine-to-machine authentication is a two-step process: 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 in seconds. There is no refresh endpoint — when the
token nears expiry, perform the exchange again. Cache the token for its lifetime rather
than exchanging per request; the secret check uses Argon2 and is deliberately
expensive.
Notes
- Keep tokens server-side. A token grants full read/write access to the organisation. There is no browser-safe key type, so evaluation from web or mobile clients should go through your own backend.
- Scopes are not yet enforced. Scopes supplied at key creation are recorded and embedded in the token, but endpoints do not currently check them. Treat every key as granting full organisation access until enforcement ships.
- Revocation is immediate. Tokens are re-validated against the database on every request, so revoking a key invalidates its outstanding tokens instantly rather than at expiry.
- Failed exchanges return
401with distinct messages for invalid credentials, revoked keys, 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. |
Notes
- Store the
secretfrom the create response immediately; only an Argon2 hash is retained, so it cannot be retrieved again. last_used_atis stamped on each successful token exchange (not on every API call), so it reflects 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.
Feature flags
CRUD
| Method | Path | Notes |
|---|---|---|
| POST | /flag |
{key, value, description?, activation_date_time?, expiration_date_time?, percentage?} → 200 with the flag; 409 if the key already exists |
| 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 or trailing dots, and consecutive dots (400);
percentage must be between 0 and 100 (400 outside the
range).
Notes
PUT /flag/{id}andPOST /flag/{id}/datesare full replaces, not patches. Any optional field you omit is set toNULL. To change a single field, read the flag, modify it, and send the complete body back.- Creating
a.b.cauto-creates missing ancestorsaanda.bas enabled flags. If an enabled parent switch is not what you want, create the parents explicitly first with the values you intend. - Renaming a key via
PUTdoes not rename children — hierarchy is string-based, so renamingplatformtocoredetaches everyplatform.*child from its former parent. Segment assignments and experiment links survive renames, as they reference the flag UUID. - Restrict keys to letters, digits,
_and-between dots. Other characters are currently accepted, but a key containing/cannot be evaluated through the path-based endpoint.
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, 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 a
non-empty context was sent, the flag's attached segments are matched;
enabledis true only if at least one matches (or none are attached).
Notes
- Percentage rollouts are not sticky. Each evaluation is an independent random roll with no bucketing by user or context, so a user under a 25% flag may see it change between requests. For a stable per-user experience, evaluate once per session and cache the result, or keep the flag at 0/100 and use segments for targeting.
- An empty or omitted
contextskips segment matching entirely and reportssegment_match: true. Segments only gate users when at least one context attribute is sent — no context means "do not 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; review/flags/statsfor keys you don't recognise. segment_matchis only returned by the single-flag context endpoint. The batch endpoint applies segment gating toenabledbut does not include the per-flagsegment_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 the keys you actually need at boot.
- Every evaluation writes an analytics row. At high evaluation rates, prefer the batch endpoint plus client-side caching over per-request evaluation.
- Circuit breakers participate in evaluation but there is currently no API to trip
or reset them; they are operated by the Featrs team. If everything under one root
suddenly evaluates
false, a tripped breaker is a likely cause.
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 } ]
Notes
- Stats are keyed by the string that was evaluated, not by existing flags: deleted flags and mistyped 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 for predictable results
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. This is useful as an "everyone" variant, so double-check it is intentional. rulesis not validated at create time: values other than strings and arrays of strings are stored but never match. Keep rules to the two supported shapes.- Assignment is attached to the flag UUID and does not travel down
the key hierarchy: a segment on
platformhas no effect when you evaluateplatform.auth. Attach segments to the exact keys you evaluate.
Experiments
Lightweight A/B tests built on flags and segments.
| 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 the experiment (attaching variant segments and observed flags) →
set status: running → log events from your app with event_name,
a stable user_id, and the user's context → read
/metrics for the verdict.
Notes
- Flags attach by
flag_ids(UUIDs), not keys. Unknown fields in the request body are currently ignored, so a misspelled field has no effect and no error. Look up ids viaGET /flagsfirst. - Variant assignment happens at event-write time from the event's
context, matched against the experiment's segments (first match wins, in attachment order). Send the same context attributes you use for flag evaluation so variants line up with your rollout. - Events logged while the experiment is
draft/paused/completedare rejected with400. Gate event emission on experiment state rather than buffering and retrying. user_idis your identifier and is deduplicated per variant (COUNT(DISTINCT user_id)); conversion requires 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 excluded from analytics. - Cross-org references are rejected atomically — attaching another organisation's
segment or 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 |
Notes
- Inviting adds an existing Featrs account to your organisation; there is no email invitation flow for people without accounts yet.
- Plan-based limits are being introduced. Handle
403responses from write endpoints (such asPOST /flagandPOST /user/invite) gracefully — they indicate a plan limit rather than a permissions problem.
Health
GET /health (no auth) → {"status": "Ok", "git_sha": "…"}. Use it
for uptime checks; git_sha identifies the build that is live.
Rate limits & capacity
There is no formal rate limit today. For best performance and reliability: 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 responses.