Suivi des ouvertures et des clics
GET /tracking : si un message a été lu, et ce qui a été suivi.
Exécute n'importe lequel des 6 appels de cette page sur votre espace de travail, avec votre propre clé.
Ce qui est enregistré
Deux interrupteurs indépendants, tous deux activés sauf s'ils ont été désactivés pour l'adresse d'expédition d'un message ou pour Toutes les adresses. opens ajoute une image de 1×1 ; clicks réécrit les liens de la partie nouvelle du corps. L'historique cité sous une réponse est le message de quelqu'un d'autre et reste intact. Un envoi nomme tracking: { opens, clicks } pour trancher pour un seul message (dans les deux sens, false étant la façon pour un programme de refuser ce que l'adresse est réglée pour faire), et un champ que vous omettez retombe sur le réglage de l'adresse d'expédition, puis sur Toutes les adresses, plutôt que sur une valeur par défaut que cette API aurait choisie pour le compte d'un espace de travail.
{ "from": "Acme Billing <[email protected]>", "to": ["[email protected]"], "subject": "Your September invoice", "html": "<p>Invoice attached.</p>", "tracking": { "opens": true, "clicks": true } }Au plus 100 destinations par message sont réécrites, une fois chacune. La même URL liée depuis une image d'en-tête, un bouton et un pied de page ne fait qu'une ligne, parce que c'est une seule question posée trois fois. Au-delà du plafond, les liens restants sont laissés exactement tels qu'ils ont été écrits : un lien non suivi fonctionne toujours, et un message qui perd silencieusement ses deux cents derniers liens est un échec bien pire qu'un rapport incomplet.
Les liens réécrits et le pixel pointent par défaut vers l'hôte de l'API OpenEmail. Lorsque le domaine d'envoi possède un domaine de suivi personnalisé dont le tracking.status vaut active, le nouveau courrier issu de ce domaine utilise https://<tracking host>/t/... à la place, et c'est PATCH /domains/{id} qui permet d'en définir un.
Tout ceci exige emails:read, et il n'existe pas de portée dédiée au suivi. Cette portée signifie déjà « lire les messages envoyés et leur statut de livraison », et savoir si quelqu'un a ouvert un message est le statut de livraison le plus littéral qui soit.
Les points de terminaison
| Appel | Renvoie |
|---|---|
| `GET /tracking` | Les messages suivis, les plus récents d'abord. opened, clicked, days (1–365, 30 par défaut), limit (200 au maximum). |
| `GET /tracking/stats` | Les taux sur une fenêtre. days (30 par défaut) et offsetMinutes, pour que les jours se découpent là où se découpe la journée du lecteur. |
| `GET /tracking/{id}` | Un rapport. Accepte un identifiant de suivi tmsg_ ou l'identifiant msg_ renvoyé par un envoi. |
| `GET /tracking/{id}/opens` | Les récupérations individuelles. includeMachine, limit (200 au maximum). |
| `GET /tracking/{id}/clicks` | La même chose, avec linkId et url sur chaque ligne. |
| `GET /emails/{id}/tracking` | Le même rapport, à partir de l'identifiant d'envoi que vous détenez déjà. |
Les booléens s'écrivent en toutes lettres dans la chaîne de requête : true, false, 1 ou 0, et tout le reste est refusé. Boolean("false") vaut true, si bien qu'un ?opened=false converti par coercition renverrait exactement l'inverse de ce qui a été demandé.
C'est une ressource à part entière plutôt que quelques champs sur /emails, pour une question de couverture : cette liste contient des enregistrements d'envoi, or le compositeur, les outils MCP et l'assistant envoient tous sans en écrire un. Un rapport bâti dessus serait un rapport sur votre trafic API plutôt que sur la boîte aux lettres.
Le rapport
{ "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 et clicks sont ce qui a été APPLIQUÉ au message ; opened et clicked sont ce qui s'est passé. openCount compte les lectures et openCountRaw compte les récupérations. L'écart, ici de quatre, ce sont les scanners et les proxys de confidentialité, conservés pour que la différence entre le journal et le total soit inspectable plutôt qu'inexpliquée. attributable est le champ à lire avant de nommer qui que ce soit : false signifie qu'une lecture a atterri sur une copie envoyée à toute la liste, et toute phrase portant ensuite sur un destinataire en particulier est une supposition.
source nomme la surface qui l'a envoyé : api pour un envoi via cette API, composer pour tout ce que l'application elle-même a envoyé. sendId vaut null dans le second cas, ce qui explique l'existence de l'identifiant de suivi.
Une ligne avec un email à null et attributed: false est l'endroit où atterrit une lecture qui n'a pas pu être rattachée à une personne, et un rapport n'en affiche une que lorsqu'une lecture a effectivement eu lieu. Un message avec un seul destinataire n'en a aucune, parce qu'un corps et un destinataire sont la même affirmation. Un message qui en compte plusieurs en a une derrière lui dès son départ, parce que le transport n'est fixé qu'à l'expédition, et elle reste hors du rapport jusqu'à ce que quelque chose y arrive : un « quelqu'un : non ouvert » permanent à côté des destinataires nommés est une ligne qui ne peut qu'être mal lue. Là où elle EST présente, les lignes nommées sont celles qui restent à zéro et attributable vaut false. La lecture est réelle, le lecteur est l'une des personnes du message, et « quelqu'un sur ce message » est le seul rendu que les données autorisent. Ne complétez jamais le nom à partir de la liste des destinataires.
Les taux sur une fenêtre
{ "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 }] }Les taux sont des pourcentages sur les messages SUIVIS, et non sur l'ensemble du courrier envoyé : un espace de travail qui suit un message sur dix a un taux d'ouverture pour ces dix-là, et diviser par tout ce qu'il a jamais envoyé le ferait chuter chaque fois que quelqu'un envoie une réponse non suivie. Un message ouvert cinq fois est UN message ouvert. Les taux comptent des messages et les totaux comptent des impacts, et confondre les deux est la façon dont sont publiés des taux d'ouverture supérieurs à 100 %.
byDay est creux : un jour où rien n'a été suivi est absent plutôt qu'à zéro, comblez donc les trous avant de le représenter graphiquement. Les jours sont regroupés à offsetMinutes à l'est d'UTC (−840 à 840) pour qu'ils se découpent là où se découpe la journée du lecteur. medianTimeToOpenSeconds est une médiane et non une moyenne, parce qu'un message ouvert trois semaines plus tard tire une moyenne vers un endroit où ne se trouve aucun message.
Les impacts individuels
{ "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 vaut human, proxy ou machine, et counted indique si l'impact a fait bouger les chiffres. Les impacts machine sont exclus sauf si vous passez includeMachine=true, ce qui est la valeur par défaut honnête : ils sont enregistrés parce que les jeter laisserait un écart inexplicable, non parce qu'ils constituent de l'engagement.
La localisation est grossière parce qu'il n'y a rien d'autre. Aucune adresse IP n'est conservée pour le moindre impact. Le pays, la région et la ville sont ce que la bordure du réseau savait déjà, et le seul autre identifiant conservé est un hachage dont le sel tourne chaque jour, si bien qu'il permet de distinguer deux récupérations au sein d'une même journée et devient inerte le lendemain.
Ce que les chiffres ne peuvent pas dire
- Apple Mail Privacy Protection récupère chaque image de chaque message à la livraison, que quelqu'un le consulte ou non. C'est classé à partir du User-Agent et du réseau et enregistré comme
machine, tout comme l'est ce qui arrive dans les dix secondes suivant l'envoi, parce que rien de ce que fait une personne ne va aussi vite. - Le proxy d'images de Gmail est
proxyplutôt quemachine: quelqu'un a affiché le message, l'ouverture est donc réelle, tandis que l'appareil, le client et la localisation ne sont pas connaissables. Le proxy met aussi en cache, si bien qu'une seconde lecture peut ne jamais nous parvenir. Les comptages passant par Gmail sont un plancher, jamais un total. - Deux récupérations de la même copie en moins de trente secondes ne font qu'une lecture. Un volet d'aperçu qui se redessine ou un message ramené à l'écran par défilement récupère à nouveau l'image ; la véritable seconde visite une heure plus tard est bien comptée.
- Nommer le destinataire exige un message assez petit pour être reconstruit par personne : la taille estimée multipliée par le nombre de destinataires doit rester sous 8 Mo. Au-delà, un seul corps part vers tout le monde, et chaque impact dessus est non attribué.
- Un message avec des clics et aucune ouverture a certainement été lu : les images sont bloquées bien plus souvent que les liens ne restent sans clic. Lisez les deux compteurs séparément plutôt que de les additionner.
- Demander le suivi des clics sur un corps sans liens n'enregistre absolument rien : les octets partis sont identiques à ceux d'un envoi non suivi, et une ligne prétendant le contraire ne pourrait être rapprochée de quoi que ce soit. Il en va de même pour un message sans corps à réécrire.
- OpenEmail retire les images de 1×1 du courrier que lisent ses propres utilisateurs, y compris le pixel qu'il envoie lui-même, et enregistre l'ouverture de son côté lorsqu'un message est affiché images visibles. Cet impact est
humanavec le clientOpenEmail. Images masquées, rien n'est enregistré.
GET /tracking/{id} et GET /emails/{id}/tracking répondent 404 pour un message qui n'a jamais été suivi, plutôt qu'un rapport vide. Les formules « nous n'avons rien enregistré » et « personne ne l'a ouvert » sont des réponses différentes et ne doivent pas partager la même réponse HTTP. Le point de terminaison de liste ne contient que des messages suivis : un message non suivi en est donc simplement absent plutôt que présent avec des zéros.
Être prévenu plutôt que demander
Une ouverture comptée déclenche email.opened et un clic compté déclenche email.clicked sur chaque point de terminaison abonné, et les deux sont inscrits dans la piste d'événements propre au message lorsqu'il est passé par cette API. Ni l'un ni l'autre ne se déclenche pour un scanner ou un proxy de confidentialité. Les pousser remplirait le journal d'un récepteur exactement du trafic que le classifieur existe pour tenir hors des chiffres.
Un fichier parti sous forme de lien de téléchargement se rapporte de la même façon. Un téléchargement compté déclenche email.downloaded et atterrit sur la même piste, et le même classifieur en tient les scanners et les générateurs d'aperçus de liens à l'écart, si bien que le compte correspond à des personnes. La charge utile nomme le fichier (shareId, fileId, filename, mimeType, sizeBytes, url) avec downloadCount, first et downloadedAt à côté des champs de client et de localisation que porte un clic. recipient est toujours null et attributed toujours false : un lien de téléchargement est une seule URL pour tous les destinataires du message, un téléchargement ne peut donc pas être rattaché à l'un d'eux.
Depuis le 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(),})Chaque appel ici est une simple lecture, et le client réessaie chacun d'eux de lui-même. get lève une OpenEmailApiError dont isNotFound vaut true pour un message qui n'a jamais été suivi, et c'est la distinction qui mérite d'être préservée dans ce que vous en alimentez.