# The event contract

POST /api/v1/events is how everything enters Mimeo — people upserts, attribution, timelines, money events — and GET reads the feed and the event registry back.

One endpoint takes everything. Signups, opt-ins, purchases, renewals, cancellations — they all arrive as events, and events are what build a person's record in Mimeo. What went in is readable too: [the feed](#reading-events) and [the registry](#event-types), further down.

```
POST /api/v1/events
Authorization: Bearer mm_your_token
Content-Type: application/json
```

## What an event does

Every accepted event does four things, in this order:

1. **Upserts the person**, matched on lowercased email. The same address never produces two people, no matter how it's capitalized or how many systems send it.
2. **Captures first-touch attribution.** The attribution on the first event that creates a person is written once and **never overwritten**. Later events can carry attribution; it won't replace what's already there. This is what makes "where did this customer originally come from?" answerable months later.
3. **Appends to the timeline.** The event becomes a permanent entry on that person's history.
4. **Writes the money tables**, if it's one of the reserved money event names below.

> [!NOTE]
> **Events wake the engine.** An event that matches an active [flow](https://mimeohq.com/docs/usage/flows)'s trigger starts a run, and it also releases anyone waiting at a gate for it. Mimeo fires its own events too — `tag_added`, `field_changed`, `sequence_completed`, `flow_exited` — so those are triggers like any other.

## Request body

Send one event object:

```
{ "email": "ada@example.com", "name": "signed_up" }
```

Or a batch, up to **100 events per request**:

```
{ "events": [ { … }, { … } ] }
```

### Fields

| Field | Notes |
| --- | --- |
| `email` | **Required.** The match key. Lowercased on receipt. |
| `name` | **Required.** The event name, e.g. `signed_up`. Use your own names freely except for the reserved money names below. |
| `occurred_at` | ISO8601 timestamp. Defaults to now. Set it explicitly when backfilling history so the timeline reads correctly. |
| `historical` | Optional, defaults to `false`. `true` means this already happened somewhere else — a migrated opt-in, a purchase from two years ago. The event is recorded in full but **starts no flow and opens no gate**. Set it on every event in a backfill. See [Backfilling without sending mail](#historical). |
| `idempotency_key` | Optional. A repeated key returns status `duplicate` and is not re-processed. Use it anywhere a retry is possible — webhook handlers especially. |
| `details` | Object. Free-form for your own events; carries the defined payload for money events. |
| `person` | Object: `first_name`, `last_name`, `fields` (a key/value object), `tags` (array of strings to add), `remove_tags` (array of strings to remove). |
| `attribution` | Object: `landing_page`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `device`, `country`. |

### Selective preferences and tag removal {#selective-preferences}

Use an event to record a preference change and update the person's tags together. This switches a reader from daily to weekly news without globally unsubscribing them:

```
POST /api/v1/events
Authorization: Bearer mm_your_token
Content-Type: application/json

{
  "email": "reader@example.com",
  "name": "preferences_saved",
  "person": {
    "remove_tags": ["daily_news"],
    "tags": ["weekly_news"]
  }
}
```

`remove_tags` removes only the requested memberships; unrelated tags stay intact. Removing a tag the person does not have is a no-op. Tag matching ignores case and surrounding whitespace. If the same tag appears in both arrays after normalization (for example, `" Daily_News "` and `"daily_news"`), the event returns `422` with no person or event writes. Tag changes never suppress or resubscribe the person. You can include `person.fields` in the same event for custom-field preferences.

> [!CAUTION]
> **Keep API tokens server-side.** A custom preference page must call your backend, which authenticates the subscriber and sends this event. Never embed an operator bearer token in browser code. For Mimeo's hosted controls, configure [subscription preferences](https://mimeohq.com/docs/api/settings#subscription-preferences) instead. In either case, use the tags or fields in your audiences and segments, plus [send-time guards](https://mimeohq.com/docs/usage/guards) for already queued mail. Changing preferences does not create those sending rules for you.

> [!CAUTION]
> **Custom field keys must be declared.** Keys inside `person.fields` have to match field definitions you've created under Settings → Fields. Unknown keys sent through the API are **silently ignored** — no error, no data. If a value isn't showing up, check the key against [`GET /api/v1/field_definitions`](#field-definitions) first.

## Response

`201 Created` when everything succeeded, or `422` if any event in the request failed. Either way you get a `results` array in the same order as the events you sent:

```
{
  "results": [
    { "status": "created",   "person_id": 42, "event_id": 1087 },
    { "status": "duplicate", "person_id": 42, "event_id": 1042 },
    { "status": "invalid",   "error": "email is required" }
  ]
}
```

`status` is `created`, `duplicate`, or `invalid`. A batch can partially succeed — always read the array rather than trusting the status code alone.

## Opt-in intake

There is no separate subscribe endpoint. Opt-ins are a convention on top of events: use the name `opted_in` and put the form or page in `details.entry_point`.

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "opted_in",
    "details": { "entry_point": "footer_newsletter_form" },
    "person": {
      "first_name": "Ada",
      "tags": ["newsletter"],
      "subscribed_on": "2023-04-11"
    },
    "attribution": {
      "landing_page": "https://example.com/blog/why-own-your-email",
      "referrer": "https://news.ycombinator.com/",
      "utm_source": "hn",
      "utm_medium": "referral",
      "device": "desktop",
      "country": "US"
    }
  }'
```

Keeping `entry_point` consistent across your forms is what later lets you answer "which form is actually producing subscribers?"

> [!NOTE]
> **`person.subscribed_on` is the only thing that sets the subscribe date — `occurred_at` never does.** Every event carries an `occurred_at` whether or not it has anything to do with joining a list, so deriving the date from it would mean backfilling old purchase history quietly rewrote your list's join dates. Only an earlier date replaces a stored one, so a repeat opt-in sending today can't overwrite the real date. See [People](https://mimeohq.com/docs/api/people).

## System events

Mimeo emits some events itself when state changes, so the timeline stays complete without you sending them:

| Event | Details |
| --- | --- |
| `tag_added` | `details.tag` |
| `tag_removed` | `details.tag` |
| `field_changed` | `details.key`, `details.old`, `details.new` |
| `pruned` | `details.flow`, `details.flow_name`, `details.flow_run_id`, `details.inactive_since`, `details.days` — a flow's [prune step](https://mimeohq.com/docs/usage/flows#prune) unsubscribed them for inactivity |

Tag and field events fire whether the change came from the UI, an import, or an event you sent. [Renaming a tag](https://mimeohq.com/docs/usage/people#renaming-a-tag) fires none and rewrites `details.tag` on the past ones. Tag and field history is auditable rather than invisible. The full vocabulary your instance actually uses — system events included — is discoverable through [the event registry](#event-types) below.

## Money events

Eight event names are reserved. They behave like any other event — upserting the person, appending to the timeline — and additionally write Mimeo's money tables.

[Subscription renewal approaching](https://mimeohq.com/docs/usage/flows#renewal-approaching) is a flow trigger over those native subscription rows, **not a ninth money event**. Keep renewal amount, date, status and cancellation fields current with the lifecycle events below. Do not emit `subscription_renewal_approaching` or replay subscription starts to schedule reminders.

> [!NOTE]
> **Mimeo never integrates with Stripe or any payment processor.** All money data comes from your systems, sent as events. That's what keeps the money model independent of who you bill through.

### `purchase`

Someone bought something.

| `details` | Notes |
| --- | --- |
| `product` | **Required.** A product key. Unknown keys are auto-created, so you don't have to pre-register your catalog. |
| `amount_cents` | Integer cents. |
| `currency` | Defaults to `usd`. |
| `promo_code` | Optional. |
| `external_id` | Your system's ID for the purchase. Used as an idempotent upsert key — resending the same `external_id` updates the purchase rather than creating a second one. |
| `metadata` | Object, free-form. |
| `payment` | Optional nested object for the initial charge: `amount_cents`, `external_id`, `occurred_at`. |

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "purchase",
    "idempotency_key": "order_9f2c1a",
    "details": {
      "product": "pro_annual",
      "amount_cents": 24000,
      "currency": "usd",
      "promo_code": "LAUNCH20",
      "external_id": "ord_9f2c1a",
      "metadata": { "seats": 3 },
      "payment": {
        "amount_cents": 24000,
        "external_id": "ch_44b81e",
        "occurred_at": "2026-07-24T14:02:11Z"
      }
    }
  }'
```

#### Free trials

A free-trial start is a `purchase` with `amount_cents: 0` and **no nested payment**. The purchase records that they took the product; the absence of a payment records that no money moved.

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "purchase",
    "details": {
      "product": "pro_monthly",
      "amount_cents": 0,
      "external_id": "trial_5521"
    }
  }'
```

### `payment`

Money actually moved. Payments are what lifetime spend sums.

| `details` | Notes |
| --- | --- |
| `amount_cents` | **Required.** |
| `currency` | Defaults to `usd`. |
| `external_id` | Your charge ID. |
| `purchase_external_id` | Links this payment to the purchase it belongs to. |
| `occurred_at` | When the charge happened. |

### `subscription_started`

| `details` | Notes |
| --- | --- |
| `product` | Product key. |
| `purchase_external_id` | The originating purchase. |
| `external_id` | Your subscription ID — the key later events update against. |
| `status` | `trialing`, `active`, `past_due`, `cancelled`, or `lifetime`. |
| `plan` | Your plan identifier. |
| `interval` | `monthly`, `quarterly`, or `yearly`. |
| `renewal_amount_cents` | What the next renewal will charge. |
| `next_renewal_at` | When that happens. |
| `cancels_at` | A scheduled end date, for backfilling a subscription that is already set to cancel at period end. |
| `metadata` | Optional object of your own keys. Replaces the stored metadata. See [subscription metadata](#subscription-metadata). |
| `payment` | Optional nested initial charge. |

### `subscription_renewed`

> [!CAUTION]
> **Renewals update the subscription and never create purchases.** If you send a `purchase` on every renewal, a two-year customer looks like twenty-four separate buyers. Send `subscription_renewed` instead — one purchase, one subscription, many payments.

| `details` | Notes |
| --- | --- |
| `external_id` | Which subscription renewed. |
| `status` | Defaults to `active`. |
| `renewal_amount_cents` | Updated renewal amount. |
| `next_renewal_at` | The new renewal date. |
| `metadata` | Optional object of your own keys. Replaces the stored metadata. See [subscription metadata](#subscription-metadata). |
| `payment` | Optional nested object for the renewal charge. |

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "subscription_renewed",
    "idempotency_key": "renewal_sub_311_2026_07",
    "details": {
      "external_id": "sub_311",
      "status": "active",
      "renewal_amount_cents": 2400,
      "next_renewal_at": "2026-08-24T00:00:00Z",
      "payment": {
        "amount_cents": 2400,
        "external_id": "ch_77d02f",
        "occurred_at": "2026-07-24T00:00:11Z"
      }
    }
  }'
```

### `subscription_cancellation_scheduled`

Someone cancelled partway through a paid period, so the subscription ends later rather than now. This sets `cancels_at` and nothing else — the status stays `active`, because the person keeps access (and could still resume) until the period ends. Send `subscription_cancelled` when the end actually arrives.

| `details` | Notes |
| --- | --- |
| `external_id` | Which subscription is scheduled to cancel. |
| `cancels_at` | When it ends. Defaults to the subscription's stored `next_renewal_at` — cancel at period end. |
| `metadata` | Optional object of your own keys. Replaces the stored metadata. See [subscription metadata](#subscription-metadata). |

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "name": "subscription_cancellation_scheduled",
    "details": {
      "external_id": "sub_311",
      "cancels_at": "2026-08-24T00:00:00Z"
    }
  }'
```

If they resume before the period ends, send `subscription_resumed`.

### `subscription_cancelled`

The subscription actually ended — this flips the status to `cancelled`. `details`: `external_id`, `cancelled_at`, and optional [`metadata`](#subscription-metadata).

### `subscription_resumed`

The cancellation is off. This clears `cancels_at`, and a subscription that had already ended comes back: status returns to `active` and `cancelled_at` clears. One that was merely scheduled to cancel keeps its current status — resuming isn't a status upgrade, so `trialing` stays `trialing` and `past_due` stays `past_due`. Being its own event name, it's also a clean trigger for a "welcome back" flow.

| `details` | Notes |
| --- | --- |
| `external_id` | Which subscription resumed. |
| `status` | Only used when reviving an ended subscription. Defaults to `active`. |
| `next_renewal_at` | When it renews again, since a resumed subscription has a renewal date once more. |
| `metadata` | Optional object of your own keys. Replaces the stored metadata. See [subscription metadata](#subscription-metadata). |

### `subscription_changed`

`details`: `external_id` plus any of `status`, `plan`, `interval`, `renewal_amount_cents`, `next_renewal_at`, `cancels_at`, [`metadata`](#subscription-metadata). Use it for upgrades, downgrades, interval switches, and payment-failure status changes. Omitted keys are left untouched; an explicit `"cancels_at": null` clears a scheduled cancellation.

### Subscription metadata {#subscription-metadata}

Every subscription carries a `metadata` object for your own keys — the domain a license covers, a seat count, an account ID. Any subscription event (`subscription_started`, `subscription_renewed`, `subscription_changed`, `subscription_cancellation_scheduled`, `subscription_cancelled`, `subscription_resumed`) can set it with `details.metadata`:

| `details.metadata` | Effect |
| --- | --- |
| An object | Replaces the stored metadata whole. Keys you leave out are dropped, so send the complete object. |
| `null` | Clears it to `{}`. |
| Absent | Leaves it untouched. |
| Anything else | Rejected with 422 `metadata must be an object`. |

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "x@y.com",
    "name": "subscription_started",
    "details": {
      "product": "support",
      "external_id": "sub_123",
      "interval": "yearly",
      "metadata": { "domain": "shop.example.com" }
    }
  }'
```

Read it back in [renewal and ending emails](https://mimeohq.com/docs/usage/emails#subscription-metadata) as `automation.subscription.metadata.<key>`, and in the subscriptions of [`GET /api/v1/people/:email`](https://mimeohq.com/docs/api/people), MCP `lookup_person` and the person export.

### Lifetime spend

A person's lifetime spend is the **sum of their payments** — not their purchases and not their subscription amounts. Purchases record intent; payments record money. A free trial adds nothing until the first payment arrives, which is exactly right.

## Batching

Up to 100 events per request, processed in order, with one result per event:

```
curl -X POST https://your-mimeo.com/api/v1/events \
  -H "Authorization: Bearer mm_your_token" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "email": "ada@example.com",
        "name": "opted_in",
        "occurred_at": "2026-07-20T09:14:00Z",
        "details": { "entry_point": "pricing_page_form" }
      },
      {
        "email": "grace@example.com",
        "name": "purchase",
        "idempotency_key": "order_7710",
        "details": {
          "product": "pro_monthly",
          "amount_cents": 2400,
          "external_id": "ord_7710",
          "payment": { "amount_cents": 2400, "external_id": "ch_7710a" }
        }
      }
    ]
  }'
```

Batching is the right tool for backfills. Set `occurred_at` on each event so history lands on the correct dates, give each one an `idempotency_key` so a re-run doesn't double-count, and set `historical: true` so none of it sets anything off.

## Backfilling without sending mail {#historical}

Recording an event wakes the automation engine. That's the whole point when something just happened, and exactly wrong when you're loading history: replaying 19,000 opt-ins would start 19,000 journeys and mail all of them. `historical: true` is how you say *this already happened — write it down, don't act on it.*

```
POST /api/v1/events
{ "email": "ada@example.com", "name": "purchase", "historical": true,
  "occurred_at": "2024-03-15T10:00:00Z",
  "details": { "product": "pro", "amount_cents": 4900 } }
```

A historical event is recorded in full. It:

- lands on the person's timeline, marked **historical**;
- writes the money tables, so a purchase backfill still produces purchases, payments and subscriptions;
- registers its name in the event registry;
- answers `event_occurred` conditions like any other event.

The one thing it does not do is start a flow or release someone waiting at a gate.

> [!NOTE]
> **Event-triggered flows are protected by default, including the ones you already have.** A flow ignores historical events unless its event trigger explicitly says `"ignore_historical": false`. An absent key means protected, so nothing written before this existed needs editing, and a flow written by hand is safe by omission. The checkbox on a flow's entry rules is the same setting.
>
> **CSV imports mark their own events historical.** Importing 19,000 people with one tag apiece emits 19,000 `tag_added` events; without this, a flow triggering on `tag_added` would start 19,000 runs from a spreadsheet. You don't have to do anything to get that — it's the default.

**Historical is not a way to silence native renewal reminders.** Money events still update subscription rows, which an active renewal trigger reads on its minute tick. That trigger does not accept `ignore_historical`. Review affected renewal flows before changing native data; silent subscription repairs reject changes that could trigger these automations.

For event-triggered flows, this replaces the old advice of deactivating every flow before a backfill and remembering to turn them back on. That only ever worked on day one; on a running instance it meant taking live automation down to load old data.

## Reading the stream back {#reading-events}

```
GET /api/v1/events
```

The same feed the UI's [Activity page](https://mimeohq.com/docs/usage/events) shows: every event on the instance, newest first — the ones you POSTed, the ones the engine emitted, and the ones imports recorded.

| Parameter | Notes |
| --- | --- |
| `name` | Only this event name. |
| `source` | Only this source: `api`, `system`, or `import`. |
| `email` | Only this person's events, by their email address. |
| `since` / `until` | Bound the time window. Any parseable time works; an unparseable value is a `400` naming it. |
| `historical` | `true` for only backfilled events, `false` for only live ones. Omit to see both. Useful for checking what a migration actually landed. |
| `per_page` | Default 100, max 500. |
| `page` | Page number. |

```
curl "https://your-mimeo.com/api/v1/events?name=signed_up&source=api" \
  -H "Authorization: Bearer mm_your_token"
```

```
{
  "events": [
    {
      "id": 1087,
      "name": "signed_up",
      "source": "api",
      "details": { "plan": "trial" },
      "occurred_at": "2026-07-24T14:02:11Z",
      "person": { "id": 42, "email": "ada@example.com" }
    }
  ],
  "pagy": { "page": 1, "pages": 12, "count": 1134, "from": 1, "to": 100, "prev": null, "next": 2 }
}
```

## The event registry {#event-types}

```
GET /api/v1/event_types
```

Every event name this instance has seen — one row per name, most recently seen first. Nothing is declared ahead of time: a name registers itself the first time it arrives, which makes this the one registry that can't drift from reality. It's read-only for the same reason — there is no write API for it. Takes `per_page` (default 100, max 500) and `page`.

```
{
  "event_types": [
    {
      "name": "signed_up",
      "sources": ["api"],
      "events_count": 1134,
      "first_seen_at": "2026-03-02T09:14:00Z",
      "last_seen_at": "2026-07-24T14:02:11Z"
    }
  ],
  "pagy": { "page": 1, "pages": 1, "count": 14, "from": 1, "to": 14, "prev": null, "next": null }
}
```

### One name in full

```
GET /api/v1/event_types/:name
```

The summary fields plus two things you can't get anywhere else: `shape` — the merged picture of every payload ever sent under the name — and `used_in` — everything listening for it.

```
curl https://your-mimeo.com/api/v1/event_types/signed_up \
  -H "Authorization: Bearer mm_your_token"
```

```
{
  "name": "signed_up",
  "sources": ["api"],
  "events_count": 1134,
  "first_seen_at": "2026-03-02T09:14:00Z",
  "last_seen_at": "2026-07-24T14:02:11Z",
  "shape": {
    "plan":  { "types": ["string"], "count": 1134, "example": "trial" },
    "seats": { "types": ["number"], "count": 310,  "example": 3 }
  },
  "used_in": {
    "flows": [
      { "id": 7, "key": "welcome", "name": "Welcome", "status": "active", "via": ["trigger"] }
    ],
    "segments": [
      { "id": 3, "key": "recent_signups", "name": "Recent signups" }
    ]
  }
}
```

Per payload key, `shape` carries the JSON types seen, how many occurrences carried the key, and one real example value. It's a glimpse, not an archive: at most 200 distinct keys are described (occurrences beyond the cap still count), and an example is truncated past \~500 bytes. In `used_in`, a flow's `via` is any of `trigger`, `gate` and `fires` — how that flow touches the name — and segments appear when a rule has an `event_occurred` condition on it. A name that has never been seen is a `404` with `{ "error": … }`.

Read the registry before writing a trigger, a gate or a segment condition: it's the difference between guessing what an event is called and what it carries, and knowing.

## Discovering the field schema {#field-definitions}

```
GET /api/v1/field_definitions
```

```
{
  "field_definitions": [
    {
      "key": "plan",
      "label": "Plan",
      "type": "select",
      "options": ["free", "pro", "team"],
      "description": "Current subscription plan"
    }
  ]
}
```

This exists for agents. A coding agent pointed at your Mimeo can read the field schema and know what this particular install understands, rather than guessing at keys that would be silently ignored. Call it before writing code that sets `person.fields`.

## Reading a person {#read-a-person}

```
GET /api/v1/people/:id_or_email
```

Takes either the numeric ID or the email address. Returns the person's profile, custom field values, tags, first-touch attribution, suppression state, and `lifetime_spend_cents`.

```
curl https://your-mimeo.com/api/v1/people/ada@example.com \
  -H "Authorization: Bearer mm_your_token"
```

Looking up by email is usually what you want — it's the key you already have, and it's the same key events match on.
