---
title: "openemail.tracking"
description: "Every method in this namespace: its signature, its parameters, what it returns and an example."
url: "https://openemail.uk/docs/python/reference/tracking"
area: "Python"
category: "Reference"
---

# openemail.tracking

Every method in this namespace: its signature, its parameters, what it returns and an example.

## Methods

Opens and clicks on tracked messages, one message at a time or rolled up.

### `tracking.list()`

List one page of tracked messages in a time window

```python
def list(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    opened: bool | None = None,
    clicked: bool | None = None,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TrackingResource]
```

Returns one page of the messages the workspace tracked and sent in the window, newest first, each as a full engagement report with its `recipients` and `links`. It covers the whole mailbox, not only mail sent through this API: messages from the composer, the MCP tools and the assistant appear too, with `sendId` set to `None` where no send record exists. A report built off `emails.list` would describe the API rather than the mailbox.

Messages that carried no pixel and no rewritten link are absent entirely and never appear as a zero. `opened=False` therefore means tracked and not opened, and `clicked=` narrows the same way. Both filters work on counted opens and clicks, so a message fetched only by Apple Mail Privacy Protection or a link scanner still counts as unopened.

The window defaults to 30 days. `minutes=` wins over `days=` when both are set, and the start of the window is floored to the grain in UTC, so `days=7` covers today plus the six whole days before it. Paging is keyset: `nextCursor` is the `tmsg_` id of the last report on the page, and passing it back as `cursor=` with the same filters continues strictly after it, so every tracked message in the window is reachable. `list_all` and `iterate` do that walk for you.

Scopes: `emails:read`.

**Parameters**

- `limit` (`int`): Reports per page, from 1 to 200, defaulting to 50.
- `cursor` (`str`): The `nextCursor` from the previous page, a `tmsg_` id. One that names no tracked message in the workspace is a 400 `invalid_cursor`.
- `opened` (`bool`): Leave it out for both. `True` keeps opened messages and `False` keeps tracked messages nobody opened.
- `clicked` (`bool`): Leave it out for both. `True` keeps messages with a counted click and `False` keeps those without.
- `days` (`int`): Window length in days, a whole number from 1 to 365, defaulting to 30.
- `minutes` (`int`): Window length in minutes, from 1 to 527040. Takes precedence over `days`.
- `grain` (`TrackingGrain`): `minute`, `hour` or `day`, defaulting to `day`. It only floors the window start so this list matches `get_stats` read at the same grain, and shapes nothing in the response.
- `api_key` (`str`): Lists with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TrackingResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `id`, `sendId`, `subject`, `from`, `source`, `sentAt`, `opens`, `clicks`, `opened`, `clicked`, `attributable`, the counted and raw open and click counts, `recipients` and `links`.

**Example**

```python
from openemail import openemail

page = openemail.tracking.list(opened=False, days=7, limit=200)

for message in page['items']:
    print(message['sentAt'], message['subject'], len(message['recipients']))

if page['hasMore']:
    following = openemail.tracking.list(
        opened=False, days=7, limit=200, cursor=page['nextCursor']
    )

    print(len(following['items']))
```

**Notes**

- A window shorter than a day needs a finer grain. `minutes=60` at the default `day` grain is rounded to one bucket and starts at midnight UTC.
- A key with an address allowlist sees only messages sent from those addresses.
- Only messages that were actually sent are listed, so a scheduled message that has not gone yet is absent even if tracking was requested.

