# People API

Look someone up by id or email: fields, tags, suppression state, money, and where they are in every flow and sequence.

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](https://mimeohq.com/docs/api/imports) and events upsert on.

```
GET /api/v1/people/ada@example.com

{ "person": {
    "id": 41, "email": "ada@example.com", "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](https://mimeohq.com/docs/api/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](https://mimeohq.com/docs/usage/people) 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 {#email-activity}

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": "ada@example.com" },
  "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`](https://mimeohq.com/docs/mcp/#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](https://mimeohq.com/docs/api/events). 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](#unsubscribe) for those actions.

```
POST /api/v1/events
{ "email": "ada@example.com", "name": "plan_changed",
  "details": { "from": "trial", "to": "pro" },
  "person": { "fields": { "plan": "pro" }, "tags": ["customer"] } }
```

See the [selective preference example](https://mimeohq.com/docs/api/events#selective-preferences) 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": "ada@example.com", "name": "subscribed",
  "person": { "subscribed_on": "2023-04-11" } }
```

> [!NOTE]
> **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](https://mimeohq.com/docs/api/subscriptions) — 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 {#unsubscribe}

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/ada@example.com/unsubscribe
{ "reason": "asked by email" }

POST /api/v1/people/ada@example.com/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 {#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": "ada@example.com" }
  }
}
```

- **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](https://mimeohq.com/docs/api/events) refuses the name for the same reason.)
- **Validated.** `note` is required and capped at 10,000 characters; anything else answers `422` with the problem named.
- **Idempotent on request.** Pass an `idempotency_key` and a retry with the same key answers `200` with `"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.
- **`metadata` is 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/queue?person=ada@example.com&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](https://mimeohq.com/docs/api/events) · [Subscriptions API](https://mimeohq.com/docs/api/subscriptions) · [People, for humans](https://mimeohq.com/docs/usage/people)
