Feature flags
Fetch MIRA FIVE flag documents over HTTP, evaluate flags yourself, hand answers to a page and count experiment exposures.
MIRA FIVE does not answer "which variant does this user get" per request. It serves each source a flag document with every flag's rules, and your code evaluates them locally, so reading a flag costs no request. Browsers fetch GET /v1/flags/{websiteKey}, servers GET /v1/flags. This page covers the four endpoints, the document format and the evaluator. For using flags from an SDK, see Feature flags.
Documents per source
The server compiles every source's live flags into up to three documents:
| View | Served to | Content |
|---|---|---|
| browser | full website sources | the definitions of the flags the website reads |
| values | every website source | the answer each of those flags gives with no facts at all |
| server | server sources | the definitions of the flags servers read; w: 1 marks those the website reads too |
A consentless website source has no id to evaluate rules on, so it only ever gets the values view. Flags only servers read never appear in a browser or values document. Documents carry no flag names, descriptions or segment ids; a segment appears as an opaque ref. A source with nothing compiled yet gets a document without flags.
Read the browser document
GET /v1/flags/{websiteKey}, with the website key in the path:
curl -i https://events.mirafive.io/v1/flags/$MIRAFIVE_WEBSITE_KEY{
"at": 1790000000000,
"flags": {
"beta-banner": { "d": "off", "r": [{ "if": [["s", "3fa9c1e07b"]], "x": "on" }], "s": "q8w2e5r7t1y4", "t": "b", "u": "b" },
"checkout-limits": {
"d": "free",
"p": { "free": { "maxItems": 10 }, "pro": { "maxItems": 50 } },
"r": [{ "if": [["p", "plan", "is", ["pro", "team"]]], "x": "pro" }],
"s": "7h2kq9x0m3pa",
"t": "c",
"u": "p"
},
"pricing-test": { "c": "b", "d": "a", "e": "r", "r": [{ "w": [["a", 5000], ["b", 5000]] }], "s": "3f9a1c0b7e2d", "t": "m", "u": "b" }
},
"v": 1
}| Query parameter | Description |
|---|---|
view=values | Returns the values document instead of the browser document. |
mirafive-preview-token | Forward it unchanged, URL-encoded, when the page URL carries it. A valid token adds one code experiment's unstarted draft to the browser document for 24 hours. |
A full source returns the browser document, or the values document with ?view=values. A consentless source always returns the values document, whatever the query says:
curl https://events.mirafive.io/v1/flags/$MIRAFIVE_WEBSITE_KEY?view=values{ "at": 1790000000000, "v": 1, "values": { "beta-banner": ["off"], "checkout-limits": ["free", { "maxItems": 10 }], "pricing-test": ["a"] } }Answers are Content-Type: application/json with Cache-Control: no-store. In a browser, fetch with cache: 'no-store', credentials: 'omit' and referrerPolicy: 'no-referrer', and add no custom headers, so there is no preflight. The Origin check of the Overview applies.
Read the browser document with segment membership
POST /v1/flags/{websiteKey} returns the same browser document plus which segments this browser is in. The body is Content-Type: text/plain;charset=UTF-8, at most 1,024 bytes:
curl -i https://events.mirafive.io/v1/flags/$MIRAFIVE_WEBSITE_KEY \
-H 'Content-Type: text/plain;charset=UTF-8' \
-d '{"anonymousId":"5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44","identified":true}'{
"document": { "at": 1790000000000, "flags": { "beta-banner": { "d": "off", "r": [{ "if": [["s", "3fa9c1e07b"]], "x": "on" }], "s": "q8w2e5r7t1y4", "t": "b", "u": "b" } }, "v": 1 },
"membership": { "segments": ["3fa9c1e07b"], "unavailable": [], "refreshedAt": 1789999400000, "stale": false }
}| Body field | Type | Description |
|---|---|---|
anonymousId | string | Required for membership. The browser's anonymous id, the UUID stored by the SDK. |
identified | boolean | Whether the page called identify(). |
document is the browser document, preview token included. membership is left out when the body is missing, longer than 1,024 bytes or not JSON, when anonymousId is not a usable id, and whenever the request carries Sec-GPC: 1 or DNT: 1. A consentless source answers 403 lookup_not_allowed.
Use this request only with the visitor's targeting consent, and never under Do Not Track, Global Privacy Control or prerendering. Otherwise use the GET.
Read the server document
GET /v1/flags, with the secret key as a bearer:
curl -i https://events.mirafive.io/v1/flags \
-H "Authorization: Bearer $MIRAFIVE_SECRET_KEY"{
"at": 1790000000000,
"flags": {
"checkout-limits": {
"d": "free",
"p": { "free": { "maxItems": 10 }, "pro": { "maxItems": 50 } },
"r": [{ "if": [["p", "plan", "is", ["pro", "team"]]], "x": "pro" }],
"s": "7h2kq9x0m3pa",
"t": "c",
"u": "p",
"w": 1
},
"invoice-v2": { "d": "off", "r": [{ "if": [["s", "9a8b7c6d5e"]], "x": "on" }, { "sh": 2000, "w": [["on", 10000]] }], "s": "m4n5b6v7c8x9", "t": "b", "u": "p" }
},
"v": 1
}The answer carries Cache-Control: private, no-cache and a weak ETag such as W/"f-Yk3v0Q9mZ2xW7pL1aR8sTc". Send it back in If-None-Match; while the document is unchanged the answer is 304 Not Modified with no body:
curl -i https://events.mirafive.io/v1/flags \
-H "Authorization: Bearer $MIRAFIVE_SECRET_KEY" \
-H 'If-None-Match: W/"f-Yk3v0Q9mZ2xW7pL1aR8sTc"'Refresh when a flag is read, no more often than every 30 seconds by default and never more often than every 10 seconds, and keep serving the last document while a refresh fails. After a 401 or 403, stop fetching until the process restarts, and keep the last document.
Look up segments for server units
POST /v1/flags/segments answers segment membership for up to 100 users or browsers at once, for the segment refs the server document tests:
curl -i https://events.mirafive.io/v1/flags/segments \
-H "Authorization: Bearer $MIRAFIVE_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{"units":[{"userId":"u_42"},{"anonymousId":"5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44"}]}'{
"units": [
{ "segments": ["9a8b7c6d5e"], "unavailable": [], "refreshedAt": 1789999400000, "stale": false },
{ "segments": [], "unavailable": [], "refreshedAt": 1789999400000, "stale": false }
]
}Each unit needs a string userId or anonymousId; the other may be absent or null. Answers come in request order, each shaped like membership. A body whose units is not a list, or holds a unit without a string id, gets 422 invalid_units; more than 100 units get 422 too_many_units. Lookups are charged per unit: combine the lookups of one tick into one request and cache answers briefly (the Node SDK keeps 10,000 units for one minute).
Documents
Bodies are canonical JSON: object keys sorted byte-wise at every depth and no insignificant whitespace, so the same content is always the same bytes. The examples on this page are indented for reading. Ignore unknown fields at every level. Browser and values documents are at most 64,000 bytes, server documents at most 256,000 bytes.
Envelope
| Field | Type | Description |
|---|---|---|
v | integer | 1. Treat any other version as unreadable and keep what you had. |
at | integer | When the document was compiled, epoch milliseconds. |
flags | object | Browser and server views: flag key → flag. |
values | object | Values view: flag key → [variant], or [variant, value] when the flag has a value for that variant. It is what the flag evaluates to with no facts. Flags whose no-facts evaluation fails are left out. |
orig | string[] | Browser and values views, optional: keys of page experiments that now show everyone the original. Answer a for them and send no exposure. |
Flag
| Field | Type | Description |
|---|---|---|
s | string | Seed for hashing, ^[0-9a-z]{12}$. Fixed once the flag has split anyone. |
t | string | Type: "b" on/off (variants on and off), "m" variants, "c" remote config. |
u | string | Unit: "b" the browser (anonymous id) or "p" the signed-in person (user id). |
d | string | Default variant. |
p | object | Optional. Variant → JSON value (remote config). |
r | array | Rules, first match wins. [] when the flag is off. |
off | 1 | Optional. The flag is turned off: the answer is d with reason DISABLED. |
e | string | Optional. The flag is a counting experiment. Before the consent banner is answered, a browser shows a random variant ("r") or the default ("o"). |
c | string | Optional. Where the experiment is counted: "b" browser, "s" server. See Exposures. |
need | integer | Optional, default 1. The evaluator feature level the flag needs. |
w | 1 | Optional, server view only. The website reads this flag too. |
e, c, p, w and unknown keys never change what evaluation returns. Variant keys match ^[a-z][a-z0-9-]{0,39}$, flag keys ^[a-z][a-z0-9-]{1,63}$.
Rules and conditions
type Rule =
| { if?: Condition[]; x: string } // a fixed variant; { x } alone means everyone
| { if?: Condition[]; sh?: number; w?: [string, number][] } // share and weights in basis points
type Condition =
| ['p', property: string, op: Op, value: Json] // value is null for set and unset
| ['s', ref: string] // in the segment
| ['s', ref: string, 1] // not in the segment
type Op = 'is' | 'not' | 'has' | 'nhas' | 'pre' | 'gt' | 'lt' | 'set' | 'unset'| Rule field | Description |
|---|---|
if | Conditions that must all hold. Absent means the rule applies to everyone. |
x | The variant this rule answers. |
sh | Share of units the rule reaches, in basis points (0 to 10,000). Default 10,000, everyone. |
w | Variants and their weights in basis points. Weights may sum to less than 10,000; the rest gets the default. |
A rule with x together with sh or w is not valid; evaluation fails with UNSUPPORTED.
Membership
| Field | Type | Description |
|---|---|---|
segments | string[] | Segment refs the unit is in. |
unavailable | string[] | Refs that cannot be answered. Their conditions are false, for "in" and "not in" alike. |
refreshedAt | integer or null | When the oldest answered segment was last built, epoch milliseconds. |
stale | boolean | true when refreshedAt is older than the server's freshness limit. |
When the server has browser lookups switched off, every ref the document tests is answered as unavailable.
Refusals
Refusals use the error body of Errors and retries: { "code": "…", "detail": "…" }.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Unknown, revoked or archived key. |
| 403 | secret_key_in_path | A secret key in a browser path. When a browser sent it, the key is marked exposed. |
| 403 | website_key_as_bearer | A website key sent as a bearer. |
| 403 | secret_key_exposed | A secret key arrived with an Origin or Sec-Fetch-Site header. Rotate it. |
| 403 | origin_not_allowed | A browser request from an origin the source does not allow. |
| 403 | lookup_not_allowed | POST /v1/flags/{websiteKey} on a consentless source. |
| 404 | not_found | Feature flags are not available for this key's organization. |
| 422 | invalid_units, too_many_units | See Look up segments for server units. |
| 429 | rate_limited | Wait for Retry-After seconds. |
401 and 403 are final. A paused organization keeps being served its documents.
Evaluate flags yourself
Every SDK runs the same evaluator. An implementation that returns every expected result in the protocol's conformance fixtures (flag-hash.cases.json, flag-eval.cases.json) evaluates exactly as MIRA FIVE does.
Facts and results
type Facts = {
id?: string // the anonymous id: the unit when u = "b"
userId?: string // the unit when u = "p"
properties?: Record<string, Json>
segments?: { in: string[]; unavailable: string[] } | 'pending' | 'unavailable'
}
type Decision = {
variant: string
reason: 'STATIC' | 'TARGETING_MATCH' | 'SPLIT' | 'DEFAULT' | 'DISABLED'
rule?: number
}
type Failure = { reason: 'ERROR'; errorCode: 'UNSUPPORTED' | 'NOT_READY' }rule is the 0-based index of the deciding rule. It is absent for DISABLED and for the final fall-through DEFAULT. A Failure means "no variant": the caller's fallback applies. The value of a decision is p[variant] when the flag has one.
Evaluation order
LEVEL = 1
evaluate(flag, facts):
if (flag.need ?? 1) > LEVEL, or a rule has "x" together with "sh" or "w": → ERROR / UNSUPPORTED
if flag.off: → { d, DISABLED }
unit = usable(flag.u == "p" ? facts.userId : facts.id)
for i, rule in flag.r:
if rule.if has an ["s", …] condition and facts.segments == "pending": → ERROR / NOT_READY
if rule.if is present and not every condition holds: continue
if rule has "x": → { x, rule.if present ? TARGETING_MATCH : STATIC, i }
if no unit, or bucket(s, ".r", unit) >= (rule.sh ?? 10000): → { d, DEFAULT, i }
b = bucket(s, ".v", unit)
for [variant, weight] in rule.w ?? []: b -= weight; if b < 0: → { variant, SPLIT, i }
→ { d, DEFAULT, i }
→ { d, DEFAULT }- An empty
if: []counts as present:{ "if": [], "x": "on" }isTARGETING_MATCH. - A unit outside a rule's share gets the default and never falls through to later rules.
NOT_READYis returned only when a rule with a segment condition is actually reached.
Usable ids
usable(id) returns the id unchanged, or "no id":
- Not a string: no id.
- Longer than 256 UTF-16 code units (JavaScript's
.length): no id. Never truncate. - Make
bare: lower-case ASCIIA–Zonly, then trim space, tab, line feed, carriage return,"and'from both ends. Whenbareis empty or one ofundefined,null,none,nan,0,true,false,anonymous,guest,id,email,distinct_id,distinctid,not_authenticated,[object object]: no id.
Hash the original id, never bare.
Conditions
A property is missing when its key is absent (own keys only: toString is missing) or its value is null. Scalars are strings, numbers and booleans.
same(a, b), for a property value or list element a and a condition value b: two strings are the same when their code units are identical; two numbers when numerically equal; two booleans when equal; a string and a number when the string matches ^-?(0|[1-9][0-9]*)(\.[0-9]+)?$ and parses to the number. Anything else is not the same.
The value of is, not, has, nhas and pre is a list (possibly empty); a value that is not a list makes the condition false. The value of gt and lt is a number; anything else makes it false.
| Op | Holds when |
|---|---|
is | the property is a scalar that is same as a listed value, or a list with a scalar element that is |
not | the property is a scalar or a list and is does not hold (an empty list property: true) |
has | the property is a string containing a listed string (non-strings in the list are ignored) |
nhas | the property is a string containing none of the listed strings |
pre | the property is a string starting with a listed string |
gt, lt | the property is a number (never a boolean or a numeric string) strictly greater or less |
set | the property is not missing |
unset | the property is missing |
Every operator except unset is false on a missing property, and objects only satisfy set. String comparisons are case-sensitive; "" is contained in and a prefix of every string.
A segment condition ["s", ref] holds when facts.segments is an object, ref is not in unavailable, and ref is in in. With a third element 1, it holds when ref is not in in, under the same two preconditions. When segments are absent or "unavailable", or the ref is unavailable, both forms are false.
Hashing and buckets
fnv1a32(bytes) = h ← 0x811c9dc5; for each byte: h ← (h XOR byte) × 0x01000193 mod 2³²
bucket(seed, salt, u) = fnv1a32( ASCII( decimal( fnv1a32( UTF-8(seed ‖ salt ‖ u) ) ) ) ) mod 10000
salt: ".r" for the share, ".v" for the variantThe inner hash is written as a decimal number and hashed again. Two salts keep share and variant independent: raising a share moves nobody between variants.
const encoder = new TextEncoder()
export function fnv1a32(text: string): number {
let hash = 0x811c9dc5
for (const byte of encoder.encode(text)) {
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0
}
return hash
}
export function bucket(seed: string, salt: '.r' | '.v', unit: string): number {
return fnv1a32(String(fnv1a32(seed + salt + unit))) % 10000
}Check your implementation against these values:
| Input | Result |
|---|---|
fnv1a32("abc") | 440920331 |
fnv1a32("müller") | 1392138076 |
fnv1a32("user-42") | 39875499 |
bucket("3f9a1c0b7e2d", ".r", "user-42") | 6137 (inner hash 4032525878, outer 3809856137) |
bucket("3f9a1c0b7e2d", ".v", "user-42") | 7627 (inner hash 2193917514, outer 1149427627) |
A worked example: flag pricing-test above has seed 3f9a1c0b7e2d, default a and one rule { "w": [["a", 5000], ["b", 5000]] }. For the unit user-42, the share bucket is 6137, below the default share of 10,000, so the unit is in. The variant bucket is 7627: minus 5000 for a leaves 2627, not below 0; minus 5000 for b leaves −2373, below 0. The answer is { variant: "b", reason: "SPLIT", rule: 0 }. With "sh": 5000 on the rule, 6137 is not below 5000, and the answer would be { variant: "a", reason: "DEFAULT", rule: 0 }.
Units and consent
u: "b"flags use the anonymous id: in a browser the stored id, and only with the visitor'sexperimentsconsent; on a server the id the page passed (the part before any.).u: "p"flags use the user id the caller identified.- Under Do Not Track, Global Privacy Control or prerendering (on a server: the caller's opt-out) there is no unit, no segment lookup and no exposure.
- Without
targetingconsent, segments are"unavailable". While a browser lookup is in flight they are"pending"; after 800 ms without an answer they become"unavailable". - A server reading an experiment counted in the browser (
c: "b") answers the default; only a bootstrap block hands the decision to the page. A server evaluates an experiment with the anonymous id only when the visitor's experiments consent is notfalse.
A browser client evaluates with four page facts it never sends: $utm_source, $utm_medium and $utm_campaign from the page URL, and $referrer_host, the referrer's hostname, each null when absent. Traits from identify() and properties the caller sets are laid over them. Servers have no page facts.
Bootstrap block
A server that renders a page can hand its flag answers to the browser client, so the page draws the right variant before any request:
<script type="application/json" id="mirafive-flags">{"v":1,"at":1727430000000,"values":{"new-checkout":["on"],"limits":["pro",{"max":3}],"pricing-test":["b",null,1]},"browser":["hero-copy"],"unit":"39875499"}</script>type Bootstrap = {
v: 1
at: number // when the server's document was last confirmed, epoch ms
values: Record<string, [variant: string, value?: Json, expose?: 1]>
browser?: string[] // keys the browser must decide itself
unit?: string // String(fnv1a32(userId)) the values were computed for
}- Only flags with
w: 1may appear, so a value meant for servers never reaches a page. Overrides are included as answered; a flag whose evaluation fails is left out. u: "b"flags whose no-id evaluation reaches a split rule (DEFAULTwith arule) are listed inbrowserinstead: the server never has the browser's anonymous id.- A third element
1marks an experiment counted in the browser (c: "b") that the server decided bySPLIT. The browser sends its exposure when the flag is read, and only ifunitequalsString(fnv1a32(userId))of its own identified user. The value slot isnullwhen the variant has no value. - Encode with
JSON.stringify(bootstrap), then replace every<,>,&, U+2028 and U+2029 with\u003c,\u003e,\u0026,\u2028and\u2029(\uand four lower-case hex digits). Escape nothing else. - Send
Cache-Control: private, no-storewith every response that carries a block. - The browser reads the block once at start. It ignores a block older than 7 days, and fetches the document at once when the block is older than 60 seconds or
browseris not empty.
Exposures
An exposure records that a unit saw an experiment's variant. It is an $exposure event in a full-mode batch (Send events), with the unit's anonymousId, userId or both:
| Property | Value |
|---|---|
$experiment | the flag key |
$variant | the variant shown |
$boot | 1 when the consent answer was known when the page first drew, else 0 (browser only) |
$snippet | page experiments only: the hash of the head snippet that drew it |
Send an exposure only when all of these hold:
- the flag has
eand the decision's reason isSPLIT; - it is read where it is counted:
c: "b"flags and page experiments in the browser,c: "s"flags on the server; - the code used the flag's value (a variant, a config value, an on/off check, or a bootstrap's marked value), not a debug evaluation, a change listener, an override or a preview;
- in a browser, the
experimentsconsent is granted (hold earlier exposures until then; drop them on a decline), and evaluating again under the id it is counted under still gives the same variant.
On a server, generating a bootstrap block for a unit counts as reading every marked c: "s" experiment in it: the page gets the value, and the browser never counts c: "s".
Deduplicate: a browser sends at most one exposure per flag per page load; a server at most one per flag, variant and unit per hour, across requests and processes. Keep the marks in a shared cache when you have one. See Experiments.