Also available in: API [`GET /tracking`](https://openemail.uk/docs/api/reference/tracking#get-tracking); TypeScript [`tracking.list()`](https://openemail.uk/docs/sdk/reference/tracking#list); Ruby [`tracking.list`](https://openemail.uk/docs/ruby/reference/tracking#list); CLI [`openemail tracking list`](https://openemail.uk/docs/cli/reference/tracking#tracking-list).

### `tracking.list_all()`

Collect every tracked message in a window into one list

```python
def list_all(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    opened: bool | None = None,
    clicked: bool | None = None,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TrackingResource]
```

Follows `nextCursor` from page to page and returns once the last page has been read, with every tracked message in the window in one list, newest first. It takes the same filters as `list` and returns the same full engagement reports.

Everything is held in memory before the call returns. Narrow the window or the filters, or switch to `iterate` when you want to stop early. `limit=` sets the page size of each underlying request, not the total.

Scopes: `emails:read`.

**Parameters**

- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): A `tmsg_` id to start after, skipping everything newer.
- `opened` (`bool`): Leave it out for both. `True` keeps opened messages and `False` keeps tracked messages nobody opened.
- `clicked` (`bool`): Leave it out for both. `True` keeps messages with a counted click and `False` keeps those without.
- `days` (`int`): Window length in days, a whole number from 1 to 365, defaulting to 30.
- `minutes` (`int`): Window length in minutes, from 1 to 527040. Takes precedence over `days`.
- `grain` (`TrackingGrain`): `minute`, `hour` or `day`, defaulting to `day`. It only floors the window start.
- `api_key` (`str`): Lists with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TrackingResource]` holding every report across all pages.

**Example**

```python
from openemail import openemail

unopened = openemail.tracking.list_all(opened=False, days=30, limit=200)

print(len(unopened), [message['subject'] for message in unopened])
```

**Notes**

- A failure on any page raises, and the reports already fetched are discarded.
- A key with an address allowlist sees only messages sent from those addresses, on every page.

Also available in: API [`GET /tracking`](https://openemail.uk/docs/api/reference/tracking#get-tracking); TypeScript [`tracking.listAll()`](https://openemail.uk/docs/sdk/reference/tracking#listAll); Ruby [`tracking.list_all`](https://openemail.uk/docs/ruby/reference/tracking#listAll).

### `tracking.iterate()`

Stream tracked messages one at a time across pages

```python
def iterate(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    opened: bool | None = None,
    clicked: bool | None = None,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TrackingResource]
```

Returns a generator that yields tracked messages one at a time, newest first, and requests the next page only once the current one is drained. Nothing is fetched until you start consuming it, and breaking out of the loop stops the requests.

The walk is keyset based, following `nextCursor` from page to page, so a message tracked while you iterate lands ahead of where you started and is never yielded, and nothing already yielded comes round again.

Scopes: `emails:read`.

**Parameters**

- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): A `tmsg_` id to start after, skipping everything newer.
- `opened` (`bool`): Leave it out for both. `True` keeps opened messages and `False` keeps tracked messages nobody opened.
- `clicked` (`bool`): Leave it out for both. `True` keeps messages with a counted click and `False` keeps those without.
- `days` (`int`): Window length in days, a whole number from 1 to 365, defaulting to 30.
- `minutes` (`int`): Window length in minutes, from 1 to 527040. Takes precedence over `days`.
- `grain` (`TrackingGrain`): `minute`, `hour` or `day`, defaulting to `day`. It only floors the window start.
- `api_key` (`str`): Lists with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TrackingResource]`, a generator that yields one report per step.

**Example**

```python
from openemail import openemail

for message in openemail.tracking.iterate(clicked=True, days=90):
    if any('/pricing' in link['url'] for link in message['links']):
        print(message['id'], message['subject'])
        break
```

**Notes**

- The generator is lazy, so an abandoned loop costs only the pages you consumed.

