---
title: "Open and click tracking"
description: "`emails.getTracking` and the whole `tracking` resource."
url: "https://openemail.uk/docs/sdk/emails/tracking"
area: "SDK"
category: "Emails"
---

# Open and click tracking

`emails.getTracking` and the whole `tracking` resource.

## One message

**tracking.ts**

```
const report = await openemail.emails.getTracking('msg_…')

console.log(report.openCount, 'opens from', report.recipients.length, 'recipients')
for (const link of report.links) console.log(link.url, link.clickCount)
```

> A message that was never tracked throws an `OpenEmailApiError` whose `isNotFound` is true, not an empty report. "We recorded nothing" and "nobody opened it" are different answers and must not share a response.

## Across the mailbox

**tracking-report.ts**

```
await openemail.tracking.list({ opened: false, days: 7, limit: 100 })
await openemail.tracking.getStats({ days: 30, offsetMinutes: -new Date().getTimezoneOffset() })
await openemail.tracking.get('msg_…')
await openemail.tracking.listOpens('msg_…', { includeMachine: true })
await openemail.tracking.listClicks('msg_…')
```

`list`, `listOpens` and `listClicks` resolve to plain arrays. `get`, `listOpens` and `listClicks` take either the `msg_…` send id or the tracking record’s own `tmsg_…`.

A resource of its own rather than fields on `emails`, and the reason is coverage: `emails` lists send records, which exist only for mail this API handled. The composer, the MCP tools and the assistant all send without one, so a report built on `emails` would be a report about your API traffic rather than about the mailbox.

## Reading the numbers honestly

| Pair | What it means |
| --- | --- |
| `opens` / `clicks` | What was APPLIED: whether the message left with a pixel or rewritten links. |
| `opened` / `clicked` | What happened. |
| `openCount` | Counted hits. Scanners and privacy proxies excluded. |
| `openCountRaw` | Every hit. Quoting this as engagement is how an open rate exceeds 100%. |
| `attributable` | Whether a reading can be pinned to a named recipient at all. |

> Rates from `tracking.getStats` are over TRACKED messages, never over everything sent. Otherwise a mailbox that tracks one message in ten would look like it had collapsed.

## Parameters: tracking.list

- `opened` (boolean): `true` selects messages with at least one counted open, `false` selects tracked messages with none. Neither is a default, and `false` never means untracked mail, which does not appear in this list at all.
- `clicked` (boolean): The same filter for counted clicks, applied independently of `opened`. Both may be given, and messages must satisfy both.
- `days` (number): How many days back from now to look, 1 to 365 and defaulting to 30; outside that range it is a 422. The window is measured on when the tracking record was created, and only records whose send actually went out are listed.
- `limit` (number): At most this many messages, 1 to 200 and defaulting to 50, newest first. There is no cursor: this is a report over a window rather than a feed, so it is bounded by `days` and `limit` and read whole.

## Response: TrackingResource

