# The event contract

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](/content/docs/api/events#reading-events/index.html) and
[the registry](/content/docs/api/events#event-types/index.html), 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.

**Events wake the engine.** An event that matches an active
[flow](/content/docs/usage/flows.html)'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.<br> `signed_up`. Use your own names freely except for the<br> reserved money names below. |
| `occurred_at` | ISO8601 timestamp. Defaults to now. Set it explicitly when<br> backfilling history so the timeline reads correctly. |
| `historical` | Optional, defaults to `false`. `true` means<br> this already happened somewhere else — a migrated opt-in, a<br> purchase from two years ago. The event is recorded in full but<br> **starts no flow and opens no gate**. Set it on every<br> event in a backfill. See<br> [Backfilling without sending mail](/content/docs/api/events#historical/index.html). |
| `idempotency_key` | Optional. A repeated key returns status `duplicate` and is<br> not re-processed. Use it anywhere a retry is possible — webhook<br> handlers especially. |
| `details` | Object. Free-form for your own events; carries the defined payload<br> for money events. |
| `person` | Object: `first_name`, `last_name`,<br> `fields` (a key/value object), `tags` (array of<br> strings). |
| `attribution` | Object: `landing_page`, `referrer`,<br> `utm_source`, `utm_medium`,<br> `utm_campaign`, `utm_term`,<br> `utm_content`, `device`, `country`. |

**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`](/content/docs/api/events#field-definitions/index.html)
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?"

**`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](/content/docs/api/people.html).

## 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`,<br> `details.new` |

These fire whether the change came from the UI, an import, or an event you
sent. 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](/content/docs/api/events#event-types/index.html) 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.

**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<br> 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 —<br> resending the same `external_id` updates the purchase<br> rather than creating a second one. |
| `metadata` | Object, free-form. |
| `payment` | Optional nested object for the initial charge:<br> `amount_cents`, `external_id`,<br> `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`,<br> `cancelled`, or `lifetime`. |
| `plan` | Your plan identifier. |
| `interval` | `monthly`, `quarterly`, or<br> `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<br> already set to cancel at period end. |
| `payment` | Optional nested initial charge. |

### `subscription_renewed`

**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. |
| `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<br> `next_renewal_at` — cancel at period end. |

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

### `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<br> `active`. |
| `next_renewal_at` | When it renews again, since a resumed subscription has a<br> renewal date once more. |

### `subscription_changed`

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

### 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

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.

**Every flow is protected by default, including the ones you**
**already have.** A flow ignores historical events unless its
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.

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

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

The same feed the UI's [Activity\
page](/content/docs/usage/events.html) 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<br> `import`. |
| `email` | Only this person's events, by their email address. |
| `since` / `until` | Bound the time window. Any parseable time works; an unparseable<br> value is a `400` naming it. |
| `historical` | `true` for only backfilled events, `false`<br> for only live ones. Omit to see both. Useful for checking what a<br> 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

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

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

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