Tracking
Every operation in this group: what it accepts, what it returns and the errors it can answer with.
Operations
Who read the mail and what they followed. Off unless the workspace turned it on, per user and per send.
Every count here is a floor rather than a total, and that is the mechanism rather than a defect: an open is inferred from a mail client fetching an image, so a reader whose client blocks images reads without being counted, and Gmail fetches the image once through its own proxy and serves every later view from cache. Mail from one OpenEmail mailbox to another never reports an open at all. OpenEmail strips 1×1 images out of what its own users read, and it makes no exception for its own pixel. A message with no opens has not been shown to be unread; a click is the stronger evidence, because links go unfollowed far less often than images go unloaded.
GET/emails/{id}/tracking
How a message was read
The same document /tracking/{id} serves, reached from the id a caller already holds. A message that was never tracked is a 404 here rather than an empty report, because "we were not recording" and "nobody opened it" are different answers and a client that renders them the same way makes a claim about a reader on no evidence.
Requires the emails:read scope.
Path parameters
idstringRequiredThe
msg_send id returned bysend.
Returns
The full report, with per-recipient and per-link detail.
Errors
- 404
No such message, or nothing was tracked for it.
The errors every operation can return400401403422500Error catalog
Also available in
GET/tracking
List tracked messages
Every message the workspace tracked in the window, newest first, one page at a time, not only the ones sent through this API. The composer, the MCP tools and the assistant mostly send through the mailbox agent, which writes the send record itself and never links it to the tracking row, so those messages carry no counts on /emails and only this list holds them.
opened=false means tracked and not opened. Messages that carried no pixel are absent from this list entirely and never appear as a zero. Follow nextCursor with the same filters to reach every tracked message in the window.
Requires the emails:read scope.
Query parameters
openedbooleanOmit for both.
falsenarrows to tracked-and-unopened.clickedbooleanOmit for both.
truekeeps messages with a counted click andfalsekeeps those without.daysintegerHow far back to look. The window starts at the beginning of that day rather than at this time of day, and ends now.
At least 1At most 365Default30minutesintegerThe window in minutes, which wins over
dayswhen both are sent. A whole number of days cannot say "the last hour", which is the report worth having while a send is going out.At least 1At most 527040grainstringOnly used to floor the start of the window, so this list can cover the same window as
/tracking/statsread at the same grain. It shapes nothing in the response.One of"minute""hour""day"Default"day"limitintegerRows per page, 1 to 200.
At least 1At most 200Default50cursorstringA
tmsg_tracking id. Keyset, not offset: pass the previous page'snextCursor. One that names nothing in this list is a 400invalid_cursor.
Returns
A page of tracked messages, newest first.
Errors
The errors every operation can return400401403404422500Error catalog
Also available in
GET/tracking/stats
Engagement over a window
The numbers behind an engagement panel, in one request. Rates are over tracked messages and count distinct messages; the totals count hits. Read the field descriptions before charting any of it. The two are easy to mix and the result is an open rate above 100%.
A key is the workspace's own authority and sees the whole workspace. The per-member address grants that narrow this in the app belong to a session, and a key has none.
Requires the emails:read scope.
Query parameters
daysintegerHow far back to look. The window starts at the beginning of that day in the offset you asked for, so the oldest
byDaybucket is a whole one, and ends now, so the newest is partial.At least 1At most 365Default30minutesintegerThe window in minutes, which wins over
dayswhen both are sent. A whole number of days cannot say "the last hour", which is the report worth having while a send is going out.At least 1At most 527040grainstringHow wide one
byDaybucket is. An hour of mail bucketed by day is a single entry, so a window shorter than a day is only worth asking for alongside a grain that can describe it. The bucket keys change shape with it:YYYY-MM-DDfor a day,YYYY-MM-DDTHHfor an hour,YYYY-MM-DDTHH:MMfor a minute.One of"minute""hour""day"Default"day"offsetMinutesintegerMinutes east of UTC to bucket
byDayin, so days break where the reader's day breaks. Left at zero, this morning's mail lands on yesterday's bar for anyone west of UTC.At least -840At most 840Default0
Returns
The window, summarised. byDay is sparse.
Errors
The errors every operation can return400401403404422500Error catalog
Also available in
GET/tracking/{id}
Retrieve one message's tracking
The whole report: per-recipient counts where the transport could attribute them, and every rewritten link with its clicks.
Requires the emails:read scope.
Path parameters
idstringRequiredA
tmsg_tracking id or themsg_send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.
Returns
The report.
Errors
- 404
Nothing was tracked for that message. Not the same as nobody having opened it.
The errors every operation can return400401403422500Error catalog
Also available in
GET/tracking/{id}/opens
The individual opens
The raw hits behind openCount, newest first, one page at a time. Every stored hit is reachable by following nextCursor.
A msg_ send id is resolved first, and one that names nothing is a 404 rather than an empty list. An empty list reads as "nobody opened it", which is the one answer this endpoint must never give by accident. A tmsg_ is taken as given and costs no lookup, so an id that never existed, or one belonging to another workspace, does come back here as an empty list. Only ids this API handed you can be read as an answer about a reader.
Requires the emails:read scope.
Path parameters
idstringRequiredA
tmsg_tracking id or themsg_send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.
Query parameters
includeMachinebooleanInclude the hits that did not count: Apple Mail Privacy Protection, corporate scanners, and the repeats collapsed by the thirty-second window. Off by default, and that default is the honest one. Those fetches are stored so the gap between the raw and counted totals stays inspectable, not because anybody read anything. Gmail's image proxy is not among them and is never hidden by this. Send
trueorfalse:?includeMachine=falsemeans false here, which is worth saying because the obvious coercion would make it true.DefaultfalselimitintegerRows per page, 1 to 200.
At least 1At most 200Default50cursorstringAn
opn_open id from this message. Keyset, not offset: pass the previous page'snextCursor. One that names nothing in this list is a 400invalid_cursor.
Returns
A page of opens, newest first.
Errors
The errors every operation can return400401403404422500Error catalog
Also available in
GET/tracking/{id}/clicks
The individual clicks
As the opens, plus which link was followed, one page at a time. Only links in the new part of the body were rewritten, so nothing here can be a click on quoted history.
Requires the emails:read scope.
Path parameters
idstringRequiredA
tmsg_tracking id or themsg_send id the message went out as. Both work, because a caller who sent through this API holds the second and has no reason to know the first exists.
Query parameters
includeMachinebooleanInclude the hits that did not count: Apple Mail Privacy Protection, corporate scanners, and the repeats collapsed by the thirty-second window. Off by default, and that default is the honest one. Those fetches are stored so the gap between the raw and counted totals stays inspectable, not because anybody read anything. Gmail's image proxy is not among them and is never hidden by this. Send
trueorfalse:?includeMachine=falsemeans false here, which is worth saying because the obvious coercion would make it true.DefaultfalselimitintegerRows per page, 1 to 200.
At least 1At most 200Default50cursorstringA
clk_click id from this message. Keyset, not offset: pass the previous page'snextCursor. One that names nothing in this list is a 400invalid_cursor.
Returns
A page of clicks, newest first.
Errors
The errors every operation can return400401403404422500Error catalog
Also available in
Objects
Clickobject
objectstring- One of
"click" idstringtrackedMessageIdstringrecipientstringNull for the same reason
recipients[].emailis: shared bytes, unknowable reader.Can be nullkindstringhumanlooked like somebody reading.proxyis an image proxy, Gmail's above all: a genuine reading by a reader we cannot see, counted once and then cached out of our sight.machineis a scanner or Apple Mail Privacy Protection, which fetches on delivery and means only that the message arrived.One of"human""proxy""machine"countedbooleanWhether this hit moved the numbers. False for every
machinehit, and false again for a hit landing within thirty seconds of the last counted one on the same copy. A preview pane redrawing is the same reading rather than a second one.includeMachinefilters on this field and not onkind, so it is what hides those collapsed repeats as well.clientstring- Can be null
devicestring- Can be null
osstringRead out of the User-Agent, which is a claim rather than a fact and is absent altogether on plenty of hits.
Can be nullcountrystringWhat the edge already knew about the request, so this is as precise as the location will ever be, and on a proxied hit it is the proxy's country rather than the reader's. There is no ip field on this resource, but the address is not discarded: it is written into the event log for the message, readable at
GET /emails/{id}/events, and into theemail.openedandemail.clickedwebhook payloads.Can be nullregionstring- Can be null
citystring- Can be null
createdAtstring- Format
date-time linkIdstringurlstringThe destination that was followed.
ClickListobject
objectstring- One of
"list" dataClick[]hasMorebooleanTrue when another page follows. Pass
nextCursorback ascursorto read it.nextCursorstringThe id of the last row on this page, or null on the last page.
Can be null
Openobject
objectstring- One of
"open" idstringtrackedMessageIdstringrecipientstringNull for the same reason
recipients[].emailis: shared bytes, unknowable reader.Can be nullkindstringhumanlooked like somebody reading.proxyis an image proxy, Gmail's above all: a genuine reading by a reader we cannot see, counted once and then cached out of our sight.machineis a scanner or Apple Mail Privacy Protection, which fetches on delivery and means only that the message arrived.One of"human""proxy""machine"countedbooleanWhether this hit moved the numbers. False for every
machinehit, and false again for a hit landing within thirty seconds of the last counted one on the same copy. A preview pane redrawing is the same reading rather than a second one.includeMachinefilters on this field and not onkind, so it is what hides those collapsed repeats as well.clientstring- Can be null
devicestring- Can be null
osstringRead out of the User-Agent, which is a claim rather than a fact and is absent altogether on plenty of hits.
Can be nullcountrystringWhat the edge already knew about the request, so this is as precise as the location will ever be, and on a proxied hit it is the proxy's country rather than the reader's. There is no ip field on this resource, but the address is not discarded: it is written into the event log for the message, readable at
GET /emails/{id}/events, and into theemail.openedandemail.clickedwebhook payloads.Can be nullregionstring- Can be null
citystring- Can be null
createdAtstring- Format
date-time
OpenListobject
objectstring- One of
"list" dataOpen[]hasMorebooleanTrue when another page follows. Pass
nextCursorback ascursorto read it.nextCursorstringThe id of the last row on this page, or null on the last page.
Can be null
Trackingobject
Every /tracking endpoint returns this whole, its list included. It arrives trimmed in exactly one place, under tracking on a GET /emails row, where it is the counts half only: opens, clicks, opened, clicked, openCount, clickCount and firstOpenAt. A page of fifty sends each carrying its recipients and its links is a report nobody asked to have expanded. The trimmed form has no id on it either, so /emails/{id}/tracking rather than /tracking/{id} is the way back to the rest of it.
objectstringPresent when the report is the whole response body. Absent under an email's
trackingfield, which is part of that email rather than a resource in its own right.One of"tracking"idstringThe tracking record,
tmsg_+ 24 hex. Not the message id and not the send id.sendIdstringThe
msg_this went out as, when the send service handled it. Null for mail the mailbox agent sent on its own behalf, which is most composer, MCP and assistant traffic. Those messages do get a send record, but nothing links this tracking row to it. Tracking covers the mailbox rather than only the traffic that came through this API.Can be nullthreadIdstring- Can be null
messageIdstringRFC 5322 Message-ID. Not a correlation key. The header is rewritten on the way out. Correlate on
id.Can be nullsubjectstring- Can be null
fromstringsourcestring- One of
"api""oauth""composer""mcp""ai""form" sentAtstring- Can be nullFormat
date-time opensbooleanWhat was APPLIED to this message, resolved when it was sent from the setting of the address it was sent from (its own, else its domain catch-all's, else off, while a broadcast copy is on unless the broadcast or its address turned it off) and any per-send override, not what is switched on now. Turning tracking on today does not make yesterday's mail start reporting, and a report that implied otherwise would read as "nobody opened it".
clicksbooleanAs
opens, for link rewriting. The two are independent switches.openedbooleanclickedbooleanattributablebooleanWhether every reading on this message can be pinned to a named recipient. False as soon as an unattributed copy has activity of its own, which is what happens whenever one body went to the whole list rather than a separate one per person. This is the flag that decides whether "Bob has not opened it" is a sentence a client is entitled to write, or whether all it may say is that somebody did.
recipientscarries the same fact one row at a time, and one row at a time is where it gets missed.openCountintegerOpens that looked like a person, with repeat fetches within thirty seconds collapsed. A preview pane redrawing is not a second reading. Through Gmail this is a floor and not a total: its proxy fetches the image once and caches it, so later readings never reach us.
clickCountintegerCounted clicks. Stronger evidence than an open, and worth weighting as such: images are blocked far more often than links go unfollowed, so a message with clicks and no opens was certainly read.
openCountRawintegerEvery open hit, the automated ones included.
openCountRaw - openCountis everything that was filtered out: Apple Mail Privacy Protection and corporate link scanners, which fetch on delivery whether or not a person ever looks, and alongside them the repeat fetches collapsed by the thirty-second window. Both are recorded and neither is counted, because discarding them outright would leave a gap in the log that nothing could explain. Do not read the difference as a machine count on its own. A message reopened twice in a minute lands in it too.clickCountRawintegerAs
openCountRaw, for clicks.firstOpenAtstring- Can be nullFormat
date-time lastOpenAtstring- Can be nullFormat
date-time firstClickAtstring- Can be nullFormat
date-time lastClickAtstring- Can be nullFormat
date-time recipientsobject[]One entry per tracked copy, which is not always one entry per person. Absent only from the trimmed form on a
GET /emailsrow; every/trackingresponse carries it, list included.emailstringNull where the bytes could not be varied per person: an encrypted message, one too large to rebuild for each recipient, or a fallback carrier that takes the whole recipient list in a single call. The reading is real; which of the recipients did it is not knowable, and the only honest rendering is "someone on this message", never a name chosen out of the list.
Can be nullkindstring- Can be nullOne of
"to""cc""bcc" attributedbooleanFalse on exactly the rows described above. Branch on this rather than on
emailbeing a string, and show nothing where it is false: attributing an unattributed open to a named recipient invents evidence about a specific person.openCountintegerclickCountintegerfirstOpenAtstring- Can be nullFormat
date-time lastOpenAtstring- Can be nullFormat
date-time firstClickAtstring- Can be nullFormat
date-time lastClickAtstring- Can be nullFormat
date-time
linksobject[]The rewritten links, in the order they appeared in the message. Only links in the new part of the body are here: the quoted history under a reply belongs to whoever wrote it, and routing their URLs through our redirector would both rewrite their message and record the recipient "clicking" something we did not put there. Repeated destinations share one entry, because a campaign page linked from a header image, a button and a footer is one question asked three times. Absent only from the trimmed form on a
GET /emailsrow.idstringurlstringWhere it actually goes: the original href.
labelstringThe text the link read as in the message, where it had any. A bare URL rarely tells the sender which of five links somebody followed.
Can be nullclickCountintegerclickCountRawinteger
TrackingListobject
objectstring- One of
"list" dataTracking[]hasMorebooleanTrue when another page follows. Pass
nextCursorback ascursorto read it.nextCursorstringThe id of the last row on this page, or null on the last page.
Can be null
TrackingStatsobject
objectstring- One of
"tracking_stats" trackedintegerMessages in the window that carried a pixel or a rewritten link.
openedintegerOf those, how many a person opened at least once. Distinct MESSAGES, not hits. A message opened five times is one opened message, and conflating the two is how open rates above 100% get published.
clickedintegertrackedForOpensintegerThe denominator of
openRate. Tracking is two switches rather than one, so this is the messages that actually carried a pixel, not every tracked message.trackedForClicksintegerThe denominator of
clickRate, and the reason it is published. A message with no links is never click-tracked, so counting it against the click rate makes that rate a measure of how much of your mail contains a link. Most mail is a reply with no links, so the difference fromtrackedis usually large.openRatenumberA percentage of
trackedForOpens, deliberately not of everything sent. A workspace that tracks one message in ten has an open rate for those ten; dividing by all its mail produces a number that falls every time somebody sends an untracked reply, which is not a fact about how anyone is reading.clickRatenumberA percentage of
trackedForClicks, asopenRateis oftrackedForOpens.totalOpensintegerHits rather than messages. Labelled as a total because it is the easy one to misread.
totalClicksintegermachineOpensintegerHits excluded from every number above: the sum of
openCountRaw - openCount, so Apple Mail Privacy Protection and corporate scanners together with the repeats the thirty-second window collapsed. Reported rather than hidden, because a reader who cannot see how much of the traffic was machinery has no way to judge the rest of the panel. Gmail's image proxy is not in this number: that fetch is a real person displaying the message, and it is counted.medianTimeToOpenSecondsintegerMedian seconds from send to first counted open, over the messages that were opened at all. Null when none were. A median over an empty set is not zero.
Can be nullbyDayobject[]SPARSE. A day on which nothing was sent has no entry rather than a row of zeroes, so a chart has to fill the gaps itself. Days break at
offsetMinuteseast of UTC, so that they break where the reader's day does rather than where the database's does.daystringThe bucket, in the requested offset, written in the shape
grainasked for:YYYY-MM-DDfor a day,YYYY-MM-DDTHHfor an hour,YYYY-MM-DDTHH:MMfor a minute. The field keeps its name at every width because it is the bucket key whatever the bucket is.sentintegeropenedintegerclickedinteger
topLinksobject[]urlstringlabelstring- Can be null
clickCountinteger
clientsobject[]Mail clients by counted opens, as far as a User-Agent can be trusted to name one.
clientstringcountinteger
countriesobject[]countrystringcountinteger