# Overview

Source: https://docs.mirafive.io/rest-api

> Read MIRA FIVE projects, reports, people and events from a script with the read-only REST API and an API key.

The REST API serves the numbers the MIRA FIVE dashboard shows: projects, the overview, people, raw events, goals, funnels and acquisition. Use it from scripts and servers, for example a nightly export or an internal report. It only reads. To let an AI agent read and set things up, use the [MCP server](https://docs.mirafive.io/mcp) instead.

## Base URL

```text
https://app.mirafive.io/api/v1
```

Every endpoint answers `GET` and `HEAD`. Any other method gets `405 Method Not Allowed` with `Allow: GET, HEAD`. Unknown query parameters are ignored.

## Authenticate

Send a credential as a bearer token:

```text
Authorization: Bearer mf_pat_…
```

Two kinds work:

| Credential | Looks like | Lifetime |
| --- | --- | --- |
| API key | `mf_pat_`, eight lower-case letters or digits, `_`, then 40 letters or digits | 30 days, 90 days, 1 year or never, chosen when you create it |
| OAuth access token | issued to an app you approved (see [MCP server](https://docs.mirafive.io/mcp#authentication)) | 1 hour, renewed with a 30-day refresh token |

Create an API key as described in [Keys](https://docs.mirafive.io/keys#create-an-api-key). The examples on these pages read it from `MIRAFIVE_API_KEY`.

A credential acts as its owner. It sees every project in every organization you belong to, and loses a project the moment you leave its organization. The REST API is read-only whatever the key's switches allow.

> **Warning:** An API key reads everything you can read, people included. Keep it on the server, in an environment variable. Never ship it to a browser or commit it.

## Make a first request

```bash
curl https://app.mirafive.io/api/v1/projects \
  -H "Authorization: Bearer $MIRAFIVE_API_KEY"
```

```json title="200 OK"
{
  "data": [
    {
      "id": "01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f",
      "type": "projects",
      "attributes": {
        "name": "Nordlicht Shop",
        "slug": "nordlicht-shop",
        "timezone": "Europe/Berlin",
        "reportingCurrency": "EUR",
        "archived": false,
        "createdAt": "2025-11-04T09:12:44.318+01:00",
        "organization": {
          "id": "01932c4d-1f2e-7a3b-8c4d-5e6f7a8b9c0d",
          "slug": "nordlicht",
          "name": "Nordlicht GmbH"
        },
        "plan": "pro",
        "retentionDays": 366
      },
      "links": {
        "self": "https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f"
      }
    }
  ],
  "links": {
    "self": "https://app.mirafive.io/api/v1/projects",
    "next": null
  },
  "meta": {
    "perPage": 50,
    "nextCursor": null
  }
}
```

Every other endpoint sits under a project: `/api/v1/projects/{project_id}/…`. The `project_id` is the `id` from this list, a UUID. A slug gets `404`.

## Response format

Successful answers are [JSON:API](https://jsonapi.org) documents with `Content-Type: application/vnd.api+json`, whatever `Accept` header you send. URLs and names are written unescaped (no `\/`).

- `data` holds one resource object, or a list of them. Each has `id`, `type`, `attributes` and, where it has an address, `links.self`.
- `meta` holds what the answer is about: the period it read, the page size, totals.
- Lists that page have top-level `links` (`self`, `next`). See [Pagination](#pagination).
- There are no relationships and no `include`.

### Sparse fieldsets

Ask for only some attributes with `fields[<type>]`, a comma-separated list:

```bash
curl -G https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f \
  -H "Authorization: Bearer $MIRAFIVE_API_KEY" \
  --data-urlencode 'fields[projects]=name,timezone'
```

```json title="200 OK"
{
  "data": {
    "id": "01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f",
    "type": "projects",
    "attributes": { "name": "Nordlicht Shop", "timezone": "Europe/Berlin" },
    "links": { "self": "https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f" }
  }
}
```

### Timestamps

Moments (`createdAt`, `firstSeen`, `occurredAt`, …) are ISO 8601 with milliseconds, in the project's timezone with its offset: `2026-09-23T13:55:00.000+02:00`. The bounds in `meta.period` and the start of each trend bucket (`t`) are ISO 8601 without milliseconds: `2026-09-28T00:00:00+02:00`.

### Money and ratios

Amounts stay per currency and are never added across currencies: `[{ "currency": "EUR", "amount": 49 }, { "currency": "CHF", "amount": 25 }]`. Ratios such as `rate`, `conversion`, `coverage` and `ctr` are between 0 and 1.

## Periods

Reports read a period. Pass a preset, or `period=custom` with two days.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `period` | string | `30d` | `1h`, `24h`, `7d`, `30d`, `90d`, `12m` or `custom`. The [events](https://docs.mirafive.io/rest-api/events) endpoint takes only `1h`, `24h` and `7d`, and defaults to `24h`. |
| `from` | string | none | First day, `YYYY-MM-DD`, in the project's timezone. **Required** with `period=custom`, refused otherwise. |
| `to` | string | none | Last day, inclusive, `YYYY-MM-DD`. **Required** with `period=custom`, refused otherwise. |

A preset ends with the current minute, hour, day or month and includes it: `7d` on 27 September is 21 to 27 September, today included. Days and months are cut in the project's timezone.

Rules for a custom range:

- `to` must be on or after `from`.
- `from` after today (in the project's timezone) is refused. A `to` after today reads up to today.
- The plan keeps a limited history (`retentionDays` on the [project](https://docs.mirafive.io/rest-api/projects)). A range that starts before it starts at the first day the plan keeps instead, and `meta.period.clampedToRetention` is `true`. A range that ends before it is refused, for example with `to is before 2025-09-26, the first day the plan keeps.`

Trends are bucketed by the period:

| Period | Bucket (`interval`) |
| --- | --- |
| `1h` | `minute` |
| `24h` | `hour` |
| `7d`, `30d`, `90d` | `day` |
| `12m` | `month` |
| `custom` | `day` up to 120 days, `week` beyond |

Every report says which window it read in `meta.period`:

```json
"period": {
  "preset": "30d",
  "from": "2026-08-29T00:00:00+02:00",
  "to": "2026-09-28T00:00:00+02:00",
  "timezone": "Europe/Berlin",
  "interval": "day",
  "comparison": null,
  "clampedToRetention": false
}
```

`to` is exclusive. `comparison` holds the `from` and `to` of the previous window when the answer compares, else `null`.

## Compare with the previous period

`compare=previous` adds the window before it, of the same length. Only three endpoints take it: the [overview](https://docs.mirafive.io/rest-api/overview), a [goal report](https://docs.mirafive.io/rest-api/goals#get-a-goal-report) and [search](https://docs.mirafive.io/rest-api/acquisition#get-the-search-summary). Everywhere else it is refused with `This endpoint does not compare periods. Leave out compare.`

The overview's key numbers and the search summary carry the previous values even without `compare`. A previous window that starts before the history the plan keeps, or before the project's first event, is left out: `comparison` is then `null`.

## Filters

The overview, events and channels endpoints narrow their events with filters. Each filter is three query parameters with the same index. All filters must hold (AND). At most 20.

```text
filters[0][field]=page_path&filters[0][op]=starts_with&filters[0][value]=/products
```

With curl, let `-G` and `--data-urlencode` encode the brackets:

```bash
curl -G https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f/overview \
  -H "Authorization: Bearer $MIRAFIVE_API_KEY" \
  --data-urlencode 'period=7d' \
  --data-urlencode 'filters[0][field]=channel' \
  --data-urlencode 'filters[0][op]=is' \
  --data-urlencode 'filters[0][value]=organic_search' \
  --data-urlencode 'filters[1][field]=country' \
  --data-urlencode 'filters[1][op]=is' \
  --data-urlencode 'filters[1][value]=DE'
```

`field` is at most 150 characters, `op` 20, `value` 1,000. The [People](https://docs.mirafive.io/rest-api/people#person-filters) list has its own filters on people.

### Fields

| Field | Operators | Value |
| --- | --- | --- |
| `event_name` | text | e.g. `checkout_started`, `$pageview` |
| `page_path` | text | e.g. `/products/linen-shirt` |
| `page_host` | text | host without `www.`, compared in lower case |
| `referrer_host` | text | host without `www.`, compared in lower case |
| `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | text | as tagged |
| `region` | text | |
| `channel` | `is`, `is_not` | `paid_search`, `paid_social`, `email`, `ai_assistant`, `organic_search`, `organic_social`, `campaign`, `referral`, `direct` |
| `country` | closed set | two-letter code, e.g. `DE` |
| `click_source`, `device_type`, `browser`, `os`, `locale`, `source_type`, `collection_mode` | closed set | as the events carry it |
| `properties.<key>` | all | a custom event property, e.g. `properties.plan`. The key matches `^[A-Za-z0-9_$][A-Za-z0-9_.$-]{0,127}$`. |

"text" means every operator except `gt` and `lt`. "closed set" means `is`, `is_not`, `set` and `not_set`.

`channel` is the channel of the visit the event belongs to. A visit collected without consent keeps only its first page, so under a channel filter it counts by that page. A project that collects only without consent refuses a channel filter: `This project collects without consent, so it is not filtered by channel.`

### Operators

| Operator | Matches when the field | Value |
| --- | --- | --- |
| `is` | equals the value | required |
| `is_not` | differs from the value | required |
| `contains` | contains the value, ignoring case | required |
| `not_contains` | does not contain the value, ignoring case | required |
| `starts_with` | starts with the value | required |
| `matches` | matches the pattern; `*` is the only wildcard | required |
| `set` | is not empty | none |
| `not_set` | is empty | none |
| `gt` | is a number greater than the value | a number, e.g. `49` |
| `lt` | is a number less than the value | a number |

## Pagination

Lists of projects, people and events come 50 at a time and page by cursor. There is no page number and no page size parameter.

```json
"links": {
  "self": "https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f/events",
  "next": "https://app.mirafive.io/api/v1/projects/01932c4e-8a7b-7c3d-9e2f-4b5a6c7d8e9f/events?cursor=eyJhdCI6IjIwMjYtMDktMjcgMDg6MTQ6MDIuNTEyIn0"
},
"meta": {
  "perPage": 50,
  "nextCursor": "eyJhdCI6IjIwMjYtMDktMjcgMDg6MTQ6MDIuNTEyIn0"
}
```

Follow `links.next` until it is `null`, or pass `meta.nextCursor` as `cursor` with the same other parameters. A cursor the list did not hand out gets `422` with `The cursor is not one this list handed out; follow links.next.`

## Rate limits

Each person has 120 requests per minute, shared by all their API keys and tokens. The MCP server has a budget of its own. Every answer carries the budget:

```text
RateLimit-Policy: "api";q=120;w=60
RateLimit: "api";r=117;t=42
```

`r` is the requests left, `t` the seconds until the window resets. Past the limit the answer is `429` with `Retry-After` and `The limit is 120 requests per minute. Try again in 42 seconds.`

Refused tokens are limited too: 60 per minute from one network. Past that, a bad token gets `429` with `Too many refused tokens came from this network. Try again in N seconds.` A valid token from the same network still gets through.

## Browsers and CORS

The API sends no CORS headers, so a page in a browser cannot read it from another origin. Call it from a server or a script.

Every answer carries `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`.

## Audit trail

Every call is recorded under **API & MCP** in MIRA FIVE as recent access: the key or app, the endpoint, the project and how the call ended. Query parameters are never stored, since they can name people. A call with a revoked or expired key is recorded against its owner, so a leaked key shows up there.

## Endpoints

| Endpoint | Page |
| --- | --- |
| `GET /api/v1/projects` | [Projects](https://docs.mirafive.io/rest-api/projects#list-projects) |
| `GET /api/v1/projects/{project_id}` | [Projects](https://docs.mirafive.io/rest-api/projects#get-a-project) |
| `GET /api/v1/projects/{project_id}/overview` | [Overview report](https://docs.mirafive.io/rest-api/overview) |
| `GET /api/v1/projects/{project_id}/people` | [People](https://docs.mirafive.io/rest-api/people#list-people) |
| `GET /api/v1/projects/{project_id}/people/{ref}` | [People](https://docs.mirafive.io/rest-api/people#get-a-person) |
| `GET /api/v1/projects/{project_id}/events` | [Events](https://docs.mirafive.io/rest-api/events) |
| `GET /api/v1/projects/{project_id}/goals` | [Goals](https://docs.mirafive.io/rest-api/goals#list-goals) |
| `GET /api/v1/projects/{project_id}/goals/{goal_id}` | [Goals](https://docs.mirafive.io/rest-api/goals#get-a-goal-report) |
| `GET /api/v1/projects/{project_id}/funnel` | [Funnel](https://docs.mirafive.io/rest-api/funnel) |
| `GET /api/v1/projects/{project_id}/acquisition/channels` | [Acquisition](https://docs.mirafive.io/rest-api/acquisition#list-channels) |
| `GET /api/v1/projects/{project_id}/acquisition/search` | [Acquisition](https://docs.mirafive.io/rest-api/acquisition#get-the-search-summary) |
| `GET /api/v1/projects/{project_id}/acquisition/ads` | [Acquisition](https://docs.mirafive.io/rest-api/acquisition#get-the-ads-summary) |

There is no OpenAPI document. Errors are listed in [Errors](https://docs.mirafive.io/rest-api/errors).