Also available in: API [`GET /tracking`](https://openemail.uk/docs/api/reference/tracking#get-tracking); TypeScript [`tracking.iterate()`](https://openemail.uk/docs/sdk/reference/tracking#iterate); Ruby [`tracking.iterate`](https://openemail.uk/docs/ruby/reference/tracking#iterate).

### `tracking.get_stats()`

Summarise engagement across a time window

```python
def get_stats(
    *,
    days: int | None = None,
    minutes: int | None = None,
    grain: TrackingGrain | None = None,
    offset_minutes: int | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TrackingStatsResource
```

Returns the numbers behind an engagement panel in one request: how many messages were tracked, opened and clicked, the open and click rates, hit totals, a time series, the top links, mail clients and countries. It covers every tracked message sent from the mailbox in the window, whichever surface sent it.

Rates and totals count different things, and mixing them is how open rates above 100% get published. `opened` and `clicked` count distinct messages. `totalOpens` and `totalClicks` count hits. `openRate` is a percentage of `trackedForOpens`, the messages that actually carried a pixel, and `clickRate` is a percentage of `trackedForClicks`, the messages that had a link to rewrite, which is usually far fewer than `tracked` because most replies contain no links.

`byDay` is sparse: a bucket in which nothing was sent has no entry, so a chart must fill the gaps. `grain=` sets the bucket width and the key shape, `YYYY-MM-DD`, `YYYY-MM-DDTHH` or `YYYY-MM-DDTHH:MM`, and `offset_minutes=` shifts bucket boundaries so days break where the reader's day does. The window starts at the beginning of the oldest bucket, so that bucket is whole, and ends now, so the newest is partial.

Scopes: `emails:read`.

**Parameters**

- `days` (`int`): Window length in days, from 1 to 365, defaulting to 30.
- `minutes` (`int`): Window length in minutes, from 1 to 527040. Takes precedence over `days`, and only useful below a day with a finer `grain`.
- `grain` (`TrackingGrain`): Bucket width for `byDay`: `minute`, `hour` or `day`, defaulting to `day`.
- `offset_minutes` (`int`): Minutes east of UTC to bucket in, from -840 to 840, defaulting to 0. Pass `time.localtime().tm_gmtoff // 60` for the zone the code runs in.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TrackingStatsResource` with `tracked`, `trackedForOpens`, `trackedForClicks`, `opened`, `clicked`, `openRate`, `clickRate`, `totalOpens`, `totalClicks`, `machineOpens`, `medianTimeToOpenSeconds` (`None` when nothing was opened), `byDay`, `topLinks`, `clients` and `countries`.

**Example**

```python
import time

from openemail import openemail

stats = openemail.tracking.get_stats(
    days=7, grain='day', offset_minutes=time.localtime().tm_gmtoff // 60
)

print(stats['openRate'], 'percent of', stats['trackedForOpens'], 'messages opened')
print(stats['byDay'], stats['topLinks'][:3])
```

**Notes**

- `openRate` and `clickRate` are percentages rounded to one decimal place, such as `42.5`, not fractions between 0 and 1.
- `machineOpens`, `clients` and `countries` are counted from individual hit records rather than from the counters on the per-message report.
- A key with an address allowlist gets figures for mail sent from those addresses only, not the whole workspace.
- `topLinks` holds at most 10 links that were clicked at least once, while `clients` and `countries` hold at most 8 each.

Also available in: API [`GET /tracking/stats`](https://openemail.uk/docs/api/reference/tracking#get-tracking-stats); TypeScript [`tracking.getStats()`](https://openemail.uk/docs/sdk/reference/tracking#getStats); Ruby [`tracking.get_stats`](https://openemail.uk/docs/ruby/reference/tracking#getStats); CLI [`openemail tracking get-stats`](https://openemail.uk/docs/cli/reference/tracking#tracking-get-stats).

### `tracking.get()`

Read one message's engagement report

```python
def get(
    id: str,
    *,
    api_key: str | None = None,
    timeout: float | None = None,
) -> TrackingResource
```

Returns the whole report for one tracked message: totals, one entry per tracked copy under `recipients`, and every rewritten link with its clicks under `links`. It accepts either identifier a caller may hold. A `msg_` id is looked up as the send it went out as, and anything else is treated as a `tmsg_` tracking id, which is what `tracking.list` and webhook payloads carry for mail sent outside this API.

`openCount` and `clickCount` are counted readings: opens that looked like a person, with repeat fetches within thirty seconds collapsed. `openCountRaw` and `clickCountRaw` include every hit, machine fetches such as Apple Mail Privacy Protection and corporate scanners among them. Gmail's image proxy is a real person displaying the message and is counted, but only once, because later views are served from its cache.

`recipients` is one entry per tracked copy, which is not always one entry per person. When one body went to the whole list, or the bytes could not vary per recipient, the shared copy has `email` set to `None` and `attributed` set to `False`. Branch on `attributed` and on the message-level `attributable` before saying that a named person has or has not read anything.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`TrackingResource` with `id`, `sendId`, `threadId`, `subject`, `from`, `source`, `sentAt`, `opens`, `clicks`, `opened`, `clicked`, `attributable`, counted and raw open and click counts, first and last open and click times, `recipients` and `links`.

**Example**

```python
from openemail import openemail

report = openemail.tracking.get('tmsg_8b2e4d71c09f3a65e1d7b402')
followed = [link['label'] or link['url'] for link in report['links'] if link['clickCount']]

print(report['opened'], report['openCount'], report['openCountRaw'])
print(followed)
```

**Notes**

- A 404 means nothing was tracked for that message, which is not the same as nobody having opened it.
- `opens` and `clicks` record what was applied when the message was sent. Turning tracking on later does not make earlier mail start reporting.
- Only links in the new part of a body are rewritten, so quoted history under a reply never appears in `links`.

Also available in: API [`GET /tracking/{id}`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id); TypeScript [`tracking.get()`](https://openemail.uk/docs/sdk/reference/tracking#get); Ruby [`tracking.get`](https://openemail.uk/docs/ruby/reference/tracking#get); CLI [`openemail tracking get`](https://openemail.uk/docs/cli/reference/tracking#tracking-get).

### `tracking.list_opens()`

List the individual opens behind a message's open count

```python
def list_opens(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TrackingOpenResource]
```

Returns one page of the raw open hits for one tracked message, newest first. `nextCursor` is the `opn_` id of the last hit on the page, and passing it back as `cursor=` continues strictly after it, so every stored hit is reachable. `list_all_opens` and `iterate_opens` do that walk for you. Each hit says which copy was fetched, how it was classified, whether it moved the numbers, and what the User-Agent and the edge could tell about the client, device and location.

`kind` is `human` for something that looked like a person reading, `proxy` for an image proxy such as Gmail's, which is a genuine reading by a reader the tracker cannot see, and `machine` for a scanner or Apple Mail Privacy Protection, which fetches on delivery and only proves the message arrived. By default only counted hits come back. `include_machine=True` adds the hits that did not count, which is every `machine` hit plus repeat fetches within thirty seconds of a counted one on the same copy, so it filters on `counted` rather than on `kind`.

A `msg_` send id is resolved first, and one that names no tracked message is a 404 rather than an empty list, since an empty list reads as nobody having opened it. A `tmsg_` id is taken as given without a lookup, so one that never existed or belongs to another workspace comes back as an empty list. Only read an empty result as an answer about readers when the id came from this API.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Hits per page, from 1 to 200, defaulting to 50.
- `cursor` (`str`): The `nextCursor` from the previous page, an `opn_` id. Anything else, or one from another message's log, is a 400 `invalid_cursor`.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`, machine fetches and collapsed repeats alike.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TrackingOpenResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `object` set to `open`, `id`, `trackedMessageId`, `recipient`, `kind`, `counted`, `userAgent`, `client`, `device`, `os`, `country`, `region`, `city` and `createdAt`.

**Example**

```python
from openemail import openemail

page = openemail.tracking.list_opens(
    'msg_3f9a1c07d2b84e6a9c5b1f20', include_machine=True, limit=200
)
machine = [hit for hit in page['items'] if hit['kind'] == 'machine']

print(len(page['items']), len(machine), page['hasMore'])
```

**Notes**

- An open row carries no IP address, and `country`, `region` and `city` are what the edge already knew, so on a proxied hit they describe the proxy rather than the reader. The address the request came from is still kept: it goes into the event log for the message, which `emails.list_events` returns, and into the `email.opened` webhook payload.
- `recipient` is `None` on a shared copy, for the same reason `email` on that copy's entry in `recipients` is `None` on the report.

Also available in: API [`GET /tracking/{id}/opens`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-opens); TypeScript [`tracking.listOpens()`](https://openemail.uk/docs/sdk/reference/tracking#listOpens); Ruby [`tracking.list_opens`](https://openemail.uk/docs/ruby/reference/tracking#listOpens); CLI [`openemail tracking list-opens`](https://openemail.uk/docs/cli/reference/tracking#tracking-list-opens).

### `tracking.list_all_opens()`

Collect every open hit on one message into one list

```python
def list_all_opens(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TrackingOpenResource]
```

Walks every page of one tracked message's open log and returns all of its hits in one list, newest first. It takes the same arguments as `list_opens`, so by default only counted hits come back and `include_machine=True` adds the rest.

A message opened by a mailing list of thousands can hold a great many rows. Prefer `iterate_opens` when you can stop early. `limit=` sets the page size of each request, not the total.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): An `opn_` id to start after, skipping every newer open.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TrackingOpenResource]` holding every hit across all pages, newest first.

**Example**

```python
from collections import Counter

