Przejdź do dokumentacji
API

Śledzenie otwarć i kliknięć

GET /tracking: czy wiadomość została przeczytana i w które odnośniki kliknięto.

GETapi.openemail.uk/emails/{id}/tracking

Uruchamia dowolne z 6 wywołań na tej stronie na twojej przestrzeni roboczej, twoim własnym kluczem.

Co jest zapisywane

Dwa niezależne przełączniki, oba włączone, chyba że wyłączono je dla adresu, z którego wiadomość jest wysyłana, albo dla ustawienia „Wszystkie adresy”. opens dokleja obrazek 1×1; clicks przepisuje odnośniki w nowej części treści. Cytowana historia pod odpowiedzią jest czyjąś cudzą wiadomością i zostaje nietknięta. Wysyłka podaje tracking: { opens, clicks }, żeby zdecydować o jednej wiadomości (w obie strony, więc false to sposób, w jaki program odmawia tego, co adres ma ustawione), a pole, które pominiesz, przyjmuje ustawienie adresu, z którego wiadomość wychodzi, potem ustawienie „Wszystkie adresy” — a nie wartość domyślną, którą to API wybrało za przestrzeń roboczą.

POST /emails
{    "from": "Acme Billing <[email protected]>",    "to": ["[email protected]"],    "subject": "Your September invoice",    "html": "<p>Invoice attached.</p>",    "tracking": { "opens": true, "clicks": true }  }

Przepisywanych jest najwyżej 100 adresów docelowych na wiadomość, każdy raz. Ten sam URL podlinkowany z obrazka nagłówka, z przycisku i ze stopki to jeden wiersz, bo to jedno pytanie zadane trzy razy. Powyżej limitu pozostałe odnośniki zostają dokładnie takie, jakie zostały napisane: nieśledzony odnośnik nadal działa, a wiadomość, która po cichu gubi ostatnie dwieście odnośników, to awaria znacznie gorsza niż niekompletny raport.

Przepisane odnośniki i piksel domyślnie wskazują na host API OpenEmail. Gdy domena nadawcza ma własną domenę śledzącą, której tracking.status to active, nowa poczta z tej domeny używa zamiast tego https://<tracking host>/t/..., a ustawia się ją przez PATCH /domains/{id}.

Wszystko to wymaga emails:read i nie ma osobnego zakresu dla śledzenia. Ten zakres już teraz oznacza „czytaj wysłane wiadomości i ich status dostarczenia”, a to, czy ktoś otworzył wiadomość, jest najbardziej dosłownym możliwym statusem dostarczenia.

Endpointy

WywołanieZwraca
`GET /tracking`Śledzone wiadomości, od najnowszych. opened, clicked, days (1–365, domyślnie 30), limit (maks. 200).
`GET /tracking/stats`Wskaźniki w oknie czasu. days (domyślnie 30) i offsetMinutes, żeby doby łamały się tam, gdzie łamie się doba czytającego.
`GET /tracking/{id}`Jeden raport. Przyjmuje identyfikator śledzenia tmsg_ albo identyfikator msg_ zwrócony przez wysyłkę.
`GET /tracking/{id}/opens`Poszczególne pobrania. includeMachine, limit (maks. 200).
`GET /tracking/{id}/clicks`To samo, z linkId i url w każdym wierszu.
`GET /emails/{id}/tracking`Ten sam raport, na podstawie identyfikatora wysyłki, który już masz.

Wartości logiczne zapisuje się w query stringu wprost: true, false, 1 albo 0, a cokolwiek innego jest odrzucane. Boolean("false") daje true, więc rzutowane ?opened=false zwróciłoby dokładną odwrotność tego, o co poproszono.

To osobny zasób, a nie kilka pól na /emails, z powodu pokrycia: tamta lista zawiera rekordy wysyłek, a kompozytor, narzędzia MCP i asystent wysyłają, nie zapisując takiego rekordu. Raport zbudowany na niej byłby raportem o twoim ruchu w API, a nie o skrzynce.

Raport

