# Errors and retries

Source: https://docs.mirafive.io/ingest-api/errors

> Every status and error code of the MIRA FIVE ingest API, when to retry, how batch ids make retries safe, and the rate limits.

The ingest API answers errors as JSON with a machine-readable `code`. Decide what to do by the status: retry `408`, `429`, `5xx` and network failures with the byte-identical body, fix everything else. A `202` is final, even when it reports dropped events.

## Error body

```json title="400 Bad Request"
{
  "code": "validation_failed",
  "detail": "The batch does not match the event schema.",
  "errors": [{ "path": "events.0.name", "message": "The events.0.name field must be a name without surrounding whitespace." }]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `code` | string | What went wrong, `^[a-z][a-z0-9_]*$`. See [Error codes](#error-codes). |
| `detail` | string | A sentence for humans. Do not parse it. |
| `errors` | array | `validation_failed` only: up to 10 `{ "path", "message" }` objects. `path` names the field (`mode`, `events.3.id`, `context.screen.0`), never its value. |

Some answers are produced before a request reaches the API and carry `{ "message": "…" }` instead of a `code`: a path that does not exist or whose key does not look like a key (`404`), a body the web server refuses as too large (`413`), and maintenance (`503`, with `Retry-After`). Every answer, errors included, carries the CORS headers, so a browser can read it.

## Status codes

| Status | Meaning | Retry |
| --- | --- | --- |
| `200` | Flag document or lookup answered. | — |
| `202` | Batch finished. See [the receipt](https://docs.mirafive.io/ingest-api/send-events#the-receipt). | Never |
| `304` | Server flag document unchanged since your `ETag`. | — |
| `400` | The body is not JSON, or not a valid batch. | No |
| `401` | The key is missing, unknown, revoked or archived. | No |
| `403` | The key is in the wrong place, exposed, or used from an origin the source does not allow. | No |
| `404` | Unknown path, or feature flags are not available for the key. | No |
| `408` | Request timeout (from a proxy). | Yes |
| `413` | The body is over 1 MiB. | No: split the batch |
| `422` | A segment lookup body the server cannot read. | No |
| `429` | Rate limited. | Yes, after `Retry-After` |
| `5xx` | The server could not finish, e.g. `503 sink_unavailable`. | Yes |
| no answer | Network error or timeout. | Yes |

## Error codes

| Code | Status | Meaning | Fix | Retry |
| --- | --- | --- | --- | --- |
| `invalid_json` | 400 | The body does not parse as JSON. `detail` says why, e.g. `Single unpaired UTF-16 surrogate in unicode escape`. | Encode well-formed UTF-8. Do not cut strings in the middle of a surrogate pair. | No |
| `validation_failed` | 400 | The batch breaks a rule of [Send events](https://docs.mirafive.io/ingest-api/send-events#the-batch). `errors` names the fields. | Fix or drop the events named in `errors`, then send the rest as a new batch. | No |
| `collection_mode_not_allowed` | 400 | A `"full"` batch sent to a consentless source. | Send `"mode": "consentless"` without identifiers, or switch the source to full mode. | No |
| `unauthorized` | 401 | Missing, unknown, revoked or archived key, or a key of an archived source or project. | Check the key on [Keys](https://docs.mirafive.io/keys). | No |
| `website_key_as_bearer` | 403 | A website key in the `Authorization` header. | Put the website key in the path, or use the secret key of a server source. | No |
| `secret_key_in_path` | 403 | A secret key in a URL path. Marked exposed when a browser sent it. | Send the secret key as `Authorization: Bearer`. | No |
| `secret_key_exposed` | 403 | Flag endpoints only: a secret key arrived with an `Origin` or `Sec-Fetch-Site` header. | Rotate the key and keep it on the server. See [Exposed secret keys](#exposed-secret-keys). | No |
| `origin_not_allowed` | 403 | The `Origin` header is not one of the source's allowed origins. | Add the site's origin to the source. | No |
| `lookup_not_allowed` | 403 | `POST /v1/flags/{websiteKey}` on a consentless source. | Use `GET /v1/flags/{websiteKey}`. | No |
| `not_found` | 404 | Feature flags are not available for this key's organization. | — | No |
| `payload_too_large` | 413 | The body is over 1,048,576 bytes. | Split the events into smaller batches, each with its own batch id. | No |
| `invalid_units` | 422 | `units` is not a list, or a unit has no string `userId` or `anonymousId`. | Send `{ "units": [{ "userId": "…" }] }`. | No |
| `too_many_units` | 422 | More than 100 units in one segment lookup. | Split the lookup. | No |
| `rate_limited` | 429 | A rate limit is spent. | Wait `Retry-After` seconds. | Yes |
| `sink_unavailable` | 503 | The batch could not be stored completely. | Resend it unchanged; the batch id keeps it from being stored twice. | Yes |

Error codes may be added within v1. Treat an unknown code by its status.

## Retry policy

Retry on `408`, `429`, any `5xx`, network errors and timeouts. Do not retry any other `4xx`, and never retry a `202`.

- **Resend the byte-identical body**, with the same batch id. Serialise the batch once and keep the bytes.
- **Back off with full jitter**: before retry `n` (0-based), wait a random time between 0 and `min(cap, base × 2ⁿ)`. The Node SDK uses a base of 250 ms and a cap of 4 seconds.
- **Honour `Retry-After`** when a `429` or `503` carries it: wait that many seconds instead of the backoff. Bound the wait to what your caller can afford.
- **Give up** after a few attempts and report the failure. A batch that was never answered with `202` may or may not have been stored; resending it later under the same id is safe.

A browser that flushes on page hide with `sendBeacon` cannot read the answer. That is accepted: it cannot retry either.

## Idempotency

The batch id is the idempotency key. The server stores a batch id once per project: sending the same `batch` again within a day is counted and stored once, and answered with the same receipt. The server keys on the id, not the bytes, so a different body under a used id is not stored. Always give a new batch a new id, and a retry the old one.

A client that accepts its own idempotency key from the caller (an order number, a webhook event id) derives the batch id from it, so every SDK agrees on the id:

```text
batch = UUIDv8( SHA-256( "mirafive:batch:" ‖ key ) )
```

1. Encode `"mirafive:batch:"` followed by the key as UTF-8. The key must not be empty.
2. Hash with SHA-256 and take the first 16 bytes.
3. Set the version: `bytes[6] = bytes[6] & 0x0f | 0x80`.
4. Set the variant: `bytes[8] = bytes[8] & 0x3f | 0x80`.
5. Format as a lower-case UUID, `8-4-4-4-12` hex digits.

Worked example for the key `order-981`:

| Step | Value |
| --- | --- |
| SHA-256 of `mirafive:batch:order-981` | `274e05f04dd71db9656387ea5c891487503b9df472193d0666ced68d06a20404` |
| first 16 bytes | `27 4e 05 f0 4d d7 1d b9 65 63 87 ea 5c 89 14 87` |
| `bytes[6]`: `0x1d & 0x0f \| 0x80` | `0x8d` |
| `bytes[8]`: `0x65 & 0x3f \| 0x80` | `0xa5` |
| batch id | `274e05f0-4dd7-8db9-a563-87ea5c891487` |

```python title="batch_id.py"
import hashlib
import uuid

