Skip to content

Creating Feature Flags

This guide creates a flag, adds targeting rules, turns it on for 10% of users and evaluates it: first in the dashboard, then with the REST API. The API commands run as written against the stack from the Quick Start.


Prerequisites

  • A running stack. Quick Start Step 1 starts one on localhost. On your own deployment, use its URL wherever this page says localhost:8000 or localhost:3000.
  • A user account with the ADMIN or DEVELOPER role. Creating, changing and turning a flag on all take a user login. An API key can't do them (the API answers 401). A superuser creates accounts under Admin → Users. The examples below use the demo administrator, admin@demo.com.
  • An API key, to evaluate the flag the way your application will. The API section creates one; in the dashboard, a superuser can also create one under Admin → API Keys.

Creating a Flag in the Dashboard

Step 1: Open the form

Open the dashboard at http://localhost:3000, click Feature Flags in the top navigation, then click + New Flag.

Step 2: Name the flag

Field Description Example
Name (required) The label shown in the dashboard. New Checkout Flow
Key (required) The identifier your code passes to the SDK. It's filled in from the name (new_checkout_flow), and you can edit it. Use lowercase letters, digits, - and _. The dashboard can't change it after the flag is created. new-checkout-flow
Description What the flag controls, and when it should be removed. Redesigned single-page checkout experience

Step 3: Add targeting rules (optional)

Targeting rules turn the flag on for specific users, whatever the rollout percentage is. Under Targeting Rules, click + Add Group: the group starts with one condition. Click + Add Condition for each condition after the first. A condition has three parts: - an attribute: pick a suggestion such as user.country, or type any attribute your application sends; - an operator; - a value.

Attribute Operator Value
user.country is one of US, CA
user.plan equals enterprise

Each group's AND/OR decides how its conditions combine, and the AND/OR above the groups decides how the groups combine.

If a user matches a rule, the flag is on for them. If they match no rule, the rollout percentage in Step 4 decides. With no groups, the builder reads No targeting rules — all users will match, and the rollout percentage decides for everyone.

Step 4: Set the rollout percentage

The Rollout percentage slider sets the share of users who get the flag when they match no targeting rule:

Percentage Effect
0% Only users who match a targeting rule get the flag
5% Matching users, plus 5% of everyone else
100% Everyone

Each user always lands on the same side of the percentage. Raising it adds users, and takes none away.

Step 5: Create the flag, then turn it on

Click Create Feature Flag. The flag starts off (its page reads Not serving), so creating it never exposes anyone. When you're ready, turn it on with the switch beside Not serving, and the page then reads Serving. The same switch turns it off again.

The dashboard's On/Off is the API's is_active (true/false), reported beside it as status ("active"/"inactive"). You set it with is_active.


Creating a Flag with the API

Run these in one terminal, in order. Each step uses the shell variables set by the ones before it ($TOKEN, $FLAG_ID, $KEY).

URLs. The collection URL answers with or without a trailing slash (/api/v1/feature-flags/ or /api/v1/feature-flags). A single flag's URL has no trailing slash (/api/v1/feature-flags/$FLAG_ID); with one, the API answers 307 Temporary Redirect. curl doesn't follow the redirect, so nothing happens and nothing is printed.

Log in

TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"admin@demo.com","password":"Demo1234!"}' | jq -r .access_token)

curl -s localhost:8000/api/v1/auth/me -H "Authorization: Bearer $TOKEN" | jq .role