GET /tracking/tmsg_9c1f7b2e4a5d40b8a3e61d2f
{    "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 i clicks mówią, co ZASTOSOWANO do wiadomości; opened i clicked mówią, co się wydarzyło. openCount liczy odczyty, a openCountRaw liczy pobrania. Różnica, tutaj cztery, to skanery i proxy prywatności, zachowane po to, by lukę między dziennikiem a sumą dało się zbadać, zamiast zostawiać ją bez wyjaśnienia. attributable to pole, które trzeba przeczytać, zanim wskaże się kogokolwiek: false oznacza, że odczyt trafił na kopię, która poszła do całej listy, i każde zdanie o konkretnym odbiorcy jest od tego momentu zgadywaniem.

source nazywa powierzchnię, która wysłała wiadomość: api dla wysyłki przez to API, composer dla wszystkiego, co wysłała sama aplikacja. sendId jest null dla tego drugiego rodzaju i właśnie dlatego istnieje identyfikator śledzenia.

Wiersz z pustym email i attributed: false to miejsce, w którym ląduje odczyt niedający się przypisać do osoby, a raport pokazuje taki wiersz tylko wtedy, gdy odczyt faktycznie nastąpił. Wiadomość z jednym odbiorcą nie ma go wcale, bo jedna treść i jeden adresat to to samo stwierdzenie. Wiadomość z kilkoma odbiorcami ma go za sobą od chwili wyjścia, ponieważ transport jest ustalony dopiero przy wysyłce, a wiersz pozostaje poza raportem, dopóki coś na niego nie trafi: trwałe „ktoś: nie otworzył” obok nazwanych odbiorców to wiersz, który można tylko źle odczytać. Tam, gdzie JEST obecny, nazwane wiersze są tymi, które stoją na zerze, a attributable ma wartość false. Odczyt jest prawdziwy, czytający to jedna z osób na wiadomości, a „ktoś z tej wiadomości” jest jedynym renderowaniem, które dane uzasadniają. Nigdy nie uzupełniaj nazwiska z listy odbiorców.

Wskaźniki w oknie czasu

GET /tracking/stats?days=30&offsetMinutes=60
{    "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 }]  }

Wskaźniki są procentami liczonymi po wiadomościach ŚLEDZONYCH, a nie po całej wysłanej poczcie: przestrzeń robocza, która śledzi jedną wiadomość na dziesięć, ma wskaźnik otwarć dla tych dziesięciu, a dzielenie przez wszystko, co kiedykolwiek wysłała, obniżałoby go za każdym razem, gdy ktoś wyśle nieśledzoną odpowiedź. Wiadomość otwarta pięć razy to JEDNA otwarta wiadomość. Wskaźniki liczą wiadomości, a sumy liczą trafienia, i mylenie jednego z drugim to sposób, w jaki publikuje się wskaźniki otwarć powyżej 100%.

byDay jest rzadkie: dzień, w którym nic nie było śledzone, jest nieobecny, a nie zerowy, więc przed wykresem uzupełnij luki. Doby są kubełkowane offsetMinutes na wschód od UTC (−840 do 840), żeby łamały się tam, gdzie łamie się doba czytającego. medianTimeToOpenSeconds jest medianą, a nie średnią, ponieważ jedna wiadomość otwarta trzy tygodnie później ciągnie średnią tam, gdzie nie ma żadnej wiadomości.

Poszczególne trafienia

GET /tracking/tmsg_…/opens?includeMachine=true
{    "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 to human, proxy albo machine, a counted mówi, czy trafienie poruszyło liczby. Trafienia maszynowe są pomijane, chyba że przekażesz includeMachine=true, co jest uczciwym domyślnym zachowaniem: są zapisywane dlatego, że ich pominięcie zostawiłoby niewyjaśnialną lukę, a nie dlatego, że są zaangażowaniem.

Lokalizacja jest zgrubna, bo to wszystko, co jest. Dla żadnego trafienia nie jest przechowywany adres IP. Kraj, region i miasto to to, co i tak wiedział edge, a jedynym innym zachowywanym identyfikatorem jest skrót, którego sól zmienia się codziennie, więc potrafi odróżnić dwa pobrania w obrębie doby i jest bezużyteczny nazajutrz.

Czego liczby nie powiedzą

  • Apple Mail Privacy Protection pobiera każdy obrazek w każdej wiadomości przy dostarczeniu, niezależnie od tego, czy ktoś ją ogląda. Jest to klasyfikowane na podstawie User-Agent i sieci oraz zapisywane jako machine; tak samo jest z wszystkim, co przychodzi w ciągu dziesięciu sekund od wysyłki, bo nic, co robi człowiek, nie dzieje się tak szybko.
  • Proxy obrazków Gmaila to proxy, a nie machine: ktoś wyświetlił wiadomość, więc otwarcie jest prawdziwe, natomiast urządzenie, klient i lokalizacja są niepoznawalne. Proxy także cache'uje, więc drugi odczyt może w ogóle do nas nie dotrzeć. Liczby przechodzące przez Gmaila są dolną granicą, nigdy sumą.
  • Dwa pobrania tej samej kopii w ciągu trzydziestu sekund to jeden odczyt. Panel podglądu przerysowujący się albo wiadomość przewinięta z powrotem w pole widzenia ponownie pobierają obrazek; prawdziwa druga wizyta godzinę później nadal jest liczona.
  • Wskazanie odbiorcy wymaga wiadomości na tyle małej, by dało się ją odbudować dla każdej osoby: szacowany rozmiar razy liczba odbiorców musi zmieścić się poniżej 8MB. Powyżej tego jedna treść idzie do wszystkich, a każde trafienie na niej jest nieprzypisane.
  • Wiadomość z kliknięciami i bez otwarć na pewno została przeczytana: obrazki są blokowane o wiele częściej, niż odnośniki pozostają nieklikane. Czytaj oba liczniki osobno, zamiast je sumować.
  • Prośba o śledzenie kliknięć w treści bez odnośników nie zapisuje niczego: bajty, które wyszły, są identyczne jak przy nieśledzonej wysyłce, a wiersz twierdzący inaczej nie dałby się z niczym pogodzić. To samo dotyczy wiadomości bez treści do przepisania.
  • OpenEmail usuwa obrazki 1×1 z poczty, którą czytają jego właśni użytkownicy — włącznie z pikselem, który sam wysyła — i zapisuje otwarcie samodzielnie, gdy wiadomość jest wyświetlana z widocznymi obrazkami. Takie trafienie ma human i klienta OpenEmail. Przy ukrytych obrazkach nic nie jest zapisywane.

GET /tracking/{id} i GET /emails/{id}/tracking odpowiadają 404 dla wiadomości, która nigdy nie była śledzona, zamiast pustym raportem. Sformułowania „nic nie zapisaliśmy” i „nikt tego nie otworzył” to różne odpowiedzi i nie mogą dzielić jednej odpowiedzi HTTP. Endpoint listy zawiera wyłącznie śledzone wiadomości, więc nieśledzona jest z niej po prostu nieobecna, a nie obecna z zerami.

Powiadomienie zamiast odpytywania

Policzone otwarcie wyzwala email.opened, a policzone kliknięcie email.clicked na każdym subskrybowanym endpoincie, i oba są zapisywane na własnym śladzie zdarzeń wiadomości tam, gdzie przeszła ona przez to API. Żadne z nich nie wyzwala się dla skanera ani proxy prywatności. Wypychanie ich zapełniłoby dziennik odbiorcy dokładnie tym ruchem, który klasyfikator ma trzymać z dala od liczb.

Plik, który wyszedł jako odnośnik do pobrania, raportuje się tak samo. Policzone pobranie wyzwala email.downloaded i ląduje na tym samym śladzie, a ten sam klasyfikator trzyma z dala skanery i podglądy odnośników, więc licznik to ludzie. Ładunek nazywa plik (shareId, fileId, filename, mimeType, sizeBytes, url) wraz z downloadCount, first i downloadedAt, obok pól klienta i lokalizacji, które niesie kliknięcie. recipient jest zawsze null, a attributed zawsze false: odnośnik do pobrania to jeden URL dla każdego odbiorcy wiadomości, więc pobrania nie da się przypisać do jednego z nich.

Z poziomu SDK

openemail.tracking
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(),})

Każde wywołanie tutaj jest zwykłym odczytem, a klient ponawia każde z osobna. get rzuca OpenEmailApiError, którego isNotFound jest true dla wiadomości, która nigdy nie była śledzona — i to jest rozróżnienie warte zachowania w tym, do czego to podajesz.