---
title: "Tracking"
description: "Every operation in this group: what it accepts, what it returns and the errors it can answer with."
url: "https://openemail.uk/docs/api/reference/tracking"
area: "API"
category: "Reference"
---

# Tracking

Every operation in this group: what it accepts, what it returns and the errors it can answer with.

## Operations

Who read the mail and what they followed. Off unless the workspace turned it on, per user and per send.

Every count here is a floor rather than a total, and that is the mechanism rather than a defect: an open is inferred from a mail client fetching an image, so a reader whose client blocks images reads without being counted, and Gmail fetches the image once through its own proxy and serves every later view from cache. Mail from one OpenEmail mailbox to another never reports an open at all. OpenEmail strips 1×1 images out of what its own users read, and it makes no exception for its own pixel. A message with no opens has not been shown to be unread; a click is the stronger evidence, because links go unfollowed far less often than images go unloaded.

### `GET /emails/{id}/tracking`

How a message was read

The same document `/tracking/{id}` serves, reached from the id a caller already holds. A message that was never tracked is a 404 here rather than an empty report, because "we were not recording" and "nobody opened it" are different answers and a client that renders them the same way makes a claim about a reader on no evidence.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Path parameters**

- `id` (`string`, required): The `msg_` send id returned by `send`.

**Returns**

- `200` `Tracking`: The full report, with per-recipient and per-link detail.

**Errors**

