---
title: "Open and click tracking"
description: "GET /tracking: whether a message was read, and what was followed."
url: "https://openemail.uk/docs/api/emails/tracking"
area: "API"
category: "Emails"
---

# Open and click tracking

GET /tracking: whether a message was read, and what was followed.

**Also documents:** `GET /emails/{id}/tracking`, `GET /tracking`, `GET /tracking/stats`, `GET /tracking/{id}`, `GET /tracking/{id}/opens`, `GET /tracking/{id}/clicks`

## What gets recorded

Two independent switches, both on unless they were turned off for the address a message is sent from or for All addresses. `opens` appends a 1×1 image; `clicks` rewrites the links in the new part of the body. The quoted history under a reply is somebody else’s message and is left alone. A send names `tracking: { opens, clicks }` to decide for one message (in either direction, so `false` is how a program declines what the address is set to do), and a field you omit falls back to the setting of the address it is sent from, then to All addresses, rather than to a default this API picked on a workspace’s behalf.

**POST /emails**

```
{
    "from": "Acme Billing <billing@acme.com>",
    "to": ["ada@example.com"],
    "subject": "Your September invoice",
    "html": "<p>Invoice attached.</p>",
    "tracking": { "opens": true, "clicks": true }
  }
```

At most 100 destinations per message are rewritten, once each. The same URL linked from a header image, a button and a footer is one row, because it is one question asked three times. Past the cap the remaining links are left exactly as they were written: an untracked link still works, and a message that silently loses its last two hundred links is a far worse failure than an incomplete report.

Rewritten links and the pixel point at the OpenEmail API host by default. When the sending domain has a custom tracking domain whose `tracking.status` is `active`, new mail from that domain uses `https://<tracking host>/t/...` instead, and `PATCH /domains/{id}` is where you set one.

> All of this needs `emails:read`, and there is no tracking scope. That scope already means "read sent messages and their delivery status", and whether somebody opened a message is the most literal possible delivery status.

## The endpoints

| Call | Returns |
| --- | --- |
| `GET /tracking` | Tracked messages, newest first. `opened`, `clicked`, `days` (1–365, default 30), `limit` (max 200). |
| `GET /tracking/stats` | Rates over a window. `days` (default 30) and `offsetMinutes`, so days break where the reader’s day breaks. |
| `GET /tracking/{id}` | One report. Takes a `tmsg_` tracking id or the `msg_` id a send returned. |
| `GET /tracking/{id}/opens` | The individual fetches. `includeMachine`, `limit` (max 200). |
| `GET /tracking/{id}/clicks` | The same, with `linkId` and `url` on each row. |
| `GET /emails/{id}/tracking` | The same report, from the send id you already hold. |

> Booleans are spelled out in the query string: `true`, `false`, `1` or `0`, and anything else is refused. `Boolean("false")` is true, so a coerced `?opened=false` would return the exact opposite of what was asked for.

This is a resource of its own rather than a few fields on `/emails` because of coverage: that list holds send records, and the composer, the MCP tools and the assistant all send without writing one. A report built on it would be a report about your API traffic rather than about the mailbox.

## The report

**GET /tracking/tmsg_9c1f7b2e4a5d40b8a3e61d2f**

