Open and click tracking
`emails.get_tracking` and the whole `tracking` namespace.
One message
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
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
openedBoolean- `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.
clickedBoolean- The same filter for counted clicks, applied independently of `opened`. Both may be given, and messages must satisfy both.
daysInteger- 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.
minutesInteger- 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`.
grainString- `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.
limitInteger- 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.
cursorString- The `next_cursor` from the previous page, a `tmsg_` id.
api_keyString- 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.
objectString- 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.
idString- 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.
sendIdString 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.
threadIdString 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.
messageIdString 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.
subjectString or nil- The subject as it was at send time. nil on a message recorded without one.
fromString- 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.
sourceString- 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.
sentAtString 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.
opensBoolean- Whether a pixel was APPLIED to this message. This is what was done, not what the account setting says now.
clicksBoolean- 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.
openedBoolean- 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.
clickedBoolean- Whether any counted click was recorded. Stronger evidence than an open, since images are blocked far more often than links go unfollowed.
attributableBoolean- 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".
openCountInteger- 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.
clickCountInteger- Counted clicks, summed over the copies. Deduplicated per link rather than per message, so two different links followed seconds apart are two clicks.
openCountRawInteger- 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.
clickCountRawInteger- Every visit to a rewritten link, machine hits and repeats included.
firstOpenAtString or nil- The earliest counted open across the copies, and nil while there is none. Machine hits never move it.
lastOpenAtString or nil- The most recent counted open across the copies, nil while there is none.
firstClickAtString or nil- The earliest counted click across the copies, nil while there is none.
lastClickAtString or nil- The most recent counted click across the copies, nil while there is none.
recipientsArray<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.
linksArray<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
emailString or nil- Who this copy went to, lower-cased and as it was at send time. nil exactly when `attributed` is false.
kindString 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.
attributedBoolean- 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.
openCountInteger- 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.
clickCountInteger- Counted clicks on this copy alone, deduplicated per link rather than per copy.
firstOpenAtString or nil- The earliest counted open on this copy, nil while there is none.
lastOpenAtString or nil- The most recent counted open on this copy, nil while there is none.
firstClickAtString or nil- The earliest counted click on this copy, nil while there is none.
lastClickAtString or nil- The most recent counted click on this copy, nil while there is none.
Each entry in links
idString- 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.
urlString- 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.
labelString 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`.
clickCountInteger- Counted visits to this link, summed over the copies. The same per-link thirty-second window as `clickCount` on the message.
clickCountRawInteger- Every visit to this link, machine hits and repeats included.