SDK specification
The contract every featflags SDK implements, from the flags document to contexts, evaluation, bucketing, membership lists, analytics, experiments and hooks. For SDK authors, and for anyone who wants to know exactly how a flag is decided.
featflags SDKs don’t call featflags to evaluate a flag. They download an environment’s flags as one JSON document from a CDN, keep it up to date, and evaluate flags locally, in the process that asks. Every SDK implements the rules on this page and runs the same shared test cases, so the same context gets the same answer in every language.
This page is that contract. It’s written for people writing or reviewing an SDK, and for anyone who wants to know exactly why a flag served what it did.
Delivery
Each environment publishes two documents, one per kind of key:
- Client keys (
pk_…) are public. Their document holds the flags marked client-visible, plus every flag they reach through prerequisites, so client SDKs can evaluate them, and carriesallowed_origins. - Server keys (
sk_…) are secret. Their document holds every flag, and the secret private lists need.
An environment can have several keys of each kind; all keys of one kind serve the same document. The document lives at:
https://flags.featflags.io/v1/{key}.json
An unknown, revoked or expired key is a 404. The CDN answers with Access-Control-Allow-Origin: * and a short browser cache (max-age=10); SDKs revalidate with If-None-Match and the last ETag.
Polling. SDKs poll every 5 seconds with server keys and every 60 seconds with client keys, each with ±10% jitter. They never replace a document with one of a lower version, and keep the last good document when a fetch fails. Browsers persist the last document and start the next page load from it; they refetch when the page is shown again or the network comes back, at most every 5 seconds. Mobile SDKs poll only in the foreground, refetch on returning to it or when the network comes back (at most every 5 seconds), and keep the last document on the device.
Edge runtimes (Cloudflare Workers, Vercel Edge Functions) run no timers between requests. There the client sets none, and a tick() handed to the runtime’s waitUntil after each response revalidates the document once a poll interval has passed and flushes once a flush interval has, or if nothing was ever flushed.
Processes that don’t live long enough to poll, such as PHP’s, share the document instead: every process reads it from a shared store, the first request to end after a poll interval revalidates it for all of them, and only a cold start fetches during a request.
Allowed origins. allowed_origins is enforced by the browser SDK: in a browser whose location.origin isn’t listed (an empty list allows every origin), every evaluation returns its fallback with reason error and an error naming the origin, the SDK logs one error, and it sends no counters. Server SDKs and native app SDKs ignore the field, as does the JS SDK in React Native. The CDN serves every document to every origin, so this is a guard against mistakes, not access control: what protects a client document is what it holds.
Long key lists travel separately, as shard files at content-addressed URLs that never change once written (Cache-Control: public, max-age=31536000, immutable).
Document schema
{
"schema": 2,
"project": "acme",
"env": "production",
"version": 1791367200000,
"published_at": "2026-10-07T10:00:00Z",
"secure_salt": "3f9c8a",
"allowed_origins": ["https://app.acme.com"],
"segments": {
"beta": { "match": [[{ "kind": "org", "attr": "plan", "op": "in", "values": ["pro", "team"] }]] }
},
"flags": {
"new-billing": {
"salt": "new-billing",
"enabled": true,
"variants": [{ "key": "on", "value": true }, { "key": "off", "value": false }],
"off_variant": "off",
"default": { "variant": "on" }
},
"new-checkout": {
"salt": "new-checkout",
"hash": "murmur3",
"enabled": true,
"variants": [{ "key": "on", "value": true }, { "key": "off", "value": false }],
"off_variant": "off",
"prerequisites": [{ "flag": "new-billing", "variant": "on" }],
"targets": [
{ "variant": "on", "hashes": ["975c96ad3472e32a"] },
{ "variant": "off", "kind": "org", "hashes": ["c2df8b7d3044ef83"] }
],
"rules": [
{ "id": "r1", "conditions": [{ "op": "in_segment", "values": ["beta"] }], "serve": { "variant": "on" } },
{ "id": "r2", "conditions": [{ "attr": "email", "op": "ends_with", "values": ["@acme.com"], "negate": true }, { "attr": "app_version", "op": "semver_gte", "values": ["5.2"] }], "serve": { "variant": "off" } }
],
"default": {
"rollout": {
"kind": "user",
"by": "key",
"split": [{ "variant": "on", "weight": 0 }, { "variant": "off", "weight": 100000 }],
"steps": [
{ "at": "2026-11-01T09:00:00Z", "split": [{ "variant": "on", "weight": 10000 }, { "variant": "off", "weight": 90000 }] },
{ "at": "2026-11-04T09:00:00Z", "split": [{ "variant": "on", "weight": 100000 }, { "variant": "off", "weight": 0 }] }
]
}
}
}
}
}
Document fields:
| Field | What |
|---|---|
schema |
Always 2. Evaluators reject any other value. |
project, env |
Keys of the project and environment. |
version |
Grows with every publish of the environment (see Versions). SDKs report it with every counter, so evaluations can be tied to the exact rules that produced them. |
published_at |
RFC 3339 time of the publish. |
secure_salt |
Salt for target hashes and in_secure values (see Hashed values). |
memberships |
Optional. The membership indexes holding lists too long to inline, and private lists (see Membership). |
allowed_origins |
Client documents only, possibly empty. See Delivery. |
experiment_key, experiments |
Optional. The environment’s experiment key fingerprint and its running experiments (see Experiment document). |
segments |
Segment key → segment. |
flags |
Flag key → flag. |
Flag fields:
| Field | Required | What |
|---|---|---|
salt |
yes | Bucketing salt; the flag key unless set. Changing it reshuffles everyone. |
hash |
no | Bucketing hash: "murmur3" (default) or "ld_sha1". See Bucketing. |
enabled |
yes | false serves off_variant. |
variants |
yes | [{ "key", "value" }], at least one. Keys are non-empty and unique within the flag; value is any JSON value. |
off_variant |
yes | Served when the flag is off or a prerequisite fails. |
prerequisites |
no | [{ "flag", "variant" }]: every listed flag must be on and serve that variant for this context. |
targets |
no | [{ "variant", "kind", "hashes" }] or [{ "variant", "kind", "list" }]: contexts whose key of that kind (default "user") is in the key list get variant. |
rules |
no | [{ "id", "conditions", "serve" }], evaluated in order. id is non-empty and unique within the flag; conditions defaults to [], which matches every context. |
default |
yes | The serve used when nothing above applies. |
Serve is exactly one of:
{ "variant": "on" }.{ "rollout": { "kind": "user", "by": "key", "seed": "61", "split": [...], "steps": [...] } }.kinddefaults to"user"andbyto"key"; together they name the bucketing value.seed, optional, is a string that replaces the flag key and salt in theld_sha1hash input (LaunchDarkly’s rollout seed); it’s only allowed on flags withhash: "ld_sha1".splitis a non-empty list of{ "variant", "weight" }with integer weights from 0 to 100000 that sum to exactly 100000 (one unit is 0.001%).steps, optional and non-empty when present, is a list of{ "at", "split" }withatan RFC 3339 time, strictly increasing, and eachsplitfollowing the same rules.
The effective split at evaluation time now is the split of the last step whose at ≤ now, or the rollout’s own split when no step has started. Buckets don’t depend on the step, so a ramp that only grows a variant’s share only ever moves contexts into it. Steps fire on the evaluator’s clock: in a client document, a user who sets their clock forward sees the next step early, which reveals nothing the document doesn’t already.
Segment: { "included": [list, …], "excluded": [list, …], "match": [[condition, …], …] }, included and excluded optional (default []), each entry a key list. A context is in the segment when an included list contains it; otherwise, when no excluded list contains it and match matches. Included wins over excluded and excluded over match, as in LaunchDarkly: a key in both lists is in the segment. Evaluators consult the included lists in order, then the excluded ones, then match, and stop as soon as the answer is known. match matches when every condition of any group matches (an OR of AND groups; an empty group matches every context, an empty match none). Conditions are as in Conditions, except that in_segment isn’t allowed, so segments can’t reference segments.
Key lists. A target, or a segment’s included or excluded entry, names a context kind (default "user") and holds the keys inline or by reference:
{ "kind": "user", "hashes": ["975c96ad3472e32a", …] }: the keys’ hashes, at most 64. The list contains a context when the context has that kind and the hash of its key is listed.{ "kind": "user", "list": "s+:beta:user" }: a list in a membership index, by id. The list contains a context when the context has that kind and the index says its key is in that list.
Lists of 64 keys or fewer are inlined; longer ones go into the membership index, and a private segment’s lists always go to the private index. Within a segment, included and excluded hold at most one entry per kind; within a flag, targets holds at most one per variant and kind.
Hashed values
Documents never carry target keys or in_secure values in plain text. Both are 16 lowercase hex characters, the first 64 bits of:
sha256_hex(utf8(secure_salt + ":" + kind + ":" + value))[0:16]
where value is the context key for targets and the attribute value for in_secure, and kind is the target’s or condition’s kind. People write plain text in the app; featflags hashes it when it publishes. Salted hashes stop casual reading, not the enumeration of guessable keys (sequential ids, emails).
Versions
SDKs never replace the document they hold with one of a lower version, including a document they persisted, because an edge that missed a purge can serve an older one. So a version never repeats or goes backwards, even after featflags restores its own data to an earlier point.
A publish takes max(previous + 1, now), where now is Unix time in milliseconds: one more than the previous version, floored at the clock. Versions are therefore large numbers (about 1.8 × 10¹² in 2026), below 2⁵³ for hundreds of thousands of years, so exact in JavaScript.
Context
The evaluation input, in LaunchDarkly’s shape. A single context:
{ "kind": "user", "key": "u-alice", "email": "alice@acme.com", "plan": "pro", "groups": ["qa", "eng"] }
kinddefaults to"user"when absent. A kind is a non-empty string of ASCII letters, digits,.,_and-, and is neitherkindnormulti.keyis a non-empty string identifying the context within its kind (a user id, device id, anonymous id, organisation id). It’s also an attribute (attr: "key") and the default bucketing value.- Every other member is an attribute.
kindisn’t one: a condition onattr: "kind"sees it as missing. To ask whether a multi-context includes a kind, test{ "kind": "org", "attr": "key", "op": "exists" }.
A multi-context carries one single context per kind, each without its own kind:
{ "kind": "multi", "user": { "key": "u-alice", "plan": "pro" }, "org": { "key": "o-acme", "plan": "team" } }
Conditions, targets and rollouts each name a kind and read only that kind’s attributes. A single context has exactly one kind; any other kind is absent.
Attribute values are strings, numbers, booleans, or arrays of those. null, objects, and arrays nested inside arrays count as missing: an attribute with one of those values is missing, and such an element inside an array is skipped (it never matches). An empty array is present but has no element that can match.
Invalid contexts. Evaluating against an invalid context returns reason error. A context is invalid when it isn’t an object; its kind isn’t a valid kind; a single context’s key is absent, empty or not a string; or a multi-context has no members, a member’s name isn’t a valid kind, or a member isn’t an object, has a kind, or has an invalid key.
The browser SDK generates and persists an anonymous user key when the caller doesn’t provide one.
Evaluation
Evaluators take the document, the flag key, the context and now (epoch milliseconds; SDKs pass their clock). The result is a variant (or the caller’s fallback) and a reason, plus rule_id for rule and prerequisite for prerequisite_failed.
For flag f and context c, the first step that applies wins:
fisn’t in the document: the caller’s fallback, reasonnot_found.cis invalid (see Context): the caller’s fallback, reasonerror.enabledis false:off_variant, reasondisabled.- Prerequisites, in order: if a prerequisite flag is missing from the document, is disabled, or evaluates (recursively, with the same
candnow) to a variant other than the required one, thenoff_variant, reasonprerequisite_failed, withprerequisitethat flag’s key. A disabled prerequisite fails even if itsoff_variantis the required variant. If a prerequisite’s own evaluation returnserror, so doesf’s. A required variant the prerequisite flag doesn’t define is anerror, checked before whether that flag is disabled; a missing prerequisite flag still fails as above. - Targets, in order: the first target whose key list contains
cserves itsvariant, reasontarget. - Rules, in order: the first rule whose conditions all match serves its
serve, reasonrule, with the rule’sid. If the serve is a rollout and the bucketing value is unbucketable, the rule is skipped and evaluation goes on with the next rule. default, reasondefault. If it’s a rollout and the bucketing value is unbucketable:off_variant, reasonfallback.
A flag that references something the document doesn’t define (a variant, a segment, a prerequisite’s variant, a list id in neither membership index) is an invalid document. Evaluators check a reference only when evaluation reaches it, as LaunchDarkly does: reaching one returns the caller’s fallback with reason error, while a context that takes another path evaluates normally. The same holds for the other things validation rejects that evaluation can run into: a matches pattern outside the regex subset, an in_segment condition inside a segment, and a step at that isn’t a real instant are each an error when reached, and never otherwise. A rule’s conditions are evaluated in order and stop at the first false one. A rollout resolves only the variant its bucket selects, so an entry the bucket doesn’t select, such as a 0-weight one, is never reached. Evaluating a prerequisite more than 10 levels below the requested flag returns error.
Typed getters (bool, string, number) return the caller’s fallback with reason type_mismatch when the variant’s value has another type.
| Reason | Variant | Extra |
|---|---|---|
disabled |
off_variant |
|
prerequisite_failed |
off_variant |
prerequisite: the first failing prerequisite’s key |
target |
the target’s variant | |
rule |
the rule’s serve | rule_id |
default |
the default serve | |
fallback |
off_variant |
|
not_found |
the caller’s fallback | |
type_mismatch |
the caller’s fallback | |
error |
the caller’s fallback | |
membership_pending |
what evaluation served |
membership_pending replaces any other served reason (and its rule_id or prerequisite) when a membership lookup the evaluation reached was unresolved because its shard was still loading: the variant is what the flag served without that list, and may change once the shard arrives. A lookup that can’t be resolved for good, such as a private list without a token, keeps the reason evaluation gave.
Conditions
A condition has LaunchDarkly’s clause shape:
{ "kind": "user", "attr": "plan", "op": "in", "values": ["pro", "team"], "negate": false }
kind defaults to "user" and negate to false. Every operator but exists takes values, and the condition holds when any value matches (an empty list matches nothing). For an attribute value a:
op |
values |
True when |
|---|---|---|
in |
strings, numbers, booleans | a equals a value. Type-strict ("1" isn’t 1), case-sensitive; numbers compare by value (1 equals 1.0). |
contains, starts_with, ends_with |
strings | a is a string that contains, starts with or ends with a value. Case-sensitive. |
matches |
regex strings | a is a string and the pattern matches anywhere in it (see Regex subset). |
gt, gte, lt, lte |
numbers | a is a number and a > v, a ≥ v, a < v or a ≤ v. |
before, after |
RFC 3339 strings or epoch-ms numbers | a is an instant (an RFC 3339 string or an epoch-ms number) strictly before or after the value’s. A string that isn’t valid RFC 3339 isn’t an instant, nor is one naming a time that doesn’t exist (February 30, hour 24, second 60). |
semver_eq, semver_gt, semver_gte, semver_lt, semver_lte |
version strings | a is a string and compares to the value by SemVer 2.0 precedence. One to three numeric components (5.2 is 5.2.0); build metadata ignored; an invalid version in a is false. |
exists |
none | The attribute is present (not missing). |
in_secure |
hashes | a is a string and its hash, with the condition’s kind, is listed. |
in_segment |
segment keys | c is in any listed segment. No attr or kind. |
In this order:
- Absent or missing is false. If the condition’s kind is absent from the context, or the attribute is missing, the condition is false and
negateis not applied (LaunchDarkly’s rule), so a negated condition never matches a context that lacks the attribute.existsandin_segmentare the exceptions: they always evaluate (existsis false when the kind or attribute is absent) and then applynegate, soexistswithnegate: truemeans “absent”. - Arrays match if any element matches. For an array attribute, the operator is true if it’s true for any element. Non-scalar elements are skipped.
- Wrong types are false. A present attribute of the wrong type for the operator (a number for
contains, a boolean forgt, a non-version string forsemver_gt) makes it false. Unlike a missing attribute, this result is then negated. negateflips the result of steps 2 and 3.
So { "attr": "plan", "op": "in", "values": ["free"], "negate": true } holds for plan: "pro" and for plan: 1, and not for a context without plan.
Regex subset
matches patterns use a subset of RE2 syntax, defined by the table below, with RE2 semantics. Matching is unanchored (a match anywhere in the string) unless the pattern anchors itself, and there are no flags: it’s case-sensitive and single-line. A pattern outside the subset makes the document invalid.
| Construct | Meaning |
|---|---|
| literal character | Any character except \ . [ ] ( ) { } | ^ $ * + ?. Non-ASCII characters are literals and match one code point. |
\ + one of the characters above |
That character, literally (\., \\, \{, …). |
. |
Any code point except \n. |
\d, \w, \s |
ASCII only: [0-9], [0-9A-Za-z_], [\t\n\f\r ]. |
\D, \W, \S |
Their ASCII negations. |
[…], [^…] |
A character class or negated class. Members: literal characters other than \ [ ] - (a ^ that isn’t first is literal), ranges x-y between two ASCII literals with x ≤ y, \d \w \s \D \W \S, and the escapes \\ \] \[ \^ \-. Non-empty. The sequences &&, --, ~~ and || aren’t allowed inside a class. |
^, $ |
Start and end of the whole string (not of a line, and never before a trailing newline). |
( … ), (?: … ) |
Capturing and non-capturing groups (captures are never used). |
a|b |
Alternation. |
*, +, ?, {m}, {m,}, {m,n} |
Quantifiers on the preceding literal, ., escape, class or group, with 0 ≤ m ≤ n ≤ 1000, each optionally followed by ? (lazy). |
Not allowed, among others: lookahead and lookbehind, backreferences, named groups, inline flags ((?i)), Unicode classes (\p{…}), POSIX classes ([[:alpha:]]), word boundaries (\b, \B), \A \z, control escapes (\n \t \xhh \uhhhh: put the character in the JSON string instead), possessive quantifiers, {,n}, and unescaped literal { } ].
- Length: at most 256 code points.
- Repeat counts: along any chain of nested quantifiers, the product of their counts is at most 1000, as Go’s
regexp/syntaxenforces. A quantifier’s count is its upper bound, or its lower bound when it has none, and*,+,?and{0,}count 1:((a{10}){10}){10}is allowed,(a{100}){11}isn’t. - Linear-time matching. Matching takes time linear in the input in every SDK. Where the host’s regex engine backtracks (JavaScript, Python, Java and others), the SDK implements the subset itself as an NFA simulation and never hands patterns to the host engine, since a backtracking engine takes exponential time on patterns the subset allows, such as
^(?:a|a)+0$. The subset has no backreferences or lookaround, so it’s always regular. Only whether a pattern matches is used, so a lazy quantifier matches exactly the strings its greedy form does.
Bucketing
The bucketing value is the rollout’s by attribute of its kind (by default, the user key). It’s bucketable when it’s a string, or an integer-valued number, used in its decimal form without a fraction (42, never 42.0). Booleans, non-integer numbers, arrays, a missing attribute and an absent kind are unbucketable: the rule is skipped, or the default falls back (see Evaluation). Below, str(value) is that string.
murmur3 (the default):
bucket = murmur3_x86_32(utf8(salt + ":" + str(value)), seed 0) % 100000
Walk the effective split in order, accumulating weights; the first variant whose cumulative weight exceeds bucket wins.
ld_sha1 (LaunchDarkly’s bucketing, for flags imported from it):
h = int(sha1_hex(utf8(flag_key + "." + salt + "." + str(value)))[0:15], 16)
With a rollout seed, the input is seed + "." + str(value) instead, with no flag key and no salt, as in LaunchDarkly. Walk the effective split in order, accumulating weights; the first variant whose cumulative weight w satisfies h × 100000 < w × 0xFFFFFFFFFFFFFFF wins. If none does (only when h = 0xFFFFFFFFFFFFFFF), the last variant in the split wins. Compute in integers: h has 60 bits, so the products need 128-bit integers or bigints.
Unlike murmur3, the input includes the flag key (unless the rollout has a seed), so two flags with the same salt bucket differently. LaunchDarkly compares h / 0xFFFFFFFFFFFFFFF with a running float sum of weight / 100000; the integer form is the exact version of that comparison and agrees with LaunchDarkly except within float64 rounding of a boundary, about one context in 10¹².
Validation
Validation rejects, beyond the shapes above:
- Variants with duplicate keys; any
off_variant, target, split or prerequisite naming a variant its flag doesn’t define. - Rule ids that repeat within a flag.
- A condition, target or rollout
kindthat isn’t a valid kind (see Context). - Split weights that don’t sum to 100000;
stepswhoseatisn’t strictly increasing. - A rollout
seedon a flag whosehashisn’tld_sha1. in_segmentvalues naming segments that don’t exist; prerequisites naming flags the document doesn’t contain; prerequisite cycles, including a flag listing itself.- A target or segment list with no
hashesor more than 64, or alistid that isn’t the one its position implies (t:<flag>:<variant>:<kind>,s+:<segment>:<kind>,s-:<segment>:<kind>) or isn’t inmemberships.listsormemberships.private.lists; two targets of a flag with the same variant and kind, or two lists of one kind in a segment’sincludedorexcluded. membershipsindexes whoselistsaren’t sorted by bytes and unique, whosekindsnames an invalid kind or hasshard_bitsabove 24, or whosesetisn’t 16 lowercase hex digits; a list id in both indexes; an emptymemberships.private.secret.experimentsthat break the rules of the experiment document.semver_*values that aren’t valid versions; times (published_at, a step’sat,beforeandafterstrings) that aren’t real instants (month 13, February 30);matchesvalues outside the regex subset, longer than 256 code points, or whose nested repeat counts multiply past 1000.
Membership
A list of keys, from 50 QA users to 10 million customers, has to answer “is this context in it?” inside the SDK without every client downloading the whole list. Short lists sit inline in the document as hashes (see Key lists). Longer ones fold into a membership index: per environment and context kind, every list above 64 keys becomes one set of sorted binary shards, and a client fetches the one shard its key falls in, one request per kind however many lists there are.
Memberships field
"memberships": {
"salt": "3f9c8a",
"set": "a3f1c2d4e5b60718",
"bytes": 20512,
"kinds": { "org": { "shard_bits": 0, "bytes": 1010 }, "user": { "shard_bits": 3, "bytes": 19502 } },
"lists": ["s+:beta:org", "s+:beta:user", "s-:beta:user", "t:new-checkout:on:user"],
"private": {
"secret": "9b1d…",
"set": "0c4e8f1a2b3c4d5e",
"bytes": 1046,
"kinds": { "user": { "shard_bits": 0, "bytes": 1046 } },
"lists": ["s+:partners:user"]
}
}
| Field | What |
|---|---|
salt |
Salt of the member hash: the environment’s secure_salt, so for a kind with shard_bits 0 an entry is exactly the key’s inline hash. |
set |
Id of the index content; names its shard URLs. |
bytes |
Total size of every shard of the index, so a server SDK can decide to download it whole. |
kinds |
Kind → { shard_bits, bytes } for every kind that has shards. shard_bits (B, 0 to 24) gives the kind 2^B shards; bytes is the size of all of them. A kind not listed has no members in any list of the index. |
lists |
List ids, sorted by bytes. A shard entry refers to a list by its position here. |
private |
The private index, laid out the same, for private segments’ lists. secret is only in server documents. |
The public index is always present when memberships is (with empty kinds and lists, and bytes 0, when only private lists exist); private is absent when there are no private lists; memberships is absent when the document has no list in either index.
List ids say what a list is: t:<flag>:<variant>:<kind> for a flag’s targets of one variant and kind, s+:<segment>:<kind> and s-:<segment>:<kind> for a segment’s included and excluded keys of one kind. Evaluators treat them as opaque and only compare them.
Member hashes and private tokens
For the public index, a context of kind k with key key has the 32-byte member hash:
h = sha256(utf8(salt + ":" + k + ":" + key))
For the private index, it has the 32-byte token:
h = hmac_sha256(key = utf8(secret), message = utf8(k + ":" + key))
Server SDKs compute tokens from the server document’s secret. Browsers and apps never see the secret: the customer’s backend computes the token with a server SDK and hands it to the client at sign-in, as 64 lowercase hex digits. A client without a token for a kind can’t consult that kind’s private lists.
With B the shard_bits of the context’s kind, reading h as a big-endian bit string:
- shard = the first
Bbits, as an unsigned integer (0 whenBis 0); - entry = the 64 bits after them (bits
BtoB + 63), as an unsigned 64-bit integer.
With 64-bit entries, a key that isn’t in any list matches an entry by chance with probability about N / 2⁶⁴ for N members (5 × 10⁻¹⁴ at a million).
Shard files
URL: https://flags.featflags.io/v1/m/{set}/{kind}/{shard}.bin, with {shard} in decimal without leading zeros (0.bin to 4095.bin for a kind with B = 12). Every shard of every listed kind exists, empty ones included, so a fetch never gets a 404. Byte by byte:
| Offset | Size | What |
|---|---|---|
| 0 | 4 | Magic FFM1 (46 46 4d 31). |
| 4 | 4 | Entry count n, unsigned 32-bit big-endian. |
| 8 | … | n entries, sorted by value, values unique within the shard. |
Each entry:
| Size | What |
|---|---|
| 8 | Entry value, unsigned 64-bit big-endian. |
| varint | Number of lists m, at least 1. |
m varints |
Positions in the index’s lists, strictly increasing. |
A varint is unsigned LEB128 in its shortest form: seven bits per byte, least significant group first, the high bit set on every byte except the last. Values fit in 32 bits. A key belongs to every list whose position its entry carries; two keys with the same shard and entry share it, carrying the union of their lists.
Shard bits. For each kind, the smallest B from 0 to 24 such that the kind’s entries total at most 2048 × 2^B bytes, so shards average 2 KB or less. A million-member list makes about 4,096 shards of 2 KB.
Set id. The first 16 lowercase hex digits of the sha256 of:
"FFM1" ‖ u32be(number of kinds)
then, for each kind in byte order: u32be(len(utf8(kind))) ‖ utf8(kind) ‖ u8(B of the kind)
then, for shard 0 to 2^B − 1: u32be(len(shard)) ‖ shard bytes
Identical content gets the identical set, and so the identical URLs.
Lookup
To check whether list L contains context c:
- Inline (
hashes): the kind is present incandsha256_hex(utf8(secure_salt + ":" + kind + ":" + key))[0:16]is inhashes. - By id: find
Linmemberships.lists(public index) ormemberships.private.lists(private index); not found is anerror(see Evaluation), and so isLin the private index of a document whosesecretis present but empty. If the kind is absent fromc, or not in that index’skinds,cisn’t inL. Otherwise computehfor the kind, fetch shardshard(h)of that kind, binary-search its entries forentry(h), andcis inLwhen that entry carriesL’s position.
Unresolved lookups. An SDK may be unable to consult a list: its shard isn’t loaded yet, or it’s a private list and the client has no token for the kind. Then a target doesn’t match, an included list doesn’t include the context, and an excluded list keeps the context out of the segment unless an included list already let it in. An unresolved lookup never puts a context into a segment or a target.
Shard files never change, so SDKs cache them by URL and never revalidate them.
- Client SDKs (browser, iOS, Android, Flutter) fetch the identified context’s shards when the context is set (one request per kind and index, private ones only with a token, passed as
identify(ctx, { membershipTokens: { user: "…" } })).identifyandwaitForReadyresolve once the shards have loaded or failed; until then lookups are unresolved and evaluations reached through them reportmembership_pending. A new document’s shards for the current context load before the client switches to it. Shards stay in memory for the session. - Server SDKs compute private tokens from the document’s
secretand expose them for backends (membership_token(ctx): kind → 64 hex digits for each kind of the context). When the indexes they can read total under 50 MB (8 MB in PHP, whose shared store is smaller), they download every shard in the background when the document arrives. Otherwise they fetch a shard on first need and keep the most recently used ones (1,024 by default, about 2 MB). Evaluation is synchronous, so a lookup whose shard isn’t loaded blocks until it arrives or a timeout (500 ms by default, shared by the whole evaluation) passes; then the lookup is unresolved, the reason ismembership_pending, and the shard keeps loading for later evaluations. - The JavaScript SDK serves browsers and servers, so it’s both: a client SDK for the identified context, and a server SDK for a context passed to an evaluation, whose shards it fetches on first need and keeps among the 1,024 most recently used. It never downloads a whole index. A JavaScript evaluation can’t block, so the synchronous methods start fetching a missing shard and report
membership_pending; each has an…Asynctwin that waits as server SDKs block.
Publishing
featflags uploads every shard of a new set before it publishes a document that names it, so a document never points at a shard that isn’t there. Old sets stay readable for clients still holding an older document: a set no current document references is deleted 24 hours after it was last current.
Analytics
Evaluation counters
SDKs count (flag, variant, reason, rule_id, version) in memory and flush the counts to POST https://events.featflags.io/v1/events every 60 seconds, plus a random 0–60 second offset per process, so a fleet started at once doesn’t flush at once. close() flushes. Where processes share state (PHP), each adds its counts to the shared store as it ends, and the first to end after the flush interval sends the pooled counts in one post.
{
"key": "pk_…",
"sdk": "js/0.1.0",
"counts": [{ "flag": "new-checkout", "variant": "on", "reason": "rule", "rule_id": "r1", "version": 1791367200000, "n": 412 }]
}
Counters carry no context data. featflags buckets them by the minute they arrive, since client clocks can’t be trusted. not_found and type_mismatch are counted with an empty variant. A key is limited to 100 posts a second, with bursts of up to 6,000 (a fleet flushing as it stops); a post past that is answered 429 and dropped. SDKs don’t retry a flush, so a post is never counted twice.
Exposure callback
An onExposure callback, where an SDK offers one, fires the first time a (key, flag, variant) is evaluated in an SDK instance. For a multi-context, key is the user member’s key if there is one, else the key of the kind that sorts first by bytes, so every SDK reports the same key whatever the member order. It’s for the customer’s own analytics; featflags receives none of these. What featflags does receive, for flags in a running experiment, are experiment exposures.
Experiments
An experiment runs on a flag: part of an environment’s traffic, the experiment’s audience (one rule of the flag, or its default), is split between variants at random. SDKs report which units saw which variant (exposures) and what they did next (events, from track()), and featflags computes which variant did better. An experiment changes what a document holds and what SDKs post, never how a flag is evaluated.
Privacy
The rules everything below follows. A hash featflags could compute, salted or not, would be pseudonymous personal data in its hands, since anyone who can compute it can enumerate likely inputs. So what featflags receives is keyed with a secret it never holds, and minimal:
- A key featflags never holds. Each environment has an experiment key, 32 random bytes generated in the customer’s browser and kept in their SDK configuration. featflags stores only its fingerprint. Every unit it receives is an HMAC under that key.
- Unlinkable across experiments. A unit id is derived per experiment, so the same person has unrelated ids in two experiments, and units can’t be linked to the counters, which carry no unit at all.
- Nothing but the unit, the variant and the event.
track(eventKey, value?)takes a key matching[a-z0-9_.:-]{1,64}and an optional finite number: no properties, no strings, no attributes. Exposures carry the variant served. Neither carries the context, its attributes, a device, a page or a session. - No request metadata. No IP address, user agent, referrer or country is stored with any row.
- Minute timestamps. Rows hold the minute an entry was made, never a finer time.
- Kept while needed. Per-unit rows are deleted 30 days after their experiment stops; results keep only per-variant aggregates.
- Only for experiments. SDKs send exposures and events only for experiments running in the environment, and only the events their metrics use.
Client apps. A browser or mobile app holding the experiment key would ship it to every user. So client SDKs needn’t hold it: the customer’s backend computes a signed-in context’s seeds with a server SDK and hands them to the app at sign-in, and the app holds only its own user’s seeds. A client SDK given the key itself (for anonymous visitors, whose keys the SDK generated) works the same, and the key is then public.
Experiment keys
An environment’s experiment key is xk_ followed by 64 lowercase hex digits: 32 bytes from a cryptographic random generator, generated in the browser (or by the CLI) and shown once. featflags receives only its fingerprint: the first 8 bytes of sha256(key bytes), as 16 lowercase hex digits, which documents carry as experiment_key.
SDKs compare their key’s fingerprint with the document’s experiment_key, and send no exposures or events when they differ or the document has none, logging one error naming both fingerprints (and logging nothing when they hold no key: experiments are off). So a deploy with an old key after a rotation sends nothing rather than units no experiment can join. Seeds carry the fingerprint of the key they were computed with, and a client ignores seeds whose fingerprint isn’t the document’s, with one error, using its key if it has one.
Unit ids
A unit is a context of the experiment’s kind, identified by its key, under the environment’s key. With K the experiment key’s 32 bytes:
seed = hmac_sha256(key = K, message = utf8(kind) ‖ 0x00 ‖ utf8(context key)) 32 bytes
unit = hmac_sha256(key = seed, message = utf8(experiment id))[0:16] 16 bytes, sent as 32 lowercase hex
- Two steps so a client needs only its own seeds. A server SDK computes them (
experiment_seeds(context), in each SDK’s spelling):{ "fingerprint": <the key's fingerprint>, "seeds": { <kind>: <64 lowercase hex digits> } }, a seed for every kind of the context, nothing without a key. The backend hands that object to the client SDK (experimentSeedsin its options and inidentify). A client SDK uses a kind’s seed when it has one and computes it from itsexperimentKeyotherwise; with neither, that kind sends no exposures or events. - The encoding. A kind can’t contain
0x00and a context key can, so the first0x00ends the kind and every(kind, key)pair is one message. HMAC-SHA256 is a PRF in its key, so a seed is a key as good asK, and one key per environment makes two environments’ seeds unrelated. - 16 bytes keep collisions out of reach (two of a billion units collide with probability about 10⁻²¹).
- An experiment’s kind is its audience rollout’s, which buckets by
key, so the unit is exactly what bucketing used: a context missing the kind is unbucketable, never exposed, and sends no event.
Experiment document
A document carries the environment’s running experiments (a guarded release’s measurement is one, under the release plan’s id); a client document only those of client-visible flags:
"experiment_key": "e178b2e6961566fc",
"experiments": {
"3f9c8a1b2c4d5e6f": { "flag": "new-checkout", "rule": "r1", "kind": "user", "period": 1, "events": ["checkout.completed", "page.error"] }
}
| Field | What |
|---|---|
experiment_key |
The environment’s experiment key fingerprint. Absent when it has none. |
experiments |
Experiment id → the running experiment. Absent when none runs. |
flag |
Its flag. At most one experiment per flag. |
rule |
Its audience: a rule id of that flag, or absent for the flag’s default. |
kind |
The audience rollout’s kind, so track() knows the unit without evaluating. |
period |
Its current period, from 1, which grows each time its split changes while it runs (a bandit’s reweight, a guarded release’s next milestone). |
events |
The event keys of its metrics, sorted and unique. |
Validation adds: ids of 16 lowercase hex digits; an experiments map needs experiment_key; each experiment’s flag is in the document and has the rule it names; the audience’s serve is a rollout with by: "key", the experiment’s kind and no steps; at most one experiment per flag; events match the event key pattern, sorted and unique; period at least 1. SDKs that don’t know these fields ignore them.
Exposures
An evaluation is an experiment exposure when the flag evaluated is in a running experiment of the document and evaluation served its audience: reason rule with the experiment’s rule as rule_id, or reason default for an experiment on the default, with a variant. Nothing else is: a target, another rule, disabled, prerequisite_failed, fallback, membership_pending (the variant may still change), error, or a flag evaluated only as another’s prerequisite. A unit that matches an earlier rule or a target isn’t in the experiment.
- The SDK records
{ experiment, period, unit, variant }the first time it evaluates that combination, keeping what it recorded in a set, cleared when it reaches 100,000 entries so a long-lived process reports again rather than grow without bound. Duplicates are harmless: results keep a unit’s first exposure. - The unit is computed from the context’s member of the experiment’s
kind, with its seed. Without a key or a seed for the kind, or when the fingerprints differ, nothing is recorded. - Exposures are buffered and posted with the counters (see Events API). The exposure callback is separate and unchanged.
Track events
track(eventKey, value?) in client SDKs (for the identified context), track(eventKey, value?, context?) in JavaScript, and track(context, eventKey, value?) in server SDKs, each in its own spelling, records that the unit did something:
eventKeymatches[a-z0-9_.:-]{1,64};value, optional, is a finite number of magnitude below 10¹⁵. Anything else is refused in the call (logged once per key, never thrown).- For each running experiment whose
eventsincludeeventKeyand whosekindthe context has, the SDK buffers one entry{ experiment, unit, event, value }. One call can send several entries, one per experiment; a key no running experiment uses sends nothing. - The SDK doesn’t check whether the unit was exposed: conversions often happen in another process (the backend) than the exposure (the browser). Results join events to exposures and ignore units never exposed.
trackruns no hooks.
Events API
Exposures and events travel in the counters’ post, POST https://events.featflags.io/v1/events, with three optional fields:
{
"key": "pk_…",
"sdk": "js/0.2.0",
"counts": [],
"experiment_key": "e178b2e6961566fc",
"exposures": [{ "experiment": "3f9c8a1b2c4d5e6f", "period": 1, "unit": "7721b4e7bcafe9260838cf339c30cab3", "variant": "on", "ago": 42 }],
"events": [{ "experiment": "3f9c8a1b2c4d5e6f", "unit": "7721b4e7bcafe9260838cf339c30cab3", "event": "checkout.completed", "value": 49.9, "ago": 3 }]
}
experiment_keyis the fingerprint of the key the SDK computed with.ago(optional, default 0) is how many whole seconds before the post the entry was made, by the SDK’s own clock, so an app that flushes late (offline, backgrounded) dates entries right without trusting its clock’s absolute time. featflags dates an entryreceive time − ago, truncated to the minute.valueis optional (absent ornull: no value).- Flushing. Exposures and events are flushed with the counters: every 60 seconds plus the process’s offset, when the page hides or the app goes to the background, and on
close(); also as soon as 5,000 are buffered. An SDK buffers at most 50,000 and drops what doesn’t fit, logging it once per flush interval. A failed flush isn’t retried, so a post is never sent twice. - Limits. A post holds at most 10,000 exposures and 10,000 events (beside 5,000 counts), in at most 1 MiB; more is a
413and nothing is taken. - What’s kept. An entry is kept only when the post’s
experiment_keyis the document’s, its experiment is in the document’sexperiments, itsperiodis at most the experiment’s, itsunitis 32 lowercase hex digits, itsvariantis one of the flag’s, itseventis in the experiment’sevents, itsvalueis finite andagois at most 86,400. Anything else is dropped.
SDK hooks
Every SDK runs hooks around every evaluation, in LaunchDarkly’s shape, for logging, metrics or tracing. A hook has a name and two optional stages, registered when the client is built:
before_evaluation(series, data) → data(JavaScriptbeforeEvaluation) runs before the flag is evaluated, hooks in registration order.after_evaluation(series, data, detail) → data(afterEvaluation) runs after it, hooks in reverse order, and gets the detail the caller gets: the value (the fallback when evaluation failed),variant,reason,rule_idandprerequisite.
series describes the call: flag_key, context (the one evaluated: in a client SDK, the client’s own when none is passed), fallback, and method, the client method called in the SDK’s own spelling (boolDetail, bool_variation_detail, …). data is a map a hook hands from one stage to the next: each hook’s before stage starts from empty data and its after stage gets what it returned; hooks never see each other’s data. The after stage’s result is unused. Hooks run for every evaluation, not_found, type_mismatch and error ones included, every time.
Identify. Client SDKs also run hooks around identify, the only method that changes the context a client evaluates with: beforeIdentify(series, data) before the context changes and afterIdentify(series, data, result) once identify settles, with the same ordering and data rules. series is { context }, and result is { status: "completed" }, or "error" when it fails or the client is closed first. Server SDKs have no identify, so no identify stages; track runs no hooks.
A stage that throws (or panics, or raises) is logged and skipped: evaluation and the other hooks go on, and that hook’s next stage gets empty data.
OpenTelemetry
An OpenTelemetry hook, in every SDK but Dart (OpenTelemetry has no official Dart API yet) and kept out of each core package, adds a feature_flag.evaluation event to the active span, per OpenTelemetry’s semantic conventions for feature flags. Evaluations outside a span record nothing.
| Attribute | Value |
|---|---|
feature_flag.key |
The flag key. |
feature_flag.provider.name |
featflags |
feature_flag.result.variant |
The variant key; absent when the fallback was returned. |
feature_flag.result.reason |
targeting_match (target, rule), default (default, fallback), disabled, error (not_found, type_mismatch, error), and featflags’ own prerequisite_failed and membership_pending, as the OpenFeature providers map them. |
error.type |
flag_not_found, type_mismatch or general (error), for error reasons only. |
OpenFeature and OpenTelemetry lists each SDK’s hook and provider packages.