```
{
    "object": "tracking",
    "id": "tmsg_9c1f7b2e4a5d40b8a3e61d2f",
    "sendId": "msg_c5f21cc6bfec4e848caf905b",
    "threadId": "thread_2f9b…",
    "messageId": "<2598…@acme.com>",
    "subject": "Your September invoice",
    "from": "billing@acme.com",
    "source": "api",
    "sentAt": "2026-08-29T08:19:08.000Z",
    "opens": true,
    "clicks": true,
    "opened": true,
    "clicked": true,
    "attributable": true,
    "openCount": 3,
    "openCountRaw": 7,
    "clickCount": 1,
    "clickCountRaw": 2,
    "firstOpenAt": "2026-08-29T09:04:11.000Z",
    "lastOpenAt": "2026-08-30T07:42:55.000Z",
    "firstClickAt": "2026-08-29T09:05:02.000Z",
    "lastClickAt": "2026-08-29T09:05:02.000Z",
    "recipients": [
      {
        "email": "ada@example.com",
        "kind": "to",
        "attributed": true,
        "openCount": 3,
        "clickCount": 1,
        "firstOpenAt": "2026-08-29T09:04:11.000Z",
        "lastOpenAt": "2026-08-30T07:42:55.000Z",
        "firstClickAt": "2026-08-29T09:05:02.000Z",
        "lastClickAt": "2026-08-29T09:05:02.000Z"
      }
    ],
    "links": [
      {
        "id": "lnk_4f0a1c8d29b74e6fa3c05d17",
        "url": "https://acme.com/invoices/42",
        "label": "View invoice",
        "clickCount": 1,
        "clickCountRaw": 2
      }
    ]
  }
```

`opens` and `clicks` are what was APPLIED to the message; `opened` and `clicked` are what happened. `openCount` counts readings and `openCountRaw` counts fetches. The difference, four here, is the scanners and the privacy proxies, kept so the gap between the log and the total is inspectable rather than unexplained. `attributable` is the field to read before naming anybody: false means a reading landed on a copy that went to the whole list, and every sentence about a particular recipient after that is a guess.

`source` names the surface that sent it: `api` for a send through this API, `composer` for everything the app itself sent. `sendId` is null for the second kind, which is why the tracking id exists.

> A row with a null `email` and `attributed: false` is where a reading lands that could not be pinned to a person, and a report shows one only when a reading actually did. A message with a single recipient has none at all, because one body and one addressee are the same statement. A message with several has one behind it from the moment it went out, because the transport is not settled until dispatch, and it stays out of the report until something arrives on it: a permanent “someone: not opened” beside the named recipients is a row that can only be misread. Where it IS present the named rows are the ones sitting at zero and `attributable` is `false`. The reading is real, the reader is one of the people on the message, and “someone on this message” is the only rendering the data supports. Never fill the name in from the recipient list.

## Rates over a window

**GET /tracking/stats?days=30&offsetMinutes=60**

```
{
    "object": "tracking_stats",
    "tracked": 128,
    "trackedForOpens": 128,
    "trackedForClicks": 47,
    "opened": 91,
    "clicked": 34,
    "openRate": 71.1,
    "clickRate": 72.3,
    "totalOpens": 240,
    "totalClicks": 52,
    "machineOpens": 173,
    "medianTimeToOpenSeconds": 2714,
    "byDay": [{ "day": "2026-08-27", "sent": 12, "opened": 9, "clicked": 3 }],
    "topLinks": [{ "url": "https://acme.com/pricing", "label": "See pricing", "clickCount": 18 }],
    "clients": [{ "client": "Gmail", "count": 96 }],
    "countries": [{ "country": "GB", "count": 71 }]
  }
```

The rates are percentages over TRACKED messages, not over all sent mail: a workspace that tracks one message in ten has an open rate for those ten, and dividing by everything it ever sent would fall every time somebody sent an untracked reply. A message opened five times is ONE opened message. The rates count messages and the totals count hits, and conflating the two is how open rates above 100% get published.

> `byDay` is sparse: a day on which nothing was tracked is absent rather than zero, so gap-fill before charting it. Days are bucketed at `offsetMinutes` east of UTC (−840 to 840) so they break where the reader’s day does. `medianTimeToOpenSeconds` is a median rather than a mean, because one message opened three weeks late drags an average somewhere no message actually is.

## The individual hits

**GET /tracking/tmsg_…/opens?includeMachine=true**