The second command prints "ADMIN". If it prints null, the login failed (it takes the account's email, not a username) and $TOKEN holds no token.

Create the flag

FLAG=$(curl -s -X POST localhost:8000/api/v1/feature-flags/ \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "key": "new-checkout-flow",
    "name": "New Checkout Flow",
    "description": "Redesigned single-page checkout experience",
    "rollout_percentage": 0
  }')
FLAG_ID=$(jq -r .id <<<"$FLAG")

jq '{key, status, is_active, rollout_percentage, default_value}' <<<"$FLAG"
{
  "key": "new-checkout-flow",
  "status": "inactive",
  "is_active": false,
  "rollout_percentage": 0,
  "default_value": false
}

A new flag is off unless the request sends "is_active": true, so creating one never exposes anyone. The response carries the flag's id, which this saves in $FLAG_ID for the next steps.

The request takes these fields: - key and name (both required; name is at most 100 characters); - description; - is_active (default false); - rollout_percentage (0–100, default 0); - targeting_rules; - default_value: what the flag serves when it is off. Only false is accepted for now, and it is the default; true answers 422; - tags.

Any other field answers 422 ("type": "extra_forbidden"), so a misspelled or unsupported field is never dropped silently. null is refused for key, name, is_active, rollout_percentage and default_value. A key that already exists answers 409, and a key with capitals or spaces answers 422.

The fields a response carries but no request writes (id, owner_id, created_at, updated_at and status) are accepted and ignored, so a GET body can be sent back unchanged unless its targeting_rules are ones PUT now refuses (422); omitting the field still works (see Add targeting rules). A status must match: "inactive" (in any case) on a create without is_active: true, and the flag's current status on an update. Any other value answers 422 with "type": "read_only"; turn a flag on or off with is_active.

Add targeting rules

targeting_rules uses the same shape the dashboard rule builder writes: a top-level logical_operator combining one or more groups, each group combining its conditions. This example targets enterprise-plan users in the US, CA or GB, or any employee:

curl -s -o /dev/null -w '%{http_code}\n' -X PUT localhost:8000/api/v1/feature-flags/$FLAG_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "targeting_rules": {
      "logical_operator": "OR",
      "groups": [
        {
          "logical_operator": "AND",
          "conditions": [
            {"attribute": "user.plan", "operator": "equals", "value": "enterprise"},
            {"attribute": "user.country", "operator": "in", "value": ["US", "CA", "GB"]}
          ]
        },
        {
          "logical_operator": "AND",
          "conditions": [
            {"attribute": "employee", "operator": "equals", "value": "true"}
          ]
        }
      ]
    }
  }'

It prints 200.

Rules the flag evaluator would not apply as written answer 422, on a create and on an update, and nothing is saved. That is:

  • a list of rules (the legacy [{"type": ...}] shape), and a value that is not an object (a string, a number, true);
  • a flat object such as {"country": ["US"]}, and any other key the shape does not have, at the top level, on a group or on a condition;
  • groups together with rules, logical_operator without groups, and a top-level name;
  • native rules without rules (a default_rule on its own), and native rules with a key the shape does not have (a misspelt rollout_percentage, priority or conditions) on a rule, a group at any depth, a condition or the default_rule;
  • native groups nested more than 10 levels deep, and native rules with more than 1,000 rules, groups and conditions in all;
  • dashboard rules with more than 1,000 groups and conditions in all, each group and each condition counting as one;
  • a group with no conditions, a condition with no attribute, and an attribute with anything other than letters, digits, _ and .;
  • an unknown operator or logical operator (and, or or not, in any case);
  • a value the operator cannot use ("abc" for greater_than, "not-a-version" for semver_gte), a regex pattern RE2 refuses, and a list operator with more than 1,000 values;
  • a rollout_percentage, top-level or on a native rule, that is not an integer from 0 to 100 (100.0 is accepted; 33.5, true and "50" are not), or an id that is not text of 1 to 100 characters.

The message names the place and the reason, never the value you sent. Only the first problem found is reported. This request misspells equals:

curl -s -X PUT localhost:8000/api/v1/feature-flags/$FLAG_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "targeting_rules": {
      "logical_operator": "AND",
      "groups": [
        {"conditions": [{"attribute": "user.plan", "operator": "equal", "value": "enterprise"}]}
      ]
    }
  }' | jq -c '{loc: .detail[0].loc, msg: .detail[0].msg}'
{"loc":["body","targeting_rules"],"msg":"Value error, groups[0].conditions[0].operator: unknown operator"}