from openemail import openemail

opens = openemail.tracking.list_all_opens(
    'msg_3f9a1c07d2b84e6a9c5b1f20', include_machine=True, limit=200
)
by_country = Counter(hit['country'] or 'unknown' for hit in opens)

print(by_country.most_common(5))
```

**Notes**

- A failure on any page raises, and the hits already fetched are discarded.
- The same id rules as `list_opens` apply: a `msg_` id that names no tracked message is a 404, and an unknown `tmsg_` id collects an empty list.

Also available in: API [`GET /tracking/{id}/opens`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-opens); TypeScript [`tracking.listAllOpens()`](https://openemail.uk/docs/sdk/reference/tracking#listAllOpens); Ruby [`tracking.list_all_opens`](https://openemail.uk/docs/ruby/reference/tracking#listAllOpens).

### `tracking.iterate_opens()`

Stream the open hits on one message one at a time

```python
def iterate_opens(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TrackingOpenResource]
```

Returns a generator over one tracked message's open log that yields hits one at a time, newest first, and fetches the next page only when the current one is drained. Nothing is requested until you consume it, and breaking out of the loop stops further requests.

The walk ends when `hasMore` is `False`. Hits recorded after the walk starts are newer than its cursor and are not yielded.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): An `opn_` id to start after, skipping every newer open.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TrackingOpenResource]`, a generator that yields one hit per step.

