MIRA FIVE

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:

ViewServed toContent
browserfull website sourcesthe definitions of the flags the website reads
valuesevery website sourcethe answer each of those flags gives with no facts at all
serverserver sourcesthe 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
200 OK
{
  "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 parameterDescription
view=valuesReturns the values document instead of the browser document.
mirafive-preview-tokenForward 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
200 OK
{ "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}'
200 OK
{
  "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 fieldTypeDescription
anonymousIdstringRequired for membership. The browser's anonymous id, the UUID stored by the SDK.
identifiedbooleanWhether 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"
200 OK
{
  "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"}]}'
200 OK
{
  "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

FieldTypeDescription
vinteger1. Treat any other version as unreadable and keep what you had.
atintegerWhen the document was compiled, epoch milliseconds.
flagsobjectBrowser and server views: flag key → flag.
valuesobjectValues 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.
origstring[]Browser and values views, optional: keys of page experiments that now show everyone the original. Answer a for them and send no exposure.

Flag

FieldTypeDescription
sstringSeed for hashing, ^[0-9a-z]{12}$. Fixed once the flag has split anyone.
tstringType: "b" on/off (variants on and off), "m" variants, "c" remote config.
ustringUnit: "b" the browser (anonymous id) or "p" the signed-in person (user id).
dstringDefault variant.
pobjectOptional. Variant → JSON value (remote config).
rarrayRules, first match wins. [] when the flag is off.
off1Optional. The flag is turned off: the answer is d with reason DISABLED.
estringOptional. The flag is a counting experiment. Before the consent banner is answered, a browser shows a random variant ("r") or the default ("o").
cstringOptional. Where the experiment is counted: "b" browser, "s" server. See Exposures.
needintegerOptional, default 1. The evaluator feature level the flag needs.
w1Optional, 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 fieldDescription
ifConditions that must all hold. Absent means the rule applies to everyone.
xThe variant this rule answers.
shShare of units the rule reaches, in basis points (0 to 10,000). Default 10,000, everyone.
wVariants 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

FieldTypeDescription
segmentsstring[]Segment refs the unit is in.
unavailablestring[]Refs that cannot be answered. Their conditions are false, for "in" and "not in" alike.
refreshedAtinteger or nullWhen the oldest answered segment was last built, epoch milliseconds.
stalebooleantrue 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": "…" }.

StatusCodeMeaning
401unauthorizedUnknown, revoked or archived key.
403secret_key_in_pathA secret key in a browser path. When a browser sent it, the key is marked exposed.
403website_key_as_bearerA website key sent as a bearer.
403secret_key_exposedA secret key arrived with an Origin or Sec-Fetch-Site header. Rotate it.
403origin_not_allowedA browser request from an origin the source does not allow.
403lookup_not_allowedPOST /v1/flags/{websiteKey} on a consentless source.
404not_foundFeature flags are not available for this key's organization.
422invalid_units, too_many_unitsSee Look up segments for server units.
429rate_limitedWait 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" } is TARGETING_MATCH.
  • A unit outside a rule's share gets the default and never falls through to later rules.
  • NOT_READY is returned only when a rule with a segment condition is actually reached.

Usable ids

usable(id) returns the id unchanged, or "no id":

  1. Not a string: no id.
  2. Longer than 256 UTF-16 code units (JavaScript's .length): no id. Never truncate.
  3. Make bare: lower-case ASCII A–Z only, then trim space, tab, line feed, carriage return, " and ' from both ends. When bare is empty or one of undefined, 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.

OpHolds when
isthe property is a scalar that is same as a listed value, or a list with a scalar element that is
notthe property is a scalar or a list and is does not hold (an empty list property: true)
hasthe property is a string containing a listed string (non-strings in the list are ignored)
nhasthe property is a string containing none of the listed strings
prethe property is a string starting with a listed string
gt, ltthe property is a number (never a boolean or a numeric string) strictly greater or less
setthe property is not missing
unsetthe 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 variant

The 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.

hash.ts
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:

InputResult
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 }.

  • u: "b" flags use the anonymous id: in a browser the stored id, and only with the visitor's experiments consent; 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 targeting consent, 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 not false.

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: 1 may 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 (DEFAULT with a rule) are listed in browser instead: the server never has the browser's anonymous id.
  • A third element 1 marks an experiment counted in the browser (c: "b") that the server decided by SPLIT. The browser sends its exposure when the flag is read, and only if unit equals String(fnv1a32(userId)) of its own identified user. The value slot is null when the variant has no value.
  • Encode with JSON.stringify(bootstrap), then replace every <, >, &, U+2028 and U+2029 with \u003c, \u003e, \u0026, \u2028 and \u2029 (\u and four lower-case hex digits). Escape nothing else.
  • Send Cache-Control: private, no-store with 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 browser is 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:

PropertyValue
$experimentthe flag key
$variantthe variant shown
$boot1 when the consent answer was known when the page first drew, else 0 (browser only)
$snippetpage experiments only: the hash of the head snippet that drew it

Send an exposure only when all of these hold:

  • the flag has e and the decision's reason is SPLIT;
  • 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 experiments consent 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.

On this page