groups[0].conditions[0] is the first condition of the first group. A problem with the rules as a whole has no place, and reads targeting rules: <reason>, for example targeting rules: unknown key.

Rules stored before this check are not re-checked. They are evaluated as before, and an update that leaves targeting_rules out succeeds whatever is stored. An update that sends stored rules back unchanged, as a GET returned them, answers 422 if they are rules the API now refuses: fix them, or leave the field out. To list the flags whose stored rules are refused, run this from the repository root, against the same database settings as the API:

python -m backend.scripts.check_targeting_rules

It prints one line per flag, feature_flag <id> <key> <place> <reason>, writes nothing, and exits 0 whether or not it lists anything (2 when it cannot read the database). A flag whose legacy (list-shaped) rules hold a condition with an operator other than eq, ne, gt, lt, contains or in has its own line: that rule matches no user.

Operators: equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, in, not_in, regex, is_null, is_not_null, semver_eq, semver_gt, semver_lt, semver_gte, semver_lte, geo_within_radius, time_window, array_contains, array_intersects, in_segment, not_in_segment. Values typed in the dashboard are strings; they are compared leniently against typed context values ("true" matches true, "17" matches 17, "beta, staff" is a list for in/not_in, "17.4" is padded to 17.4.0 for semver_*).

regex patterns use RE2 syntax: \w, \d, \s are ASCII-only, $ matches only at the very end of the value, lookaround and backreferences are refused, and values longer than 256 characters are not evaluated. A pattern RE2 refuses answers 422 when it is saved (groups[0].conditions[0]: pattern is not valid). A flag whose rules were stored with such a pattern before this check, or whose context value cannot be evaluated, evaluates disabled with reason error rather than falling through to the rollout; python -m backend.scripts.check_targeting_rules lists those flags. See the rules engine reference.

