Flags docs
Core concepts

How Flags works.

The model, the evaluation rules and the guarantees, in the order you meet them.


1

Projects, configs and environments

the hierarchy
Workspace
└── Project  (salt, segments, change history, protected environments)
    ├── Environment  development · staging · production   (up to 5)
    └── Config  "Main"                                      (up to 10)
        └── Setting  new_checkout: boolean                  (up to 200)
            └── one value + targeting per environment
                  value                the plain value (the toggle)
                  rules[]              first match wins
                  percentages[]        split everyone else
SDK key = (config, environment) → GET /v1/configs/{sdk_key}/config.json
  • Project: one product or team. Segments, history and the hashing salt belong to it.
  • Environment: Development, Staging, Production and so on. Every flag has its own value and targeting in each one. Keys are lowercase and never change.
  • Config: a set of flags that ship together, for example a server config and a client (browser or mobile) config. Your app downloads one config in one environment.
  • Setting: a feature flag or a remote setting. Its key is what your code reads (new_checkout).

2

Flag types

The type is fixed when the setting is created. Reading it with a default of another type returns your default with reason ERROR (TYPE_MISMATCH).

Setting types
TypeUse it forStarts as
booleanFeature flag: on or offfalse
stringText: a banner, a plan name, a variant label""
integerA whole number (safe integer)0
numberA decimal number0
jsonA JSON document, parsed once per config revision by the SDK (100 KB at most){}

3

The user object

Who you evaluate for. It stays in your process: the SDK never sends it to ProductCraft.

user.ts
import { FlagsClient } from "@productcraft/flags";
const flags = new FlagsClient({ sdkKey: process.env.PRODUCTCRAFT_FLAGS_SDK_KEY });

// ConfigCat's shape: identifier, email, country and custom attributes.
await flags.getValue("new_checkout", false, {
  identifier: "user-123",
  email: "ada@example.com".normalize("NFC"),
  country: "NL",
  custom: {
    plan: "pro", // text
    seats: 12, // number comparators
    app_version: "2.4.0", // semver comparators
    signed_up: new Date("2026-09-01T00:00:00Z"), // date comparators: a Date or unix seconds
    roles: ["admin", "billing"], // array comparators
  },
});

// Flat attributes work too: a rule on "plan" reads custom.plan first,
// then the top-level plan.
await flags.getValue("new_checkout", false, { identifier: "user-123", plan: "pro" });
  • identifier, email and country are top-level; a rule on any other attribute reads custom[attribute] first, then a top-level field of that name.
  • Comparisons are case-sensitive and compare Unicode code points with no normalisation: NL does not match nl. Normalise emails to NFC (.normalize("NFC")) before you pass them, or the same address typed two ways can land in different buckets.
  • Dates: pass a Date or unix seconds. An ISO string such as "2026-09-01" is a soft miss: the condition is false and a warning is logged.
  • A missing identifier skips the split. Percentage rollouts hash the identifier (or the attribute you choose); a user without it gets the value after the split, never one bucket that every anonymous user shares.

4

Targeting: the value, rules, then percentages

For one setting in one environment the SDK decides in this order:

  1. Rules, top to bottom. A rule is a list of conditions that must all be true (AND). The first rule that matches serves its value, or splits by percentage.
  2. Percentages for everyone no rule matched, if you set a split.
  3. The value, the plain on/off or setting value.

A condition that cannot be evaluated (the attribute is missing, empty or of the wrong kind) is a soft miss: it counts as false, negated comparators included, and evaluation continues with the next rule. A broken config for that key (an unknown key, a wrong default type, a prerequisite cycle) returns your default with reason ERROR.

