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.
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": "[email protected]", "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. |
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-instance.com/api/v1/events \
-H "Authorization: Bearer mm_your_token" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"name": "opted_in",
"details": { "entry_point": "footer_newsletter_form" },
"person": {
"first_name": "Ada",
"tags": ["newsletter"]
},
"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?"
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.
Money events
Six 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-instance.com/api/v1/events \
-H "Authorization: Bearer mm_your_token" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"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-instance.com/api/v1/events \
-H "Authorization: Bearer mm_your_token" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"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. |
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-instance.com/api/v1/events \
-H "Authorization: Bearer mm_your_token" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"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_cancelled
details: external_id,
cancelled_at.
subscription_changed
details: external_id plus any of
status, plan, interval,
renewal_amount_cents, next_renewal_at. Use it for
upgrades, downgrades, interval switches, and payment-failure status
changes.
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-instance.com/api/v1/events \
-H "Authorization: Bearer mm_your_token" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"email": "[email protected]",
"name": "opted_in",
"occurred_at": "2026-07-20T09:14:00Z",
"details": { "entry_point": "pricing_page_form" }
},
{
"email": "[email protected]",
"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, and give each one an
idempotency_key so a re-run doesn't double-count.
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 instance 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-instance.com/api/v1/people/[email protected] \
-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.