in_segment, not_in_segment. {"attribute": "segment", "operator": "in_segment", "value": "<segment id>"}. One segment per condition; to match any of several, put one condition per segment in an OR group. A ruleset can use at most 10 different segments. A user is a member of an ID-list segment when the user the flag or experiment is evaluated for (the request's user_id) is in its list, and of a rules segment when the attributes sent with the request match its rules; in a segment's rules, user_id is that same user, whatever the context says. Membership is decided by the server from its own records: nothing in the context you send makes a user a member. A condition on the attribute segment with any other operator, such as equals, compares the context value as before. Saving rules that name a segment that is unknown, inactive or archived, or whose rules are not valid, answers 422, and a segment condition in a native default_rule (returned without its conditions being evaluated) is refused. When the server cannot decide a user's membership of a segment the rules name (a flag unarchived after its segment was archived, a segment rule whose pattern cannot be evaluated for this context), it does not guess: the flag answers enabled: false with reason: "error" and the experiment does not enrol the user (reason: "targeting"), whichever of the two operators the condition uses. Flags that use a segment are always evaluated by the server, never by an SDK's local evaluation. A segment condition requires nothing from the context. A user who lacks an attribute is not refused outright: on experiments, as on flags, they fail only the conditions on that attribute (is_null passes), so another OR branch can still enrol them, and they match a NOT group on that attribute (a user with no country matches NOT (country equals US), but not country not_equals US). Users already assigned to an experiment keep their assignment.

See Segments.

Users who match a rule are bucketed with the rule's rollout_percentage (100 unless set on the rules object); users who match no rule fall through to the flag's global rollout_percentage. The native Enhanced Rules Engine shape ({"rules": [...]}) is accepted as well; it must carry rules. null, {} and {"groups": []} mean no rules. A list of rules (the legacy shape) is refused when saved; a flag stored with one before is still evaluated.

Targeting context and attribute aliases

Rules are matched against the context the SDK sends with each evaluation (the user's attributes: context=<url-encoded JSON> on GET /feature-flags/evaluate/{key}, or the context object on the POST variant). Before matching, the context is expanded so that:

  • every key is available as given ({"country": "US"} answers country);
  • nested objects are flattened to dotted keys ({"app": {"version": "3.2.1"}} answers app.version);
  • every top-level key also answers user.<key>, device.<key> and app.<key> ({"country": "US"} answers user.country; {"os_version": "17.4.0"} answers device.os_version), and user.<key> / device.<key> / app.<key> keys in the context answer the bare <key> too;
  • explicit keys always win over aliases.

So a dashboard rule on user.country matches an SDK that sends {"country": "US"} without any renaming on either side. A condition whose attribute is absent from the context does not match (except is_null, which does). A context larger or more deeply nested than the Targeting Context Limits allow answers 422.

Turn the flag on at 10%

curl -s -X PUT localhost:8000/api/v1/feature-flags/$FLAG_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"rollout_percentage": 10, "is_active": true}' | jq '{status, rollout_percentage}'
{
  "status": "active",
  "rollout_percentage": 10
}

"is_active": false turns it off again. POST /api/v1/feature-flags/$FLAG_ID/activate and .../deactivate do the same without a body. A PUT changes only the fields it sends; a status in it must be the flag's current status.

Archived flags

An archived flag is retired: it is never served, and nothing turns it back on by accident. Every request that would turn it on (PUT with "is_active": true, /activate, /enable, /toggle, and bulk enable) answers 400 with

This flag is archived. Unarchive it before turning it on.

and leaves the flag archived. A request that turns it off ("is_active": false, /deactivate, /disable, bulk disable) succeeds and changes nothing, because an archived flag is already off. The way back is POST /api/v1/feature-flags/$FLAG_ID/unarchive (beta), which makes the flag inactive; turn it on afterwards as usual. A flag is archived with POST /api/v1/feature-flags/bulk-toggle and "action": "archive". The dashboard has no archive or unarchive action yet.

Create an API key

Applications evaluate flags with an API key, not a user token. The key is shown only once, and this saves it in $KEY:

KEY=$(curl -s -X POST localhost:8000/api/v1/api-keys \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"flag-guide"}' | jq -r .key)

Flag Key Naming Conventions

A key is lowercase letters, digits, hyphens and underscores, starting with a letter or digit. Anything else is refused with 422.


When a Flag Evaluates to Off

A flag is on or off for each user. Its default_value, what it serves when it is off, is false for every flag for now. Evaluation returns enabled: false when:

  • the flag is off (reason: "inactive"), which includes a flag that a safety rollback to 0% turned off (see Safety Monitoring);
  • the user matches no targeting rule and falls outside the rollout percentage (reason: "rollout");
  • the user matches a rule but falls outside that rule's own rollout percentage (reason: "targeting_rule");
  • evaluation fails on the server (reason: "error").

When the call itself fails (a network error or a wrong API key), the SDKs' isFeatureEnabled and is_feature_enabled return false. So a flag your code can't reach behaves as off.


Evaluating the Flag from Your Application

JavaScript SDK

import { ExperimentationClient } from '@getexperimently/js-sdk';

const client = new ExperimentationClient({
  apiUrl: 'http://localhost:8000',   // your API origin; the SDK appends /api/v1/...
  apiKey: process.env.EXPERIMENTLY_API_KEY,
});

const isEnabled = await client.isFeatureEnabled('new-checkout-flow', {
  userId: 'user-123',
  attributes: { plan: 'enterprise', country: 'US' },
});

if (isEnabled) {
  showNewCheckoutFlow();
} else {
  showCurrentCheckoutFlow();
}

Python SDK

import os

from experimentation import ExperimentationClient

client = ExperimentationClient(
    api_url="http://localhost:8000",   # your API origin
    api_key=os.environ["EXPERIMENTLY_API_KEY"],
)

is_enabled = client.is_feature_enabled(
    flag_key="new-checkout-flow",
    user_id="user-123",
    user_attributes={"plan": "enterprise", "country": "US"},
)

REST API (Direct)

Send the targeting context as a URL-encoded JSON object on the GET endpoint (this is what the SDKs do):

curl -s -G localhost:8000/api/v1/feature-flags/evaluate/new-checkout-flow \
  -H "X-API-Key: $KEY" \
  --data-urlencode "user_id=user-123" \
  --data-urlencode 'context={"plan":"enterprise","country":"US"}' | jq
{
  "key": "new-checkout-flow",
  "enabled": true,
  "config": null,
  "reason": "targeting_rule"
}

Or send it as the context object in the body of the POST variant:

curl -s -X POST localhost:8000/api/v1/feature-flags/evaluate/new-checkout-flow \
  -H "X-API-Key: $KEY" \
  -H 'content-type: application/json' \
  -d '{"user_id": "user-123", "context": {"plan": "enterprise", "country": "US"}}' | jq .reason

It prints "targeting_rule".

The same user on a free plan in Germany matches no rule. The 10% rollout then decides, and user-123 falls outside it:

curl -s -G localhost:8000/api/v1/feature-flags/evaluate/new-checkout-flow \
  -H "X-API-Key: $KEY" \
  --data-urlencode "user_id=user-123" \
  --data-urlencode 'context={"plan":"free","country":"DE"}' | jq '{enabled, reason}'

reason explains the outcome: targeting_rule (a rule matched and its rollout percentage decided), rollout (no rule matched; the global rollout percentage decided), inactive or error. A context that is not a JSON object returns 422. GET /api/v1/feature-flags/user/{user_id}?context=... evaluates every active flag the same way and returns {"flag-key": true|false, ...}.

Reporting client-side errors

Safety monitoring computes a flag's error rate from error_logs rows divided by its evaluations. The errors your applications see behind a flag (crashes, failed requests) count toward it when they are reported with a server-side key: an API key with the sdk:ruleset scope, created by a user who can change feature flags (ADMIN, DEVELOPER or a superuser). A browser or mobile app sends its errors to your server, and the server forwards them with that key. Keep the key on the server: it can also download every flag's targeting rules.

SERVER_KEY=$(curl -s -X POST localhost:8000/api/v1/api-keys \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"flag-guide-server","scopes":["sdk:ruleset"]}' | jq -r .key)

curl -s -X POST localhost:8000/api/v1/tracking/errors \
  -H "X-API-Key: $SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "feature_flag_key": "new-checkout-flow",
    "user_id": "user-123",
    "error_type": "crash",
    "message": "NullPointerException in CheckoutV2",
    "metadata": {"os": "Android", "os_version": "12.0.0"}
  }' | jq .error_type

