Open and click tracking
GET /tracking: whether a message was read, and what was followed.
Runs any of the 6 calls on this page against your workspace, with your own key.
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.
{ "from": "Acme Billing <[email protected]>", "to": ["[email protected]"], "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
{ "object": "tracking", "id": "tmsg_9c1f7b2e4a5d40b8a3e61d2f", "sendId": "msg_c5f21cc6bfec4e848caf905b", "threadId": "thread_2f9b…", "messageId": "<2598…@acme.com>", "subject": "Your September invoice", "from": "[email protected]", "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": "[email protected]", "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
{ "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
{ "object": "list", "data": [ { "object": "open", "id": "opn_1a7c…", "trackedMessageId": "tmsg_9c1f7b2e4a5d40b8a3e61d2f", "recipient": "[email protected]", "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
proxyrather thanmachine: 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
humanwith the clientOpenEmail. 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.
From the SDK
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.