- `404`: No such message, or nothing was tracked for it.
- The errors every operation can return: `400`, `401`, `403`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`emails.getTracking()`](https://openemail.uk/docs/sdk/reference/emails#getTracking); CLI [`openemail emails get-tracking`](https://openemail.uk/docs/cli/reference/emails#emails-get-tracking); MCP [`getEmailTracking`](https://openemail.uk/docs/mcp/tools/tracking#getEmailTracking).

### `GET /tracking`

List tracked messages

Every message the workspace tracked in the window, newest first, one page at a time, not only the ones sent through this API. The composer, the MCP tools and the assistant mostly send through the mailbox agent, which writes the send record itself and never links it to the tracking row, so those messages carry no counts on `/emails` and only this list holds them.

`opened=false` means tracked and not opened. Messages that carried no pixel are absent from this list entirely and never appear as a zero. Follow `nextCursor` with the same filters to reach every tracked message in the window.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Query parameters**

- `opened` (`boolean`): Omit for both. `false` narrows to tracked-and-unopened.
- `clicked` (`boolean`): Omit for both. `true` keeps messages with a counted click and `false` keeps those without.
- `days` (`integer`, at least 1, at most 365, default `30`): How far back to look. The window starts at the beginning of that day rather than at this time of day, and ends now.
- `minutes` (`integer`, at least 1, at most 527040): The window in minutes, which wins over `days` when both are sent. A whole number of days cannot say "the last hour", which is the report worth having while a send is going out.
- `grain` (`string`, one of `"minute"`, `"hour"`, `"day"`, default `"day"`): Only used to floor the start of the window, so this list can cover the same window as `/tracking/stats` read at the same grain. It shapes nothing in the response.
- `limit` (`integer`, at least 1, at most 200, default `50`): Rows per page, 1 to 200.
- `cursor` (`string`): A `tmsg_` tracking id. Keyset, not offset: pass the previous page's `nextCursor`. One that names nothing in this list is a 400 `invalid_cursor`.

**Returns**

- `200` `TrackingList`: A page of tracked messages, newest first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`tracking.list()`](https://openemail.uk/docs/sdk/reference/tracking#list), [`tracking.listAll()`](https://openemail.uk/docs/sdk/reference/tracking#listAll), [`tracking.iterate()`](https://openemail.uk/docs/sdk/reference/tracking#iterate); CLI [`openemail tracking list`](https://openemail.uk/docs/cli/reference/tracking#tracking-list); MCP [`listTrackedEmails`](https://openemail.uk/docs/mcp/tools/tracking#listTrackedEmails).

### `GET /tracking/stats`

Engagement over a window

The numbers behind an engagement panel, in one request. Rates are over tracked messages and count distinct messages; the totals count hits. Read the field descriptions before charting any of it. The two are easy to mix and the result is an open rate above 100%.

A key is the workspace's own authority and sees the whole workspace. The per-member address grants that narrow this in the app belong to a session, and a key has none.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Query parameters**

- `days` (`integer`, at least 1, at most 365, default `30`): How far back to look. The window starts at the beginning of that day in the offset you asked for, so the oldest `byDay` bucket is a whole one, and ends now, so the newest is partial.
- `minutes` (`integer`, at least 1, at most 527040): The window in minutes, which wins over `days` when both are sent. A whole number of days cannot say "the last hour", which is the report worth having while a send is going out.
- `grain` (`string`, one of `"minute"`, `"hour"`, `"day"`, default `"day"`): How wide one `byDay` bucket is. An hour of mail bucketed by day is a single entry, so a window shorter than a day is only worth asking for alongside a grain that can describe it. The bucket keys change shape with it: `YYYY-MM-DD` for a day, `YYYY-MM-DDTHH` for an hour, `YYYY-MM-DDTHH:MM` for a minute.
- `offsetMinutes` (`integer`, at least -840, at most 840, default `0`): Minutes east of UTC to bucket `byDay` in, so days break where the reader's day breaks. Left at zero, this morning's mail lands on yesterday's bar for anyone west of UTC.

**Returns**

- `200` `TrackingStats`: The window, summarised. `byDay` is sparse.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`tracking.getStats()`](https://openemail.uk/docs/sdk/reference/tracking#getStats); CLI [`openemail tracking get-stats`](https://openemail.uk/docs/cli/reference/tracking#tracking-get-stats); MCP [`getEngagementStats`](https://openemail.uk/docs/mcp/tools/tracking#getEngagementStats).

### `GET /tracking/{id}`

Retrieve one message's tracking

The whole report: per-recipient counts where the transport could attribute them, and every rewritten link with its clicks.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Path parameters**

- `id` (`string`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.

**Returns**

- `200` `Tracking`: The report.

**Errors**

- `404`: Nothing was tracked for that message. Not the same as nobody having opened it.
- The errors every operation can return: `400`, `401`, `403`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`tracking.get()`](https://openemail.uk/docs/sdk/reference/tracking#get); CLI [`openemail tracking get`](https://openemail.uk/docs/cli/reference/tracking#tracking-get); MCP [`listTrackedEmails`](https://openemail.uk/docs/mcp/tools/tracking#listTrackedEmails).

### `GET /tracking/{id}/opens`

The individual opens

The raw hits behind `openCount`, newest first, one page at a time. Every stored hit is reachable by following `nextCursor`.

A `msg_` send id is resolved first, and one that names nothing is a 404 rather than an empty list. An empty list reads as "nobody opened it", which is the one answer this endpoint must never give by accident. A `tmsg_` is taken as given and costs no lookup, so an id that never existed, or one belonging to another workspace, does come back here as an empty list. Only ids this API handed you can be read as an answer about a reader.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Path parameters**

- `id` (`string`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.

**Query parameters**

- `includeMachine` (`boolean`, default `false`): Include the hits that did not count: Apple Mail Privacy Protection, corporate scanners, and the repeats collapsed by the thirty-second window. Off by default, and that default is the honest one. Those fetches are stored so the gap between the raw and counted totals stays inspectable, not because anybody read anything. Gmail's image proxy is not among them and is never hidden by this. Send `true` or `false`: `?includeMachine=false` means false here, which is worth saying because the obvious coercion would make it true.
- `limit` (`integer`, at least 1, at most 200, default `50`): Rows per page, 1 to 200.
- `cursor` (`string`): An `opn_` open id from this message. Keyset, not offset: pass the previous page's `nextCursor`. One that names nothing in this list is a 400 `invalid_cursor`.

**Returns**

- `200` `OpenList`: A page of opens, newest first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`tracking.listOpens()`](https://openemail.uk/docs/sdk/reference/tracking#listOpens), [`tracking.listAllOpens()`](https://openemail.uk/docs/sdk/reference/tracking#listAllOpens), [`tracking.iterateOpens()`](https://openemail.uk/docs/sdk/reference/tracking#iterateOpens); CLI [`openemail tracking list-opens`](https://openemail.uk/docs/cli/reference/tracking#tracking-list-opens); MCP [`getEmailTracking`](https://openemail.uk/docs/mcp/tools/tracking#getEmailTracking).

### `GET /tracking/{id}/clicks`

The individual clicks

As the opens, plus which link was followed, one page at a time. Only links in the new part of the body were rewritten, so nothing here can be a click on quoted history.

Requires the `emails:read` scope.

- Scopes: `emails:read`.

**Path parameters**

- `id` (`string`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.

**Query parameters**

- `includeMachine` (`boolean`, default `false`): Include the hits that did not count: Apple Mail Privacy Protection, corporate scanners, and the repeats collapsed by the thirty-second window. Off by default, and that default is the honest one. Those fetches are stored so the gap between the raw and counted totals stays inspectable, not because anybody read anything. Gmail's image proxy is not among them and is never hidden by this. Send `true` or `false`: `?includeMachine=false` means false here, which is worth saying because the obvious coercion would make it true.
- `limit` (`integer`, at least 1, at most 200, default `50`): Rows per page, 1 to 200.
- `cursor` (`string`): A `clk_` click id from this message. Keyset, not offset: pass the previous page's `nextCursor`. One that names nothing in this list is a 400 `invalid_cursor`.

**Returns**

- `200` `ClickList`: A page of clicks, newest first.

**Errors**

- The errors every operation can return: `400`, `401`, `403`, `404`, `422`, `500`, described in the [error catalog](https://openemail.uk/docs/api/errors).

Also available in: SDK [`tracking.listClicks()`](https://openemail.uk/docs/sdk/reference/tracking#listClicks), [`tracking.listAllClicks()`](https://openemail.uk/docs/sdk/reference/tracking#listAllClicks), [`tracking.iterateClicks()`](https://openemail.uk/docs/sdk/reference/tracking#iterateClicks); CLI [`openemail tracking list-clicks`](https://openemail.uk/docs/cli/reference/tracking#tracking-list-clicks); MCP [`getEmailTracking`](https://openemail.uk/docs/mcp/tools/tracking#getEmailTracking).

### Objects

#### `Click`

`object`

- `object` (`string`, one of `"click"`)
- `id` (`string`)
- `trackedMessageId` (`string`)
- `recipient` (`string`, nullable): Null for the same reason `recipients[].email` is: shared bytes, unknowable reader.
- `kind` (`string`, one of `"human"`, `"proxy"`, `"machine"`): `human` looked like somebody reading. `proxy` is an image proxy, Gmail's above all: a genuine reading by a reader we cannot see, counted once and then cached out of our sight. `machine` is a scanner or Apple Mail Privacy Protection, which fetches on delivery and means only that the message arrived.
- `counted` (`boolean`): Whether this hit moved the numbers. False for every `machine` hit, and false again for a hit landing within thirty seconds of the last counted one on the same copy. A preview pane redrawing is the same reading rather than a second one. `includeMachine` filters on this field and not on `kind`, so it is what hides those collapsed repeats as well.
- `client` (`string`, nullable)
- `device` (`string`, nullable)
- `os` (`string`, nullable): Read out of the User-Agent, which is a claim rather than a fact and is absent altogether on plenty of hits.
- `country` (`string`, nullable): What the edge already knew about the request, so this is as precise as the location will ever be, and on a proxied hit it is the proxy's country rather than the reader's. There is no ip field on this resource, but the address is not discarded: it is written into the event log for the message, readable at `GET /emails/{id}/events`, and into the `email.opened` and `email.clicked` webhook payloads.
- `region` (`string`, nullable)
- `city` (`string`, nullable)
- `createdAt` (`string`, format `date-time`)
- `linkId` (`string`)
- `url` (`string`): The destination that was followed.

#### `ClickList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Click[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): The id of the last row on this page, or null on the last page.

#### `Open`

`object`

- `object` (`string`, one of `"open"`)
- `id` (`string`)
- `trackedMessageId` (`string`)
- `recipient` (`string`, nullable): Null for the same reason `recipients[].email` is: shared bytes, unknowable reader.
- `kind` (`string`, one of `"human"`, `"proxy"`, `"machine"`): `human` looked like somebody reading. `proxy` is an image proxy, Gmail's above all: a genuine reading by a reader we cannot see, counted once and then cached out of our sight. `machine` is a scanner or Apple Mail Privacy Protection, which fetches on delivery and means only that the message arrived.
- `counted` (`boolean`): Whether this hit moved the numbers. False for every `machine` hit, and false again for a hit landing within thirty seconds of the last counted one on the same copy. A preview pane redrawing is the same reading rather than a second one. `includeMachine` filters on this field and not on `kind`, so it is what hides those collapsed repeats as well.
- `client` (`string`, nullable)
- `device` (`string`, nullable)
- `os` (`string`, nullable): Read out of the User-Agent, which is a claim rather than a fact and is absent altogether on plenty of hits.
- `country` (`string`, nullable): What the edge already knew about the request, so this is as precise as the location will ever be, and on a proxied hit it is the proxy's country rather than the reader's. There is no ip field on this resource, but the address is not discarded: it is written into the event log for the message, readable at `GET /emails/{id}/events`, and into the `email.opened` and `email.clicked` webhook payloads.
- `region` (`string`, nullable)
- `city` (`string`, nullable)
- `createdAt` (`string`, format `date-time`)

#### `OpenList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Open[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): The id of the last row on this page, or null on the last page.

#### `Tracking`

`object`

Every `/tracking` endpoint returns this whole, its list included. It arrives trimmed in exactly one place, under `tracking` on a `GET /emails` row, where it is the counts half only: `opens`, `clicks`, `opened`, `clicked`, `openCount`, `clickCount` and `firstOpenAt`. A page of fifty sends each carrying its recipients and its links is a report nobody asked to have expanded. The trimmed form has no `id` on it either, so `/emails/{id}/tracking` rather than `/tracking/{id}` is the way back to the rest of it.

- `object` (`string`, one of `"tracking"`): Present when the report is the whole response body. Absent under an email's `tracking` field, which is part of that email rather than a resource in its own right.
- `id` (`string`): The tracking record, `tmsg_` + 24 hex. Not the message id and not the send id.
- `sendId` (`string`, nullable): The `msg_` this went out as, when the send service handled it. Null for mail the mailbox agent sent on its own behalf, which is most composer, MCP and assistant traffic. Those messages do get a send record, but nothing links this tracking row to it. Tracking covers the mailbox rather than only the traffic that came through this API.
- `threadId` (`string`, nullable)
- `messageId` (`string`, nullable): RFC 5322 Message-ID. Not a correlation key. The header is rewritten on the way out. Correlate on `id`.
- `subject` (`string`, nullable)
- `from` (`string`)
- `source` (`string`, one of `"api"`, `"oauth"`, `"composer"`, `"mcp"`, `"ai"`, `"form"`)
- `sentAt` (`string`, nullable, format `date-time`)
- `opens` (`boolean`): What was APPLIED to this message, resolved when it was sent from the setting of the address it was sent from (its own, else its domain catch-all's, else off, while a broadcast copy is on unless the broadcast or its address turned it off) and any per-send override, not what is switched on now. Turning tracking on today does not make yesterday's mail start reporting, and a report that implied otherwise would read as "nobody opened it".
- `clicks` (`boolean`): As `opens`, for link rewriting. The two are independent switches.
- `opened` (`boolean`)
- `clicked` (`boolean`)
- `attributable` (`boolean`): Whether every reading on this message can be pinned to a named recipient. False as soon as an unattributed copy has activity of its own, which is what happens whenever one body went to the whole list rather than a separate one per person. This is the flag that decides whether "Bob has not opened it" is a sentence a client is entitled to write, or whether all it may say is that somebody did. `recipients` carries the same fact one row at a time, and one row at a time is where it gets missed.
- `openCount` (`integer`): Opens that looked like a person, with repeat fetches within thirty seconds collapsed. A preview pane redrawing is not a second reading. Through Gmail this is a floor and not a total: its proxy fetches the image once and caches it, so later readings never reach us.
- `clickCount` (`integer`): Counted clicks. Stronger evidence than an open, and worth weighting as such: images are blocked far more often than links go unfollowed, so a message with clicks and no opens was certainly read.
- `openCountRaw` (`integer`): Every open hit, the automated ones included. `openCountRaw - openCount` is everything that was filtered out: Apple Mail Privacy Protection and corporate link scanners, which fetch on delivery whether or not a person ever looks, and alongside them the repeat fetches collapsed by the thirty-second window. Both are recorded and neither is counted, because discarding them outright would leave a gap in the log that nothing could explain. Do not read the difference as a machine count on its own. A message reopened twice in a minute lands in it too.
- `clickCountRaw` (`integer`): As `openCountRaw`, for clicks.
- `firstOpenAt` (`string`, nullable, format `date-time`)
- `lastOpenAt` (`string`, nullable, format `date-time`)
- `firstClickAt` (`string`, nullable, format `date-time`)
- `lastClickAt` (`string`, nullable, format `date-time`)
- `recipients` (`object[]`): One entry per tracked copy, which is not always one entry per person. Absent only from the trimmed form on a `GET /emails` row; every `/tracking` response carries it, list included.
  - `email` (`string`, nullable): Null where the bytes could not be varied per person: an encrypted message, one too large to rebuild for each recipient, or a fallback carrier that takes the whole recipient list in a single call. The reading is real; which of the recipients did it is not knowable, and the only honest rendering is "someone on this message", never a name chosen out of the list.
  - `kind` (`string`, nullable, one of `"to"`, `"cc"`, `"bcc"`)
  - `attributed` (`boolean`): False on exactly the rows described above. Branch on this rather than on `email` being a string, and show nothing where it is false: attributing an unattributed open to a named recipient invents evidence about a specific person.
  - `openCount` (`integer`)
  - `clickCount` (`integer`)
  - `firstOpenAt` (`string`, nullable, format `date-time`)
  - `lastOpenAt` (`string`, nullable, format `date-time`)
  - `firstClickAt` (`string`, nullable, format `date-time`)
  - `lastClickAt` (`string`, nullable, format `date-time`)
- `links` (`object[]`): The rewritten links, in the order they appeared in the message. Only links in the new part of the body are here: the quoted history under a reply belongs to whoever wrote it, and routing their URLs through our redirector would both rewrite their message and record the recipient "clicking" something we did not put there. Repeated destinations share one entry, because a campaign page linked from a header image, a button and a footer is one question asked three times. Absent only from the trimmed form on a `GET /emails` row.
  - `id` (`string`)
  - `url` (`string`): Where it actually goes: the original href.
  - `label` (`string`, nullable): The text the link read as in the message, where it had any. A bare URL rarely tells the sender which of five links somebody followed.
  - `clickCount` (`integer`)
  - `clickCountRaw` (`integer`)

#### `TrackingList`

`object`

- `object` (`string`, one of `"list"`)
- `data` (`Tracking[]`)
- `hasMore` (`boolean`): True when another page follows. Pass `nextCursor` back as `cursor` to read it.
- `nextCursor` (`string`, nullable): The id of the last row on this page, or null on the last page.

#### `TrackingStats`

`object`

- `object` (`string`, one of `"tracking_stats"`)
- `tracked` (`integer`): Messages in the window that carried a pixel or a rewritten link.
- `opened` (`integer`): Of those, how many a person opened at least once. Distinct MESSAGES, not hits. A message opened five times is one opened message, and conflating the two is how open rates above 100% get published.
- `clicked` (`integer`)
- `trackedForOpens` (`integer`): The denominator of `openRate`. Tracking is two switches rather than one, so this is the messages that actually carried a pixel, not every tracked message.
- `trackedForClicks` (`integer`): The denominator of `clickRate`, and the reason it is published. A message with no links is never click-tracked, so counting it against the click rate makes that rate a measure of how much of your mail contains a link. Most mail is a reply with no links, so the difference from `tracked` is usually large.
- `openRate` (`number`): A percentage of `trackedForOpens`, deliberately not of everything sent. A workspace that tracks one message in ten has an open rate for those ten; dividing by all its mail produces a number that falls every time somebody sends an untracked reply, which is not a fact about how anyone is reading.
- `clickRate` (`number`): A percentage of `trackedForClicks`, as `openRate` is of `trackedForOpens`.
- `totalOpens` (`integer`): Hits rather than messages. Labelled as a total because it is the easy one to misread.
- `totalClicks` (`integer`)
- `machineOpens` (`integer`): Hits excluded from every number above: the sum of `openCountRaw - openCount`, so Apple Mail Privacy Protection and corporate scanners together with the repeats the thirty-second window collapsed. Reported rather than hidden, because a reader who cannot see how much of the traffic was machinery has no way to judge the rest of the panel. Gmail's image proxy is not in this number: that fetch is a real person displaying the message, and it is counted.
- `medianTimeToOpenSeconds` (`integer`, nullable): Median seconds from send to first counted open, over the messages that were opened at all. Null when none were. A median over an empty set is not zero.
- `byDay` (`object[]`): SPARSE. A day on which nothing was sent has no entry rather than a row of zeroes, so a chart has to fill the gaps itself. Days break at `offsetMinutes` east of UTC, so that they break where the reader's day does rather than where the database's does.
  - `day` (`string`): The bucket, in the requested offset, written in the shape `grain` asked for: `YYYY-MM-DD` for a day, `YYYY-MM-DDTHH` for an hour, `YYYY-MM-DDTHH:MM` for a minute. The field keeps its name at every width because it is the bucket key whatever the bucket is.
  - `sent` (`integer`)
  - `opened` (`integer`)
  - `clicked` (`integer`)
- `topLinks` (`object[]`)
  - `url` (`string`)
  - `label` (`string`, nullable)
  - `clickCount` (`integer`)
- `clients` (`object[]`): Mail clients by counted opens, as far as a User-Agent can be trusted to name one.
  - `client` (`string`)
  - `count` (`integer`)
- `countries` (`object[]`)
  - `country` (`string`)
  - `count` (`integer`)