**Example**

```python
from openemail import openemail

for hit in openemail.tracking.iterate_opens('tmsg_8b2e4d71c09f3a65e1d7b402'):
    if hit['kind'] == 'human':
        print('Most recently read by a person at', hit['createdAt'], 'in', hit['client'])
        break
```

**Notes**

- The generator is lazy, so an abandoned loop costs only the pages you consumed.

Also available in: API [`GET /tracking/{id}/opens`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-opens); TypeScript [`tracking.iterateOpens()`](https://openemail.uk/docs/sdk/reference/tracking#iterateOpens); Ruby [`tracking.iterate_opens`](https://openemail.uk/docs/ruby/reference/tracking#iterateOpens).

### `tracking.list_clicks()`

List the individual link clicks on a message

```python
def list_clicks(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Page[TrackingClickResource]
```

Returns one page of the raw click hits for one tracked message, newest first. `nextCursor` is the `clk_` id of the last hit on the page, and passing it back as `cursor=` continues strictly after it, so every stored hit is reachable. `list_all_clicks` and `iterate_clicks` do that walk for you. Each hit has the same classification and client detail as an open, plus `linkId` and `url` naming the link that was followed, where `url` is the original destination rather than the redirect.

A click is stronger evidence of reading than an open. Mail clients block images far more often than readers leave links unfollowed, so a message with clicks and no opens was certainly read. Link scanners that follow every URL on delivery are classified as `machine` and excluded from the default result, and `include_machine=True` brings them back along with repeat hits collapsed by the thirty second window.

Only links in the new part of the body were rewritten, so nothing here can be a click on quoted history under a reply. The id rules match `list_opens`: a `msg_` id that names no tracked message is a 404, while a `tmsg_` id is used without a lookup and an unknown one returns an empty list.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Hits per page, from 1 to 200, defaulting to 50.
- `cursor` (`str`): The `nextCursor` from the previous page, a `clk_` id. Anything else, or one from another message's log, is a 400 `invalid_cursor`.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`, scanner clicks and collapsed repeats alike.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Page[TrackingClickResource]`, a dict with `items`, `hasMore` and `nextCursor`. Each item has `object` set to `click`, `id`, `trackedMessageId`, `linkId`, `url`, `recipient`, `kind`, `counted`, `userAgent`, `client`, `device`, `os`, `country`, `region`, `city` and `createdAt`.

**Example**

```python
from collections import Counter

from openemail import openemail

page = openemail.tracking.list_clicks('tmsg_8b2e4d71c09f3a65e1d7b402', limit=200)
by_url = Counter(click['url'] for click in page['items'])

print(by_url.most_common(), page['hasMore'])
```

**Notes**

- Repeated destinations share one `linkId` on the report, so a page linked from a header image, a button and a footer is one link. Group by `linkId` to match `links` on `tracking.get`.
- A key with an address allowlist gets a 404 for a `msg_` id sent from an address outside it, and a `tmsg_` id is then looked up rather than taken as given.

Also available in: API [`GET /tracking/{id}/clicks`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-clicks); TypeScript [`tracking.listClicks()`](https://openemail.uk/docs/sdk/reference/tracking#listClicks); Ruby [`tracking.list_clicks`](https://openemail.uk/docs/ruby/reference/tracking#listClicks); CLI [`openemail tracking list-clicks`](https://openemail.uk/docs/cli/reference/tracking#tracking-list-clicks).

### `tracking.list_all_clicks()`

Collect every click hit on one message into one list

```python
def list_all_clicks(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> builtins.list[TrackingClickResource]
```

Walks every page of one tracked message's click log and returns all of its hits in one list, newest first. It takes the same arguments as `list_clicks`, so by default only counted hits come back and `include_machine=True` adds the rest.

A message opened by a mailing list of thousands can hold a great many rows. Prefer `iterate_clicks` when you can stop early. `limit=` sets the page size of each request, not the total.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): A `clk_` id to start after, skipping every newer click.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`list[TrackingClickResource]` holding every hit across all pages, newest first.

**Example**

```python
from openemail import openemail

clicks = openemail.tracking.list_all_clicks('tmsg_8b2e4d71c09f3a65e1d7b402')
people = {click['recipient'] for click in clicks if click['recipient']}

print(len(people), 'people clicked')
```

**Notes**

- A failure on any page raises, and the hits already fetched are discarded.
- The same id rules as `list_clicks` apply: a `msg_` id that names no tracked message is a 404, and an unknown `tmsg_` id collects an empty list.

Also available in: API [`GET /tracking/{id}/clicks`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-clicks); TypeScript [`tracking.listAllClicks()`](https://openemail.uk/docs/sdk/reference/tracking#listAllClicks); Ruby [`tracking.list_all_clicks`](https://openemail.uk/docs/ruby/reference/tracking#listAllClicks).

### `tracking.iterate_clicks()`

Stream the click hits on one message one at a time

```python
def iterate_clicks(
    id: str,
    *,
    limit: int | None = None,
    cursor: str | None = None,
    include_machine: bool | None = None,
    api_key: str | None = None,
    timeout: float | None = None,
) -> Iterator[TrackingClickResource]
```

Returns a generator over one tracked message's click log that yields hits one at a time, newest first, and fetches the next page only when the current one is drained. Nothing is requested until you consume it, and breaking out of the loop stops further requests.

The walk ends when `hasMore` is `False`. Hits recorded after the walk starts are newer than its cursor and are not yielded.

Scopes: `emails:read`.

**Parameters**

- `id` (`str`, required): A `tmsg_` tracking id or the `msg_` send id the message went out as.
- `limit` (`int`): Page size per request, from 1 to 200, defaulting to 50 on the server.
- `cursor` (`str`): A `clk_` id to start after, skipping every newer click.
- `include_machine` (`bool`): Defaults to `False`. `True` also returns hits with `counted` set to `False`.
- `api_key` (`str`): Reads with this key instead of the client's.
- `timeout` (`float`): Seconds this call may take, the response included, before it raises `OpenEmailNetworkError` with `is_timeout`. It overrides the client's `timeout` for this call, and `0` turns the limit off.

**Returns**

`Iterator[TrackingClickResource]`, a generator that yields one hit per step.

**Example**

```python
from openemail import openemail

for click in openemail.tracking.iterate_clicks('tmsg_8b2e4d71c09f3a65e1d7b402'):
    if '/unsubscribe' in click['url']:
        print(click['recipient'], click['createdAt'])
        break
```

**Notes**

- The generator is lazy, so an abandoned loop costs only the pages you consumed.

Also available in: API [`GET /tracking/{id}/clicks`](https://openemail.uk/docs/api/reference/tracking#get-tracking-id-clicks); TypeScript [`tracking.iterateClicks()`](https://openemail.uk/docs/sdk/reference/tracking#iterateClicks); Ruby [`tracking.iterate_clicks`](https://openemail.uk/docs/ruby/reference/tracking#iterateClicks).