```
{
    "object": "list",
    "data": [
      {
        "object": "open",
        "id": "opn_1a7c…",
        "trackedMessageId": "tmsg_9c1f7b2e4a5d40b8a3e61d2f",
        "recipient": "ada@example.com",
        "kind": "machine",
        "counted": false,
        "client": "Apple Mail Privacy Protection",
        "device": "unknown",
        "os": "macOS",
        "country": "GB",
        "region": "England",
        "city": "London",
        "createdAt": "2026-08-29T08:19:11.000Z"
      }
    ]
  }
```

`kind` is `human`, `proxy` or `machine`, and `counted` says whether it moved the numbers. Machine hits are excluded unless you pass `includeMachine=true`, which is the honest default: they are recorded because dropping them would leave an unexplainable gap, not because they are engagement.

> The location is coarse because it is all there is. No IP address is stored for any hit. The country, region and city are what the edge already knew, and the only other identifier kept is a hash whose salt rotates daily, so it can tell two fetches apart within a day and is inert the day after.

## What the numbers cannot say

- Apple Mail Privacy Protection fetches every image in every message on delivery whether or not anyone looks. It is classified from the User-Agent and the network and recorded as `machine`, and so is anything arriving within ten seconds of the send, because nothing a person does happens that fast.
- Gmail’s image proxy is `proxy` rather than `machine`: somebody displayed the message, so the open is real, while the device, the client and the location are not knowable. The proxy also caches, so a second reading may never reach us at all. Counts through Gmail are a floor, never a total.
- Two fetches of the same copy within thirty seconds are one reading. A preview pane redrawing or a message scrolled back into view refetches the image; the genuine second visit an hour later is still counted.
- Naming the recipient needs a message small enough to rebuild per person: the estimated size times the recipient count has to come in under 8MB. Above that one body goes to everyone, and every hit on it is unattributed.
- A message with clicks and no opens has certainly been read: images are blocked far more often than links go unclicked. Read the two counters separately rather than adding them.
- Asking for clicks on a body with no links records nothing at all: the bytes that went out are identical to an untracked send, and a row claiming otherwise could not be reconciled with anything. The same is true of a message with no body to rewrite.
- OpenEmail strips 1×1 images out of the mail its own users read, the pixel it sends included, and records the open itself when a message is displayed with images showing. That hit is `human` with the client `OpenEmail`. With images hidden nothing is recorded.

> `GET /tracking/{id}` and `GET /emails/{id}/tracking` answer `404` for a message that was never tracked, rather than an empty report. The phrases "we recorded nothing" and "nobody opened it" are different answers and must not share a response. The list endpoint holds only tracked messages, so an untracked one is simply absent from it rather than present with zeroes.

## Being told rather than asking

A counted open fires `email.opened` and a counted click fires `email.clicked` at every subscribed endpoint, and both are written to the message’s own event trail where it went through this API. Neither fires for a scanner or a privacy proxy. Pushing those would fill a receiver’s log with the exact traffic the classifier exists to keep out of the numbers.

A file that went out as a download link reports the same way. A counted download fires `email.downloaded` and lands on the same trail, and the same classifier keeps scanners and link previewers out of it, so the count is people. The payload names the file (`shareId`, `fileId`, `filename`, `mimeType`, `sizeBytes`, `url`) with `downloadCount`, `first` and `downloadedAt` beside the client and location fields a click carries. `recipient` is always null and `attributed` always false: a download link is one URL for every recipient of the message, so a download cannot be pinned to one of them.

- [Webhooks](https://openemail.uk/docs/sdk/webhooks/verify.md): The events, their payloads, and verifying a delivery.

## From the SDK

**openemail.tracking**

```
const report = await openemail.tracking.get('msg_c5f21cc6bfec…')
const cold = await openemail.tracking.list({ days: 30, opened: false })
const stats = await openemail.tracking.getStats({
  days: 30,
  offsetMinutes: -new Date().getTimezoneOffset(),
})
```

Every call here is a plain read, and the client retries each one on its own. `get` throws an `OpenEmailApiError` whose `isNotFound` is true for a message that was never tracked, which is the distinction worth preserving in whatever you feed it into.
