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 carries allowed_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": [...] } }. kind defaults to "user" and by to "key"; together they name the bucketing value. seed, optional, is a string that replaces the flag key and salt in the ld_sha1 hash input (LaunchDarkly’s rollout seed); it’s only allowed on flags with hash: "ld_sha1". split is 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" } with at an RFC 3339 time, strictly increasing, and each split following 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"] }
  • kind defaults to "user" when absent. A kind is a non-empty string of ASCII letters, digits, ., _ and -, and is neither kind nor multi.
  • key is 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. kind isn’t one: a condition on attr: "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:

  1. f isn’t in the document: the caller’s fallback, reason not_found.
  2. c is invalid (see Context): the caller’s fallback, reason error.
  3. enabled is false: off_variant, reason disabled.
  4. Prerequisites, in order: if a prerequisite flag is missing from the document, is disabled, or evaluates (recursively, with the same c and now) to a variant other than the required one, then off_variant, reason prerequisite_failed, with prerequisite that flag’s key. A disabled prerequisite fails even if its off_variant is the required variant. If a prerequisite’s own evaluation returns error, so does f’s. A required variant the prerequisite flag doesn’t define is an error, checked before whether that flag is disabled; a missing prerequisite flag still fails as above.
  5. Targets, in order: the first target whose key list contains c serves its variant, reason target.
  6. Rules, in order: the first rule whose conditions all match serves its serve, reason rule, with the rule’s id. If the serve is a rollout and the bucketing value is unbucketable, the rule is skipped and evaluation goes on with the next rule.
  7. default, reason default. If it’s a rollout and the bucketing value is unbucketable: off_variant, reason fallback.

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:

  1. 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 negate is not applied (LaunchDarkly’s rule), so a negated condition never matches a context that lacks the attribute. exists and in_segment are the exceptions: they always evaluate (exists is false when the kind or attribute is absent) and then apply negate, so exists with negate: true means “absent”.
  2. 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.
  3. Wrong types are false. A present attribute of the wrong type for the operator (a number for contains, a boolean for gt, a non-version string for semver_gt) makes it false. Unlike a missing attribute, this result is then negated.
  4. negate flips 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/syntax enforces. 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 kind that isn’t a valid kind (see Context).
  • Split weights that don’t sum to 100000; steps whose at isn’t strictly increasing.
  • A rollout seed on a flag whose hash isn’t ld_sha1.
  • in_segment values 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 hashes or more than 64, or a list id that isn’t the one its position implies (t:<flag>:<variant>:<kind>, s+:<segment>:<kind>, s-:<segment>:<kind>) or isn’t in memberships.lists or memberships.private.lists; two targets of a flag with the same variant and kind, or two lists of one kind in a segment’s included or excluded.
  • memberships indexes whose lists aren’t sorted by bytes and unique, whose kinds names an invalid kind or has shard_bits above 24, or whose set isn’t 16 lowercase hex digits; a list id in both indexes; an empty memberships.private.secret.
  • experiments that break the rules of the experiment document.
  • semver_* values that aren’t valid versions; times (published_at, a step’s at, before and after strings) that aren’t real instants (month 13, February 30); matches values 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 B bits, as an unsigned integer (0 when B is 0);
  • entry = the 64 bits after them (bits B to B + 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:

  1. Inline (hashes): the kind is present in c and sha256_hex(utf8(secure_salt + ":" + kind + ":" + key))[0:16] is in hashes.
  2. By id: find L in memberships.lists (public index) or memberships.private.lists (private index); not found is an error (see Evaluation), and so is L in the private index of a document whose secret is present but empty. If the kind is absent from c, or not in that index’s kinds, c isn’t in L. Otherwise compute h for the kind, fetch shard shard(h) of that kind, binary-search its entries for entry(h), and c is in L when that entry carries L’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: "…" } })). identify and waitForReady resolve once the shards have loaded or failed; until then lookups are unresolved and evaluations reached through them report membership_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 secret and 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 is membership_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 …Async twin 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 (experimentSeeds in its options and in identify). A client SDK uses a kind’s seed when it has one and computes it from its experimentKey otherwise; with neither, that kind sends no exposures or events.
  • The encoding. A kind can’t contain 0x00 and a context key can, so the first 0x00 ends 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 as K, 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:

  • eventKey matches [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 events include eventKey and whose kind the 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.
  • track runs 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_key is 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 entry receive time − ago, truncated to the minute. value is optional (absent or null: 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 413 and nothing is taken.
  • What’s kept. An entry is kept only when the post’s experiment_key is the document’s, its experiment is in the document’s experiments, its period is at most the experiment’s, its unit is 32 lowercase hex digits, its variant is one of the flag’s, its event is in the experiment’s events, its value is finite and ago is 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 (JavaScript beforeEvaluation) 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_id and prerequisite.

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.