A condition is one of three kinds: a user attribute compared with values, a segment (the user is or is not in it), or a prerequisite (another flag's value for the same user, up to 16 deep, no cycles).


5

Comparators

Snake_case names, the same in the console, the API, the config JSON and the SDKs. There are no regular-expression, case-insensitive or CIDR comparators in v1.

Comparators
FamilyComparatorsNotes
Texteq, neq, one_of, not_one_of, contains_any, not_contains_any, starts_with_any, not_starts_with_any, ends_with_any, not_ends_with_anyCase-sensitive, by codepoint, no Unicode normalisation. Numbers are compared as their text (1.5 → "1.5").
Confidential (hashed)eq_hashed, neq_hashed, one_of_hashed, not_one_of_hashed, starts_with_any_hashed, not_starts_with_any_hashed, ends_with_any_hashed, not_ends_with_any_hashed, array_contains_any_hashed, array_not_contains_any_hashedOnly SHA-256 hashes ship in the config JSON. Obfuscation, not secrecy: see Confidential comparators.
Semversemver_one_of, semver_not_one_of, semver_lt, semver_lte, semver_gt, semver_gteSemVer 2.0.0 precedence; build metadata ignored; a leading "v" is invalid.
Numbernum_eq, num_neq, num_lt, num_lte, num_gt, num_gteIEEE doubles. A string attribute is trimmed and parsed ("12", "1,5"); anything else is a soft miss.
Datebefore, afterUnix seconds (fractions allowed). Pass a Date or a number: an ISO string is a soft miss.
Arrayarray_contains_any, array_not_contains_anyThe attribute is a string[] (or a string holding a JSON array of strings).

Segment conditions use in / not_in; prerequisite conditions eq / neq. An unknown comparator is a soft miss, so older SDKs ignore newer ones safely.


6

Segments

A named group of users, defined once per project and used by any flag in it.

A segment is a list of user conditions (AND), for example “email ends with @acme.com” or a list of beta testers. Change it once and every flag that uses it follows. A segment in use cannot be deleted (409 SEGMENT_IN_USE). The config JSON holds only the segments its rules use. Guide 02 walks through it.


7

Percentage rollouts and stickiness

The SDK puts each user in one of 100 buckets: the first 7 hex digits of sha1(seed + identifier), modulo 100. The seed is the flag's key when it was created and never changes, so renaming a flag keeps everyone in their bucket, and flags imported from ConfigCat keep ConfigCat's buckets. The same user gets the same answer on every server and after every restart, and raising 10% to 25% only adds users. Rule-level and flag-level splits share the hash.

Split on another attribute (an account or team id) with percentage_attribute, so a whole team switches together.


8

Confidential comparators

Keep a list of emails or ids out of the config JSON in clear text.

The *_hashed comparators ship only salted SHA-256 hashes: sha256(value + salt + seed). The API keeps the clear text encrypted so the console can show and edit the list, for people with flags.confidential.read. Responses show the count, never the values.

This is obfuscation, not secrecy. The salt ships in the same config JSON, so a short or guessable value (an email at a known domain, a 6-digit id) can be found by trying candidates. Hashed starts-with and ends-with also reveal the length they compare ("12_…"), and a domain suffix is found instantly. Use them to keep names out of plain sight, not to protect secrets.


9

The toggle is the default, and the kill switch

The on/off switch in the flag list is the setting's plain value: what users get when no rule or split decides. Flipping it with rules in place changes only what “everyone else” gets; staff who match a rule still get the rule's value.

To turn a feature off for everyone, choose Serve OFF to everyone when the console asks (clear_rules: true in the API). It removes the rules and the split in one change; the old targeting stays in History, one click from coming back. The kill-switch runbook has the incident version.


10

Protected environments and change notes

Mark an environment protected (Production, by default on imported projects) and value changes there need flags.protected.update on top of flags.value.update. Members can work in Development; owners and admins, or keys you grant it to, change Production.

Turn on require a note and every change in that environment must say why (422 NOTE_REQUIRED without one). Notes appear in History and in the config.published webhook.


11

SDK keys and what they expose

An SDK key (pcf_sdk_ + 38 characters, with a checksum that catches typos) reads one config in one environment and nothing else. Rotate it from the console or the API: the old key keeps working for a grace period you choose (24 hours by default, up to 7 days), then stops.

Whoever holds a key can read every flag key, segment key and clear-text rule value of that config. So:

  • Server configs: keep the key on your servers. Internal rules (customer names, plan ids) are fine here.
  • Client configs (browser, mobile): the key ships in your app, so it is public. Put only what a user may see in them, and use hashed comparators for anything that looks like personal data.

Names, descriptions, tags and hints never leave the management API.


12

Revisions, generations and caching

Every change that alters what an environment serves publishes a new revision of its config JSON. Revisions only go up, and the SDK ignores an older one it might get from a cache. The generation changes only if we restore our database from a backup, and tells SDKs to accept the restored revision.

The SDK polls with If-None-Match, so an unchanged config costs a 304 with no body. Responses are gzip or brotli compressed; 200 flags with a couple of rules each come to about 2 KB on the wire. The last 100 revisions per environment are kept, and the console shows each one.


13

Propagation time

From Save in the console to every running app.

Our servers see a new revision within 5 seconds. Each SDK polls on its interval with up to 10% jitter, so the worst case is poll interval × 1.1 + 5 s: about 71 seconds at the default 60 s, about 38 seconds at the 30 s minimum. Call forceRefresh() or react to the config.published webhook to be faster.


14

Why did this user get this value?

details.ts
import { FlagsClient } from "@productcraft/flags";
const flags = new FlagsClient({ sdkKey: process.env.PRODUCTCRAFT_FLAGS_SDK_KEY });

const d = await flags.getValueDetails("new_checkout", false, { identifier: "user-123" });
// reason: STATIC | TARGETING_MATCH | SPLIT | DEFAULT | ERROR | CACHED | STALE
console.log(d.value, d.reason, d.variant, d.ruleIndex, d.percentageIndex, d.errorCode, d.revision);

STATIC: no rules or split, the plain value. TARGETING_MATCH: a rule served it. SPLIT: a percentage option. DEFAULT: rules exist, none matched. ERROR: your default, with an errorCode. CACHED / STALE: the client answered from its cache or a config older than it would like. The console's Test panel shows the same answer for a sample user, from the same engine.


15

Hosting, availability and limits

Flags runs in one EU region (DigitalOcean, Frankfurt, fra1). During the preview there is no SLA; the internal target for config delivery is 99.5% a month. Delivery keeps serving cached configs through a database outage (marked X-PC-Config-Stale: 1, for up to 24 hours), and SDKs ride out a full outage on the config they hold, your cache or the bootstrap.

Flags preview limits
WhatLimit
Projects3 per workspace
Environments5 per project
Configs10 per project
Feature flags and settings200 per config
Segments20 per project
Targeting rules50 per flag per environment
Conditions20 per rule (and per segment)
Comparison values1,000 values or 64 KB per condition; 20,000 per config
String or JSON value100 KB
Config JSON1 MiB per environment (422 CONFIG_TOO_LARGE); the console warns from 256 KB
Prerequisite depth16
SDK poll interval60 s by default, 30 s minimum
Config downloads20 req/s per SDK key and client IP, 20 req/s per client IP; a 304 is never refused for the key’s sake
Management API600 req/min per credential, writes 120 req/min, test evaluation 30 req/min
Change history50,000 changes or 200 MiB per project (oldest pruned, warning at 80%)
Stored config revisionsthe last 100, or 20 MiB, per config and environment
Webhook endpointsthe shared per-workspace limit of every ProductCraft product