The event contract — Mimeo docs

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 and the registry, 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'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.
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).
attribution Object: landing_page, referrer,
utm_source, utm_medium,
utm_campaign, utm_term,
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 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.

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

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 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
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.
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
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
active.
next_renewal_at When it renews again, since a resumed subscription has a
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:

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

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.