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 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 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
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.
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 instead. In either case, use the tags or fields in your audiences and segments, plus send-time guards for already queued mail. Changing preferences does not create those sending rules for you.
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.
Subscription 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.
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
untouched; 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.
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
GET /api/v1/events
The same feed the UI's Activity page 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.