People API
Look someone up: their fields, tags, suppression state, money and where they are in every flow and sequence. Plus the operator-side writes: unsubscribe, resubscribe, and internal notes on the timeline.
GET /api/v1/people/:id_or_email
Addressed by numeric id or email address — email is the universal key here, lowercased, and it's what imports and events upsert on.
GET /api/v1/people/[email protected]
{ "person": {
"id": 41, "email": "[email protected]", "name": "Ada Lovelace",
"unsubscribed": false,
"subscribed_on": "2023-04-11",
"fields": { "plan": "trial" },
"tags": ["trial"],
"attribution": { "first_landing_page": "…", "utm_source": "…" },
"lifetime_spend_cents": 29900,
"engagement_score": 83, "engagement_band": "hot",
"subscriptions": [
{ "external_id": "sub_311", "product": "pro-plan", "status": "active",
"plan": "pro", "interval": "yearly",
"renewal_amount_cents": 29900, "currency": "usd",
"next_renewal_at": "2027-08-06T00:00:00Z",
"cancels_at": null, "cancelled_at": null }
],
"active_flow_runs": [ { "flow": "trial_journey", "step": "trial_journey.7-wait-for-pricing" } ],
"recent_events": [ … ] } }
Each subscription is keyed the way money events are: product is the product key and external_id is what subscription events update against. A non-null cancels_at is a scheduled cancellation — the subscription is still live until that date, and cancelled_at stays null until it actually ends.
engagement_score is the person's 0–100 engagement score and engagement_band its band key — one of hot, engaged, warm, cooling, cold, or suppressed. It's read-only and computed from opens, clicks, and suppression state; 0 always means the address is suppressed.
Email activity for one person
A read-only history of the emails sent to one current email address, with human-only opens and clicks. Both unscoped operator tokens and source-bound integration tokens can call this endpoint. It returns only the person's id and email plus email activity — no tags, fields, attribution, subscriptions, or other profile data.
GET /api/v1/people/email/activity?email=ada%40example.com&until=2026-09-01T12%3A00%3A00Z&limit=20
Authorization: Bearer mm_your_token
{ "person": { "id": 41, "email": "[email protected]" },
"emails": [
{ "subject": "Welcome aboard",
"sent_at": "2026-08-30T09:00:00Z",
"first_opened_at": "2026-08-30T09:12:00Z", "opens_count": 2,
"first_clicked_at": "2026-08-30T09:15:00Z", "clicks_count": 1 },
{ "subject": "Your next step",
"sent_at": "2026-09-01T12:00:00Z",
"first_opened_at": null, "opens_count": 0,
"first_clicked_at": null, "clicks_count": 0 }
] }
| Query parameter | Meaning |
|---|---|
email |
Required current email address. Trimmed and lowercased, just like GET /api/v1/people/email. URL-encode it, especially a plus sign in an address. |
until |
Optional ISO8601 timestamp. Includes sends whose sent_at is at or before it. Include a UTC offset or Z and URL-encode the value. |
limit |
Optional positive integer, default 20, capped at 100. Keeps the most recent matching sends, then returns them in ascending sent_at order (oldest first). Equal timestamps are ordered by send id. |
Only real, non-test sends accepted by the provider are included (including sends later marked delivered, bounced, or complained). Failed or still-in-progress attempts are excluded. The subject is the send's saved subject, falling back to the email's subject when absent. Opens and clicks count individual human tracking hits, not unique people; bot-flagged hits are excluded from both counts and first timestamps. A first timestamp is null when there are no human hits. All timestamps use ISO8601.
The cutoff applies to sends only. Engagement reflects all human hits known now, including hits after until. An existing person with no matching sends returns 200 and "emails": []. This never creates or updates a person.
| HTTP | JSON error |
|---|---|
| 401 | {"error":"Invalid or missing API token"} — missing, invalid, or revoked bearer token. |
| 404 | {"error":"person_not_found"} — no person has the normalized current email. |
| 422 | {"error":"invalid_email"}, {"error":"invalid_until"}, or {"error":"invalid_limit"} — missing/invalid email, invalid ISO8601 cutoff, or non-positive/non-integer limit. |
Source-bound means a restricted API surface, not ownership of a person's history: this read includes all matching sends for that person, regardless of source. Keep tokens and this lookup server-side, and authorize access in the calling app. Operator MCP clients can use get_person_email_activity with the same parameters and response.
Writing to a person
To change a person's fields or tags through the API, record an event. An event upserts the person, sets fields, adds person.tags and removes person.remove_tags, leaving a timeline entry explaining why they changed. These tag changes do not unsubscribe or resubscribe anyone; use the dedicated operator suppression endpoints for those actions.
POST /api/v1/events
{ "email": "[email protected]", "name": "plan_changed",
"details": { "from": "trial", "to": "pro" },
"person": { "fields": { "plan": "pro" }, "tags": ["customer"] } }
See the selective preference example to remove daily-news membership and add weekly news in one event. Unrelated tags remain intact. A normalized tag cannot appear in both the add and remove arrays: that returns 422 without writes. Operator API tokens belong on your server, not in a subscriber's browser.
The subscribe date
Every person has subscribed_on, a calendar date (YYYY-MM-DD) recording when they joined the list. People created through Mimeo get the day they arrived; people carried over from another tool get whatever date you supply. It is never null, so a question like "who joined before June?" is one comparison rather than a null check plus a fallback.
Set it explicitly under person when you record an event:
POST /api/v1/events
{ "email": "[email protected]", "name": "subscribed",
"person": { "subscribed_on": "2023-04-11" } }
Only an earlier date replaces a stored one, and the event's occurred_at is never used for it. Both rules exist for the same reason: an event carries a timestamp whether or not it has anything to do with joining a list, so backfilling years of purchase history would otherwise rewrite everyone's subscribe date to their first purchase. Because earlier wins, those old timestamps are exactly the ones that would land.
The practical effect is that a repeat opt-in can't overwrite a real join date, and re-sending the same event is idempotent. To move a date forward, edit it on the person's profile — that's the one write that sets it outright.
Suppression
A person's own opt-out happens through the unsubscribe link and the tokenized subscription endpoints — which attribute it to the exact email it came from. Suppression state appears on the person: whether they're unsubscribed, when, why, and how it happened (link — the footer link, one_click — the mail client's button, api — a page of yours calling the subscription API, import, provider_sync, provider_webhook, manual — you, or your agent).
Unsubscribe and resubscribe as the operator
The same two buttons the person's page has, for when the request reaches you rather than the link — someone writes in asking to be taken off, a list you're retiring, an opt-out the provider missed:
POST /api/v1/people/[email protected]/unsubscribe
{ "reason": "asked by email" }
POST /api/v1/people/[email protected]/resubscribe
Both take an id or an email, both are idempotent, and both answer with the person as GET does. reason is optional (default unsubscribe) and shows on their record; the source is manual, so it stays distinguishable from a click. Unsubscribing stops every send to them immediately and mirrors to your provider where the provider supports it; resubscribing clears the suppression and mirrors that too.
"It's people, not subscribers" is the rule the data model follows: someone who unsubscribes is still a person, with their history intact.
Internal notes
Attach a plain-text internal note to a person's timeline — bookkeeping about the person (a support exchange, a manual correction, why something was done), as opposed to an event, which is something the person did. Notes land on the timeline next to everything else, shown with a note icon and their readable text.
POST /api/v1/people/:id_or_email/notes
Authorization: Bearer <token>
{
"note": "Called about billing — resolved, keeping them on the annual plan.",
"idempotency_key": "support-call-2026-08-23",
"metadata": { "ticket": 4211 }
}
Same bearer-token authentication as the rest of the API, and the person is addressed by numeric id or email, exactly like GET /api/v1/people/:id. Returns 201 with the note:
{
"status": "created",
"note": {
"id": 9182,
"note": "Called about billing — resolved, keeping them on the annual plan.",
"metadata": { "ticket": 4211 },
"occurred_at": "2026-08-23T14:02:11-04:00",
"person": { "id": 41, "email": "[email protected]" }
}
}
- Notes never trigger automation. A note can't start a flow, open a gate, or send mail, and the reserved name it's stored under (
internal_note) never enters the event-type trigger vocabulary. There is no opt-in — internal notes are invisible to the engine, always. (The generic events API refuses the name for the same reason.) - Validated.
noteis required and capped at 10,000 characters; anything else answers422with the problem named. - Idempotent on request. Pass an
idempotency_keyand a retry with the same key answers200with"status": "duplicate"and the already-written note, instead of writing a second one — so a maintenance script can retry safely. - Never creates a person. An unknown id or email is a
404— a note is about someone who exists. metadatais optional — structured context (IDs, timestamps, batch names) kept alongside the note and shown apart from the readable text on the timeline.
The queue
GET /api/v1/[email protected]&status=held
What's scheduled for them, what's holding and why, and what was dropped. ?status= takes pending, held, dropped, canceled or sent; ?limit= caps at 200.
See also: Events API · Subscriptions API · People, for humans