---
title: "Open and click tracking"
description: "`emails.get_tracking` and the whole `tracking` namespace."
url: "https://openemail.uk/docs/ruby/emails/tracking"
area: "Ruby"
category: "Emails"
---

# Open and click tracking

`emails.get_tracking` and the whole `tracking` namespace.

## One message

**tracking.rb**

```
report = client.emails.get_tracking("msg_3f9a1c07d2b84e6a9c5b1f20")

puts "#{report[:openCount]} opens from #{report[:recipients].size} recipients"
report[:links].each { |link| puts "#{link[:url]} #{link[:clickCount]}" }
```

> A message that was never tracked raises an `OpenEmail::NotFoundError`, whose `not_found?` is true, not an empty report. "We recorded nothing" and "nobody opened it" are different answers and must not share a response. A message sent with a test key is never tracked, so it always raises one.

## Across the mailbox

**tracking_report.rb**

```
client.tracking.list(opened: false, days: 7, limit: 100)
client.tracking.get_stats(days: 30, offset_minutes: Time.now.utc_offset / 60)
client.tracking.get("msg_3f9a1c07d2b84e6a9c5b1f20")
client.tracking.list_opens("msg_3f9a1c07d2b84e6a9c5b1f20", include_machine: true)
client.tracking.list_clicks("msg_3f9a1c07d2b84e6a9c5b1f20")
```

`list`, `list_opens` and `list_clicks` return one `OpenEmail::Page`, and `list_all`, `iterate`, `list_all_opens`, `iterate_opens`, `list_all_clicks` and `iterate_clicks` walk every page for you. `get`, `list_opens` and `list_clicks` take either the `msg_…` send id or the tracking record’s own `tmsg_…`.

A namespace of its own rather than methods 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` and `clicks` | What was APPLIED: whether the message left with a pixel or rewritten links. |
| `opened` and `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.get_stats` are over TRACKED messages, never over everything sent. Otherwise a mailbox that tracks one message in ten would look like it had collapsed. `openRate` and `clickRate` are percentages rounded to one decimal place, such as `42.5`, not fractions between 0 and 1.

## 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` (Integer): How many days back from now to look, 1 to 365 and defaulting to 30, and 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.
- `minutes` (Integer): The window in minutes instead, from 1 to 527040, which wins over `days` when both are set. A window shorter than a day needs a finer `grain`.
- `grain` (String): `minute`, `hour` or `day`, defaulting to `day`. It only floors the start of the window, so this list matches `get_stats` read at the same grain, and shapes nothing in the response.
- `limit` (Integer): Reports per page, 1 to 200 and defaulting to 50, newest first. Pass the page’s `next_cursor` back as `cursor:`, with the same filters, for the next one, or let `list_all` and `iterate` walk the whole window.
- `cursor` (String): The `next_cursor` from the previous page, a `tmsg_` id.
- `api_key` (String): Lists with this key instead of the client’s.

## Response: the tracking report

`emails.get_tracking` and `tracking.get` return one report as a Hash with Symbol keys, and `tracking.list` returns a page of them.

- `object` (String): Always `tracking` on a report fetched in its own right, through `tracking.get`, `tracking.list` or `emails.get_tracking`. The same report nested as `tracking` on a message from `emails.get` arrives without this key, because there it is part of that message rather than something that was fetched.
- `id` (String): The tracking record’s own id, `tmsg_…`. It is what `list_opens` and `list_clicks` are keyed on, and a `msg_…` handed to them is looked up as this first.
- `sendId` (String or nil): The `msg_…` send this correlates to, and nil 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 or nil): Filled after transmission so a reading UI can find the message again, and nil where the driver reported none. Not load-bearing: a record with it nil still counts.
- `messageId` (String or nil): The RFC 5322 Message-ID, not our id. Also filled after transmission, and nil where the transport returned nothing to fill it with.
- `subject` (String or nil): The subject as it was at send time. nil 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` (String): Which surface sent it: `composer`, `api`, `mcp`, `ai` or `queue`. A surface this gem does not name yet may appear, so treat an unknown value as information rather than an error.
- `sentAt` (String or nil): When the message went, as an ISO 8601 instant. nil on a record whose send never completed. `tracking.list` leaves those out, `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` (Integer): 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` (Integer): Counted clicks, summed over the copies. Deduplicated per link rather than per message, so two different links followed seconds apart are two clicks.
- `openCountRaw` (Integer): Every pixel fetch, scanners and privacy proxies included. `openCountRaw` minus `openCount` is how many were set aside, machine fetches and repeats within thirty seconds together, and the only evidence available that the filtering happened at all.
- `clickCountRaw` (Integer): Every visit to a rewritten link, machine hits and repeats included.
- `firstOpenAt` (String or nil): The earliest counted open across the copies, and nil while there is none. Machine hits never move it.
- `lastOpenAt` (String or nil): The most recent counted open across the copies, nil while there is none.
- `firstClickAt` (String or nil): The earliest counted click across the copies, nil while there is none.
- `lastClickAt` (String or nil): The most recent counted click across the copies, nil while there is none.
- `recipients` (Array<Hash>): 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.
- `links` (Array<Hash>): 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.

**Each entry in recipients**

- `email` (String or nil): Who this copy went to, lower-cased and as it was at send time. nil exactly when `attributed` is false.
- `kind` (String or nil): `to`, `cc` or `bcc`: which header the address appeared on, so a report reads the way the message did. nil on the shared copy, which belongs to no one address.
- `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.
- `openCount` (Integer): 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.
- `clickCount` (Integer): Counted clicks on this copy alone, deduplicated per link rather than per copy.
- `firstOpenAt` (String or nil): The earliest counted open on this copy, nil while there is none.
- `lastOpenAt` (String or nil): The most recent counted open on this copy, nil while there is none.
- `firstClickAt` (String or nil): The earliest counted click on this copy, nil while there is none.
- `lastClickAt` (String or nil): The most recent counted click on this copy, nil while there is none.

**Each entry in links**

- `id` (String): The link’s own id, `lnk_…`. It is the value a click row’s `linkId` names, so a hit from `list_clicks` can be matched back to the entry here.
- `url` (String): Where the link actually goes, as it was in the message before rewriting. The redirector looks an id up to this and sends the visitor on.
- `label` (String or nil): The anchor text as it appeared in the message, or nil 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 stands in for `url`.
- `clickCount` (Integer): Counted visits to this link, summed over the copies. The same per-link thirty-second window as `clickCount` on the message.
- `clickCountRaw` (Integer): Every visit to this link, machine hits and repeats included.