def batch_id(key: str) -> str:
    if key == "":
        raise ValueError("the idempotency key must not be empty")
    b = bytearray(hashlib.sha256(("mirafive:batch:" + key).encode("utf-8")).digest()[:16])
    b[6] = b[6] & 0x0F | 0x80
    b[8] = b[8] & 0x3F | 0x80
    return str(uuid.UUID(bytes=bytes(b)))

print(batch_id("order-981"))  # 274e05f0-4dd7-8db9-a563-87ea5c891487
```

More test vectors from the protocol fixtures (`batch-id.cases.json`):

| Key | Batch id |
| --- | --- |
| `a` | `08121d76-bf7c-8abc-b3f2-b4d829d528e8` |
| `Order-981` | `33054841-d9c7-8916-8b1c-a93ea2bc8d83` |
| `order-981 ` (trailing space) | `98885f1a-aa10-84bb-8e3a-b5fc0ef59b10` |
| `stripe:evt_1PqL4mK2eZvKYlo2C0nX3Yz` | `659d3cac-a353-8d7a-bedd-c1cbe51e4089` |
| `müller` | `014e1e19-76ed-83d4-9c73-9241eb5b2e2c` |
| `🚀 launch` | `dd5a0288-6ddc-80cb-9e1c-06e197e55ff9` |

Keys are used exactly as given: case, whitespace and Unicode normalisation all change the id.

## Rate limits

Limits count per client address (an IPv4 address or an IPv6 /64) and per key. Website keys are public, so their budgets count per key and client network: a copied key only drains its own bucket. The current limits:

| Endpoint | Counted | Limit |
| --- | --- | --- |
| `POST /v1/batch…`, before the key is checked | requests per address | 100 per second |
| `POST /v1/batch/{websiteKey}` | events per key and client network | 6,000 per minute |
| `POST /v1/batch` | events per key | 120,000 per minute |
| `/v1/flags…`, before the key is checked | requests per address | 200 per second |
| `GET /v1/flags/{websiteKey}` | requests per key and client network | 100 per second |
| `POST /v1/flags/{websiteKey}` | requests per key and client network | 20 per second |
| `GET /v1/flags` | requests per key | 6,000 per minute |
| `POST /v1/flags/segments` | units per key | 6,000 per minute |

Batches are charged per event; a batch the server cannot read costs one. A limit refuses only once its bucket is spent, so a batch larger than the remaining budget still passes once. Over a limit, the answer is `429 rate_limited` with `Retry-After` in seconds. Limits can change: rely on `429` and `Retry-After`, not on these numbers.

## Exposed secret keys

A secret key is exposed once it has been seen from a browser: any request carrying an `Origin` or `Sec-Fetch-Site` header, which browsers add and server runtimes and curl do not. That also happens when the key is sent in a URL path from a browser.

| Endpoint | Exposed key |
| --- | --- |
| `POST /v1/batch` | Still accepted, so a leak shows up in MIRA FIVE rather than as lost data. |
| `GET /v1/flags`, `POST /v1/flags/segments` | Refused with `403 secret_key_exposed`. |

> **Warning:** An exposed key stays exposed. Create a new secret key for the server source, deploy it, and revoke the old one. See [Keys](https://docs.mirafive.io/keys).

Then find where the key reached a browser: a public env var prefix (`NEXT_PUBLIC_`, `VITE_`, `PUBLIC_`, `NUXT_PUBLIC_`), a client bundle, or a server call made from a page.
