# Feature flags

Source: https://docs.mirafive.io/ingest-api/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](https://docs.mirafive.io/guides/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:

```bash
curl -i https://events.mirafive.io/v1/flags/$MIRAFIVE_WEBSITE_KEY
```

```json title="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 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:

```bash
curl https://events.mirafive.io/v1/flags/$MIRAFIVE_WEBSITE_KEY?view=values
```

```json title="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](https://docs.mirafive.io/ingest-api#website-key-in-the-path) 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:

```bash
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}'
```

```json title="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 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](#usable-ids), 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:

```bash
curl -i https://events.mirafive.io/v1/flags \
  -H "Authorization: Bearer $MIRAFIVE_SECRET_KEY"
```

```json title="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:

```bash
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:

```bash
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"}]}'
```

```json title="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`](#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](#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](#rules-and-conditions), 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](#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

```ts
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](https://docs.mirafive.io/ingest-api/errors): `{ "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](#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

```ts
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

```text
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.

| 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

```text
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.

```ts title="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:

| 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'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:

```html
<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>
```

```ts
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](https://docs.mirafive.io/ingest-api/send-events#reserved-event-names)), 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 `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](https://docs.mirafive.io/guides/experiments).