- `object` ('tracking'): Always `'tracking'` on a report fetched in its own right, through `tracking.get`, `tracking.list` or `emails.getTracking`. The same report nested as `email.tracking` on a retrieved message arrives without this key, because there it is part of that object rather than something that was fetched.
- `id` (string): The tracking record's own id, `tmsg_…`. It is what the per-hit calls `listOpens` and `listClicks` are keyed on; a `msg_…` handed to them is resolved to this first.
- `sendId` (string | null): The `msg_…` send this correlates to, and null where no send record was written. The composer, MCP's `sendEmail` and the assistant all send without one. Tracking covers the mailbox, not only API traffic.
- `threadId` (string | null): Filled after transmission so a reading UI can find the message again, and null where the driver reported none. Not load-bearing: a record with it null still counts.
- `messageId` (string | null): The RFC 5322 Message-ID, not our id. Also filled after transmission, and null where the transport returned nothing to fill it with.
- `subject` (string | null): The subject as it was at send time. Null on a message recorded without one.
- `from` (string): The sending address, copied onto the record rather than joined from the send. Reports are read long after the fact, and an address corrected or removed since would otherwise rewrite history.
- `source` (EmailSource | (string & {})): Which surface sent it: `composer`, `api`, `mcp`, `ai` or `queue`. Typed open so a surface this SDK does not yet name is not a breaking change.
- `sentAt` (string | null): When the message went, as an ISO-8601 instant. Null on a record whose send never completed. `tracking.list` excludes those, `get` does not.
- `opens` (boolean): Whether a pixel was APPLIED to this message. This is what was done, not what the account setting says now.
- `clicks` (boolean): Whether this message's links were rewritten. False when the body carried no links, because then nothing was changed and a record claiming otherwise could not be reconciled with the bytes.
- `opened` (boolean): Whether any counted open was recorded across the copies. Read it against `opens`: no data because none was collected is a different fact from nobody having read the message.
- `clicked` (boolean): Whether any counted click was recorded. Stronger evidence than an open, since images are blocked far more often than links go unfollowed.
- `attributable` (boolean): Whether every reading here can be pinned to a named recipient. False the moment an unattributed copy shows counted activity, which is the multi-recipient case where one body goes to the whole list under one token, so check it before writing "Bob has not opened this".
- `openCount` (number): Opens believed to have been caused by a person, summed over the copies. Machine hits are excluded and repeats within thirty seconds collapse into one, so this is the figure to put in front of a reader.
- `clickCount` (number): Counted clicks, summed over the copies. Deduplicated per link rather than per message, so two different links followed seconds apart are two clicks.
- `openCountRaw` (number): Every pixel fetch, scanners and privacy proxies included. `openCountRaw - openCount` is how many the classifier set aside, and the only evidence available that the filtering happened at all.
- `clickCountRaw` (number): Every visit to a rewritten link, machine hits and repeats included.
- `firstOpenAt` (string | null): The earliest counted open across the copies, and null while there is none. Machine hits never move it.
- `lastOpenAt` (string | null): The most recent counted open across the copies, null while there is none.
- `firstClickAt` (string | null): The earliest counted click across the copies, null while there is none.
- `lastClickAt` (string | null): The most recent counted click across the copies, null while there is none.
- `recipients` (TrackingRecipientResource[]): One entry per tracked copy: per recipient where the transport lets the bytes differ per person, and a single shared entry where it does not. The shared entry is dropped unless something actually landed on it, so an untouched "someone" row never sits beside real names.
- `recipients[].email` (string | null): Who this copy went to, lowercased and as it was at send time. Null exactly when `attributed` is false.
- `recipients[].kind` ('to' | 'cc' | 'bcc' | null): Which header the address appeared on, so a report reads the way the message did. Null on the shared copy, which belongs to no one address.
- `recipients[].attributed` (boolean): Whether this row names a person. Read it before `email`: false is the shared copy, listed as soon as any hit lands on it, and putting a name to that hit, even on a message with a single recipient, would invent the one fact the mechanism cannot supply.
- `recipients[].openCount` (number): Counted opens on this copy alone, under the same exclusions as the message total: machine hits dropped, and repeats within thirty seconds collapsed into one.
- `recipients[].clickCount` (number): Counted clicks on this copy alone, deduplicated per link rather than per copy.
- `recipients[].firstOpenAt` (string | null): The earliest counted open on this copy, null while there is none.
- `recipients[].lastOpenAt` (string | null): The most recent counted open on this copy, null while there is none.
- `recipients[].firstClickAt` (string | null): The earliest counted click on this copy, null while there is none.
- `recipients[].lastClickAt` (string | null): The most recent counted click on this copy, null while there is none.
- `links` (TrackingLinkResource[]): Every link that was rewritten in this message, ordered by where it sat in the body. Empty where none were: a message sent with `clicks` off, or one whose body carried no link at all.
- `links[].id` (string): The link's own id, `lnk_…`. It is the value a click row's `linkId` names, so a hit from `listClicks` can be matched back to the entry here.
- `links[].url` (string): Where the link actually goes, as it was in the message before rewriting. The redirector resolves an id back to this and sends the visitor on.
- `links[].label` (string | null): The anchor text as it appeared in the message, or null where the link had none, such as an image or a bare URL. It is there so a report can say "the pricing link" rather than quote a URL with three tracking parameters on it, and it never substitutes for `url`.
- `links[].clickCount` (number): Counted visits to this link, summed over the copies. The same per-link thirty-second window as `clickCount` on the message.
- `links[].clickCountRaw` (number): Every visit to this link, machine hits and repeats included.
