# Send events

Source: https://docs.mirafive.io/ingest-api/send-events

> Send a batch of events to MIRA FIVE with one HTTP request, from a server with the secret key or from a browser with the website key.

Events travel in batches: one JSON object with up to 1,000 events, sent with `POST /v1/batch` from a server (secret key as a bearer) or `POST /v1/batch/{websiteKey}` from a browser (website key in the path). Both take the same body and answer `202` with a receipt. Keys and CORS are on the [Overview](https://docs.mirafive.io/ingest-api).

## Send from a server

Put the secret key of a server source in `MIRAFIVE_SECRET_KEY` and send the batch as JSON:

```bash
curl -i https://events.mirafive.io/v1/batch \
  -H "Authorization: Bearer $MIRAFIVE_SECRET_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<EOF
{
  "v": 1,
  "batch": "$(uuidgen)",
  "mode": "full",
  "context": { "sdk": "acme-importer/1.0.0" },
  "events": [
    {
      "name": "signup",
      "time": $(date +%s)000,
      "userId": "u_42",
      "properties": { "plan": "pro" }
    }
  ]
}
EOF
```

```json title="202 Accepted"
{ "batch": "3b1f0e2a-8c4d-4e7f-9a6b-5c2d1e0f9a8b", "accepted": 1, "dropped": 0 }
```

The shell fills in a new batch id and the current time in epoch milliseconds. Write a `$` in an event name as `\$` inside this heredoc (`"\$identify"`), or the shell expands it.

## Send from a browser

Put the website key of a website source in the path and send the body as `text/plain`, so the browser sends no preflight:

```bash
curl -i https://events.mirafive.io/v1/batch/$MIRAFIVE_WEBSITE_KEY \
  -H 'Content-Type: text/plain;charset=UTF-8' \
  -H 'Origin: https://shop.example' \
  -d '{"v":1,"batch":"0192d4a8-7b1c-4e8a-9c1d-2b3e4f5a6b7c","mode":"consentless","events":[{"name":"$pageview","page":{"url":"https://shop.example/pricing","title":"Pricing"}}]}'
```

```json title="202 Accepted"
{ "batch": "0192d4a8-7b1c-4e8a-9c1d-2b3e4f5a6b7c", "accepted": 1, "dropped": 0 }
```

`https://shop.example` stands for one of the source's allowed origins. In a page, `navigator.sendBeacon()` with a string body sends exactly this request, and keeps working while the tab closes:

```ts title="beacon.ts"
const batch = {
  v: 1,
  batch: crypto.randomUUID(),
  mode: 'consentless',
  sentAt: Date.now(),
  events: [
    {
      name: '$pageview',
      time: Date.now(),
      page: { url: location.origin + location.pathname, title: document.title, referrer: document.referrer },
    },
  ],
}

navigator.sendBeacon('https://events.mirafive.io/v1/batch/mf_…', JSON.stringify(batch))
```

A client you write for browsers sends nothing when `navigator.doNotTrack` is `"1"`, `navigator.globalPrivacyControl` is `true`, the page is prerendering, or the host is `localhost`, `127.*`, `[::1]`, `*.local` or a `file:` URL. The [browser SDK](https://docs.mirafive.io/sdks/browser) does all of this; use it where you can.

## The batch

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `v` | integer | — | **Required.** The protocol version, `1`. |
| `batch` | string | — | **Required.** A UUID (any version), new for every batch and kept for its retries. It is the idempotency key: see [Idempotency](https://docs.mirafive.io/ingest-api/errors#idempotency). |
| `mode` | string | — | **Required.** `"consentless"` or `"full"`. See [Collection modes](#collection-modes). |
| `sentAt` | integer | — | Epoch milliseconds when the request left the device. Stored with every event, and used as the time of events without `time`. |
| `context` | object | — | Shared by every event of the batch. See [Context](#context). |
| `events` | array | — | **Required.** 1 to 1,000 events. |

Integers must be JSON integers: `"1727430000000"` or `1727430000000.5` is refused. `null` in an optional field counts as absent. Fields the protocol does not define are ignored.

## Events

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | string | — | **Required.** 1 to 128 characters, no leading or trailing whitespace. A leading `$` is reserved: see [Reserved event names](#reserved-event-names). |
| `time` | integer | `sentAt`, else the time received | Epoch milliseconds when it happened. A time more than 5 minutes ahead of the server or more than 30 days behind it is replaced by the time received. |
| `id` | string | derived | A UUID, unique within the batch. Without one, the server derives a stable id from `batch`, the event's index and `time`, so a resent batch gets the same ids. |
| `page` | object | — | `url` up to 2,048, `title` up to 512, `referrer` up to 2,048 characters. See [Clean page URLs](#clean-page-urls). |
| `properties` | object | — | The event's data. See [Properties](#properties). |
| `anonymousId` | string | — | The browser or device, 1 to 256 characters, not blank. **Full mode only.** |
| `userId` | string | — | Your id for the signed-in person, 1 to 256 characters, not blank. **Full mode only.** |
| `sessionId` | string | — | A UUID for the visit. **Full mode only.** |

Lengths count Unicode code points; counting UTF-16 code units, as JavaScript does, is never more permissive. Send well-formed strings: a lone UTF-16 surrogate, for example from truncating an emoji in half, makes the whole body `400 invalid_json`.

An `anonymousId` or `userId` that is a placeholder is stored as empty, so a broken integration cannot merge every visitor into one person. Placeholders are, compared after lower-casing ASCII letters and trimming spaces, tabs, line breaks and quotes: `undefined`, `null`, `none`, `nan`, `0`, `true`, `false`, `anonymous`, `guest`, `id`, `email`, `distinct_id`, `distinctid`, `not_authenticated`, `[object object]`.

### Properties

`properties` is a JSON object. The server refuses the event's batch (`400 validation_failed`) when it:

- encodes to more than 32,768 bytes as UTF-8 JSON, counted without escaping `/` or non-ASCII characters;
- holds more than 64 leaf values (a list counts as one value, however long; an empty object counts as one);
- nests objects more than 5 levels deep;
- has a key longer than 128 characters.

Two properties have a meaning: `revenue`, a number, and `currency`, an ISO 4217 code such as `"EUR"`. Revenue without a currency is counted in the project's reporting currency. See [Track events](https://docs.mirafive.io/guides/track-events).

## Context

`context` describes the sender once for the whole batch:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `sdk` | string | — | `name/version`: name up to 64 characters without `/`, version up to 32 characters. For example `acme-ruby/0.3.0`. |
| `locale` | string | — | BCP 47 language tag, 2 to 35 characters, e.g. `de-DE`. **Full mode only.** |
| `timezone` | string | — | IANA time zone, up to 64 characters, e.g. `Europe/Berlin`. **Full mode only.** |
| `screen` | array | — | `[width, height]`, integers from 0 to 32,768. **Full mode only.** |

Device type, browser and location come from the request's `User-Agent` and address, only for website sources. Events from a server source carry no device and no location.

## Collection modes

`mode` says what the batch may carry. The concepts are in [Consent](https://docs.mirafive.io/guides/consent).

**Consentless** needs no consent banner and carries nothing that identifies a person or a device. A consentless batch MUST NOT carry:

- `anonymousId`, `userId` or `sessionId` on any event;
- `context.locale`, `context.timezone` or `context.screen`.

The server refuses the whole batch with `400 validation_failed` rather than stripping the fields, so a misconfigured client is noticed. `$identify`, `$search` and `$exposure` events in a consentless batch are dropped, and the rest is kept. A client in consentless mode also reads no language, time zone or screen size and writes nothing to the device.

**Full** is for visitors who consented, or for servers acting on consent you already hold. It may carry identifiers and device context.

The source's mode is a ceiling. A full source accepts consentless batches (for example, before the visitor answers the banner). A consentless source refuses full batches with `400 collection_mode_not_allowed`.

A request with `Sec-GPC: 1` or `DNT: 1` has its full batch stored as consentless: identifiers and ad click ids are not kept, and `$identify`, `$search` and `$exposure` are dropped.

## Reserved event names

Names starting with `$` belong to MIRA FIVE. Only these are accepted; any other `$` name makes the batch `400 validation_failed`.

| Name | Sent by | Carries |
| --- | --- | --- |
| `$pageview` | browser clients | `page`. Optional property `$boot: 1` when the page's consent answer was known when it first drew. |
| `$autocapture` | browser clients | Properties `$event_type` (`click`, `submit` or `change`), `$el_tag`, `$el_selector`, `$el_id`, `$el_classes`, `$el_text`, `$el_href`, `$el_name`, `$el_type`, `$el_attrs`. |
| `$identify` | any client, **full only** | `userId`, and the person's traits as `properties`. See [Identify users](https://docs.mirafive.io/guides/identify-users). |
| `$search` | any client, **full only** | Property `query`. The server lower-cases it, replaces emails and runs of six or more digits, and cuts it to 100 UTF-16 code units. An event without a usable `query` is dropped. |
| `$exposure` | any client, **full only** | Properties `$experiment` (`^[a-z][a-z0-9-]{1,63}$`), `$variant` (`^[a-z][a-z0-9-]{0,39}$`), optional `$boot` and `$snippet`; needs an `anonymousId` or a `userId`. An exposure missing any of these is dropped alone. See [Exposures](https://docs.mirafive.io/ingest-api/feature-flags#exposures). |
| `$install_check` | setup tools | Nothing. Proves a key and the host work; never stored or billed. |

An event named `flagProperties` is dropped: old trackers sent it by mistake.

## Clean page URLs

The server stores a page URL as scheme, host, port and path only. From the query it keeps `utm_source`, `utm_medium`, `utm_campaign`, `utm_term` and `utm_content`, and the ad click ids `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid`, `ttclid` and `li_fat_id` (the click id itself only in full mode). The referrer loses its query. A URL that is not `http` or `https` is not stored.

Clean URLs before they leave the device anyway, so nothing personal travels:

1. Keep only query parameters whose name starts with `utm_` or is `ref`, `source`, `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid`, `ttclid` or `li_fat_id`. Names compare case-sensitively; keep the kept parameters' original encoding.
2. Drop the fragment, unless the site routes by hash. Then keep it, and clean a query inside it the same way.
3. A URL that does not parse loses everything from the first `?` or `#`.

`https://shop.example/pricing?utm_source=news&email=a%40b.example#plans` becomes `https://shop.example/pricing?utm_source=news`.

## The receipt

A batch the server has finished with answers `202 Accepted`:

| Field | Type | Description |
| --- | --- | --- |
| `batch` | string | The batch id you sent. |
| `accepted` | integer | Events kept, exposures included. |
| `dropped` | integer | Events not kept. |
| `reason` | string | Present only when nothing was kept for one of the reasons below. |

| `reason` | Meaning |
| --- | --- |
| `bot` | The `User-Agent` of a website batch belongs to a bot. |
| `install_check` | The batch held only `$install_check` events. |
| `ingestion_paused` | The organization is paused. |
| `allowance_exhausted` | The plan's monthly events are used up. |

`dropped` above 0 without a `reason` means single events were dropped: person events in a consentless batch, a `$search` without a query, an invalid `$exposure`, or the part of a batch that crossed the monthly allowance (its first events are kept).

A `202` is final, whatever it says. Do not retry it. Every other status is described in [Errors and retries](https://docs.mirafive.io/ingest-api/errors).

## Complete example

A full-mode batch from a browser after consent: a pageview, a click, an identify, an experiment exposure and a purchase.

```json title="batch.json"
{
  "v": 1,
  "batch": "6c1f0c2e-8f4a-4b7e-9a3d-5e2b1c0d9f8a",
  "mode": "full",
  "sentAt": 1727430060000,
  "context": {
    "sdk": "mirafive-browser/1.0.0",
    "locale": "de-DE",
    "timezone": "Europe/Berlin",
    "screen": [1512, 982]
  },
  "events": [
    {
      "name": "$pageview",
      "time": 1727430000000,
      "page": { "url": "https://shop.example/checkout", "title": "Kasse", "referrer": "https://shop.example/pricing" },
      "properties": { "$boot": 1 },
      "anonymousId": "5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44",
      "sessionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    },
    {
      "name": "$autocapture",
      "time": 1727430012000,
      "page": { "url": "https://shop.example/checkout" },
      "properties": {
        "$event_type": "click",
        "$el_tag": "button",
        "$el_selector": "form > button.primary",
        "$el_text": "Jetzt kaufen"
      },
      "anonymousId": "5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44",
      "sessionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    },
    {
      "name": "$identify",
      "time": 1727430030000,
      "properties": { "plan": "pro" },
      "anonymousId": "5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44",
      "userId": "u_42",
      "sessionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    },
    {
      "name": "$exposure",
      "time": 1727430000500,
      "properties": { "$experiment": "hero", "$variant": "b", "$boot": 1, "$snippet": "fa1adf55" },
      "anonymousId": "5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44",
      "userId": "u_42",
      "sessionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    },
    {
      "name": "order completed",
      "time": 1727430055000,
      "page": { "url": "https://shop.example/thanks" },
      "properties": { "revenue": 49.9, "currency": "EUR", "items": [{ "sku": "tee-black", "quantity": 2 }] },
      "anonymousId": "5f0c1c8e-3e0e-4a57-9d59-3f7f2a6d1e44",
      "userId": "u_42",
      "sessionId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"
    }
  ]
}
```

```json title="202 Accepted"
{ "batch": "6c1f0c2e-8f4a-4b7e-9a3d-5e2b1c0d9f8a", "accepted": 5, "dropped": 0 }
```

## JSON Schema

JSON Schemas (draft 2020-12) describe what a client sends and receives. Validate your batches against them in tests:

| Schema | Describes |
| --- | --- |
| [`batch.schema.json`](https://docs.mirafive.io/schema/v1/batch.schema.json) | the batch, including the consentless rules |
| [`receipt.schema.json`](https://docs.mirafive.io/schema/v1/receipt.schema.json) | the `202` receipt |
| [`error.schema.json`](https://docs.mirafive.io/schema/v1/error.schema.json) | the error body |

A batch that passes the schema can still be refused for what a schema cannot express: event ids repeated within the batch, properties over 32,768 bytes, 64 leaf values or 5 levels, and a body over 1 MiB.