It prints "crash", the stored row's error_type.

A report sent with $KEY, or with any other key without the sdk:ruleset scope, is accepted (201) and stored the same way, but does not count toward the error rate or automatic rollback: the flag's safety check shows it in details.uncounted_error_count (see Which errors count).

POST /api/v1/tracking/errors/batch accepts {"errors": [...]} (up to 100) and returns {"success_count", "failure_count", "errors"}. At least one of feature_flag_key / experiment_key is required; unknown keys return 404 (single) or are listed per item (batch).

What is stored for each report, on both endpoints:

Field Stored as
timestamp The time the server received the report, for the stored row and for the timestamp in the POST /api/v1/tracking/errors response. A timestamp you send is kept in the row's metadata as client_timestamp (ISO 8601).
message The first 1000 characters.
stack_trace At most 8 KB (8192 bytes, UTF-8); anything longer is cut off at a character boundary.
metadata Stored as sent when its compact JSON is at most 8 KB (8192 bytes, UTF-8). Larger metadata is replaced by {"metadata_truncated": true, "metadata_bytes": <size>}. client_timestamp is added after this check.

Next Steps

  • Gradual Rollouts — set up a staged rollout schedule with automatic progression
  • Safety Monitoring — automatic rollback if error rates spike: off by default, and a superuser turns it on in Admin → Safety
  • SDK Documentation — full SDK integration guide