Skip to navigation

Guide: Open Tracking

Know when a recipient opens an email your agent sent, and react to it.

When you send with track_opens, AgentMail adds an invisible 1x1 pixel to your HTML body, served from your own domain. The first time a recipient’s mail client loads that pixel, the message is marked as opened.

You can see opens two ways: on the message itself, as the opened label (which you can filter on) and the opened_at time, and as the message.opened event, which is pushed to your webhook or WebSocket the moment it happens.

Open tracking is included on every plan at no extra cost. You turn it on per domain, in the console or through the API, and then choose per message: only sends with track_opens carry the pixel.

Setup

Open tracking is only available on a custom domain, because the pixel is served from your domain and carries your sender reputation rather than a shared one. See Custom Domains if you do not have one yet.

Turn it on in the console

In the AgentMail Console, go to Domains:

  • New domain: click Add Domain. In the DNS records shown after the domain is added, turn on the Open tracking switch.
  • Existing domain: open the domain’s actions menu and choose View DNS records, then turn on the Open tracking switch. You can also choose Edit and check Open tracking.

The records then include the new link.<domain> CNAME, in an Open tracking section with its own status.

Turn it on with the API

Enable tracking when you create the domain:

import os
from agentmail import AgentMail
client = AgentMail(api_key=os.getenv("AGENTMAIL_API_KEY"))
domain = client.domains.create(
domain="mail.yourcompany.com",
tracking_enabled=True,
)
for record in domain.records:
print(record.type, record.name, record.value)

Or turn it on for a domain you already have:

domain = client.domains.update(
domain_id="mail.yourcompany.com",
tracking_enabled=True,
)

Either call adds a link.<domain> CNAME to the domain’s records.

Publish the CNAME record

Publish the link CNAME at your DNS provider, then wait for tracking_status to report VERIFIED:

domain = client.domains.get(domain_id="mail.yourcompany.com")
print(domain.tracking_status) # VERIFIED when open tracking is ready
print(domain.tracking_reason) # why it is not ready yet, if known

Once the CNAME resolves, AgentMail requests a TLS certificate for link.<domain>. Issuance usually takes a few minutes. Until tracking_status is VERIFIED, sends with track_opens are rejected.

Open tracking never holds up the domain itself. Turning it on leaves the domain’s status as it is, and the link and CAA records do not count toward domain verification. You can create inboxes and send without track_opens while tracking is still setting up.

Two DNS settings commonly block the certificate:

  • Proxied records. If your DNS provider can proxy traffic, such as Cloudflare’s orange cloud, set the link record to DNS only. A proxied record does not resolve to the CNAME target, so open tracking cannot verify.
  • CAA records. If your domain or a parent domain has CAA records that leave Amazon out, tracking_status is INVALID with the reason caa_blocked, and records lists a CAA record to add: 0 issue "amazon.com", next to your existing CAA records. Some DNS providers, such as Vercel, add CAA records by default. Once the record is published, AgentMail requests the certificate again on its own within a few minutes. If you have no CAA records, no CAA record is listed and there is nothing to change.

If the certificate is refused for another reason, tracking_status moves to FAILED with the reason certificate_failed. Fix the DNS, then turn tracking off and back on to request a new certificate.

Sending a tracked message

Set track_opens on any send, reply, reply-all, or forward:

client.inboxes.messages.send(
inbox_id="agent@mail.yourcompany.com",
to=["customer@example.com"],
subject="Following up",
html="<p>Just checking in on my last note.</p>",
track_opens=True,
)

The message must have an HTML body. A pixel can only live in HTML, so a text-only message is delivered normally but is not tracked. Sending both text and html is fine.

If the domain is not ready, the send is rejected with a 422 rather than going out untracked, so a misconfigured domain fails loudly instead of silently losing tracking.

Checking whether a message was opened

On the first open we add the opened system label to the message and to its thread, and set the message’s opened_at to the time of that open. A message that carried the pixel also has track_opens: true, so you can tell a tracked message nobody has opened yet from one that was never tracked. All three show up wherever you already read messages, with no extra call:

message = client.inboxes.messages.get(
inbox_id="agent@mail.yourcompany.com",
message_id=message_id,
)
if message.opened_at:
print("Opened at", message.opened_at)
elif message.track_opens:
print("Tracked, not opened yet")
else:
print("Not tracked")

Because it is a label, you can also filter on it. This is the simplest pattern for an agent that sends, goes away, and checks back later:

opened = client.inboxes.messages.list(
inbox_id="agent@mail.yourcompany.com",
labels=["opened"],
)

Reacting to an open in real time

Subscribe to the message.opened event to be notified the moment it happens, instead of checking back. The event’s open.timestamp is the same time as the message’s opened_at:

import os
from agentmail import AgentMail
client = AgentMail(api_key=os.getenv("AGENTMAIL_API_KEY"))
webhook = client.webhooks.create(
url="https://yourapp.com/hooks/agentmail",
event_types=["message.opened"],
)
# store webhook.secret (it starts with whsec_) as AGENTMAIL_WEBHOOK_SECRET

AgentMail delivers webhooks through Svix, so verify each request’s signature against the raw request body before you trust it. These handlers use the svix library (pip install agentmail flask svix or npm install express svix):

import os
from agentmail import MessageOpenedEvent
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
webhook = Webhook(os.environ["AGENTMAIL_WEBHOOK_SECRET"])
@app.route("/hooks/agentmail", methods=["POST"])
def agentmail_webhook():
# verify the signature on the raw body, not on parsed json
try:
payload = webhook.verify(request.get_data(), request.headers)
except WebhookVerificationError:
return "", 400
if payload["event_type"] == "message.opened":
event = MessageOpenedEvent(**payload)
print(event.open.message_id, event.open.timestamp)
return "", 204
if __name__ == "__main__":
app.run(port=3000)

See Webhook events for every event type and Verifying webhooks for more on signature verification. To receive the event over a WebSocket instead, subscribe with message.opened in event_types; see WebSockets.

What open tracking can and cannot tell you

Open tracking is a directional signal, not proof that someone read your email. This is true of every pixel-based implementation, not just ours, and it is worth designing around:

  • Opens can be missed. Many mail clients block remote images by default. A message without the opened label was not necessarily unread.
  • Opens can fire without a human. Apple Mail Privacy Protection downloads every image when a message arrives, whether or not anyone reads it, and Gmail and many security scanners pre-fetch images through a proxy. Each of these registers an open, often within seconds of delivery, and on many messages the first open is one of them. Compare opened_at with the message’s timestamp: an open a few seconds after sending is most likely a machine.

If your agent takes action on an open, such as sending a follow-up, design it to tolerate both a missed open and a machine-triggered one.

Open tracking and deliverability

The pixel is a remote image, and some spam filters weigh tracking images against a first message from a sender the recipient has never heard from. Tracking is off unless you set track_opens, so a common pattern is to send the first cold message untracked and track replies and follow-ups. See Email Deliverability for more.

Current limits

  • First open only. The label and opened_at are set, and the event fires, once per message, on the first load of the pixel. Repeat opens are not counted, so when a scanner loads the pixel first, opened_at is the scanner’s time and a later human open is not recorded.
  • One pixel per message, not per recipient. A message sent to several recipients fires once when any of them opens it, and the event does not say which one. Send separate messages if you need per-recipient attribution.
  • Immediate sends only. track_opens is honored on send, reply, reply-all, and forward. It is not yet supported on drafts or scheduled sends.
  • Custom domains only. Tracking is not available on shared AgentMail domains.
  • No click tracking yet.