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:
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.
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.
Appends to the timeline. The event becomes a permanent entry on that person's history.
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 thereserved 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 meansthis 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 isnot 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 ofstrings). |
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 purchaserather 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, oryearly. |
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 storednext_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 toactive. |
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:
- 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_occurredconditions 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, orimport. |
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, falsefor 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.