> For clean Markdown content of this page, append .md to this URL. For the complete documentation index, see https://www.agentmail.to/docs/llms.txt.

# Guide: Open Tracking

> Turn on open tracking for a custom domain, send with track_opens, and read opens from the opened label, opened_at, and the message.opened event.

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](/docs/custom-domains) if you do not have one yet.

### Turn it on in the console

In the [AgentMail Console](https://console.agentmail.to), 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:

**`Python`**

```python title="Python"
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)
```

**`TypeScript`**

```typescript title="TypeScript"
import { AgentMailClient } from "agentmail";

const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_API_KEY });

const domain = await client.domains.create({
  domain: "mail.yourcompany.com",
  trackingEnabled: true,
});

for (const record of domain.records) {
  console.log(record.type, record.name, record.value);
}
```

Or turn it on for a domain you already have:

**`Python`**

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

**`TypeScript`**

```typescript title="TypeScript"
const domain = await client.domains.update("mail.yourcompany.com", {
  trackingEnabled: 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`:

**`Python`**

```python title="Python"
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
```

**`TypeScript`**

```typescript title="TypeScript"
const domain = await client.domains.get("mail.yourcompany.com");

console.log(domain.trackingStatus); // VERIFIED when open tracking is ready
console.log(domain.trackingReason); // 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.

> **Note**
>
> 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:

**`Python`**

```python title="Python"
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,
)
```

**`TypeScript`**

```typescript title="TypeScript"
await client.inboxes.messages.send("agent@mail.yourcompany.com", {
  to: ["customer@example.com"],
  subject: "Following up",
  html: "<p>Just checking in on my last note.</p>",
  trackOpens: true,
});
```

> **Note**
>
> 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:

**`Python`**

```python title="Python"
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")
```

**`TypeScript`**

```typescript title="TypeScript"
const message = await client.inboxes.messages.get("agent@mail.yourcompany.com", messageId);

if (message.openedAt) {
  console.log("Opened at", message.openedAt);
} else if (message.trackOpens) {
  console.log("Tracked, not opened yet");
} else {
  console.log("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:

**`Python`**

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

**`TypeScript`**

```typescript title="TypeScript"
const opened = await client.inboxes.messages.list("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`:

**`Python`**

```python title="Python"
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
```

**`TypeScript`**

```typescript title="TypeScript"
import { AgentMailClient } from "agentmail";

const client = new AgentMailClient({ apiKey: process.env.AGENTMAIL_API_KEY });

const webhook = await client.webhooks.create({
  url: "https://yourapp.com/hooks/agentmail",
  eventTypes: ["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`):

**`Python`**

```python title="Python"
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)
```

**`TypeScript`**

```typescript title="TypeScript"
import express from "express";
import { Webhook } from "svix";

const app = express();
const webhook = new Webhook(process.env.AGENTMAIL_WEBHOOK_SECRET!);

// express.raw keeps the body as bytes so the signature can be checked
app.post("/hooks/agentmail", express.raw({ type: "application/json" }), (req, res) => {
  let payload: any;
  try {
    payload = webhook.verify(req.body, req.headers as Record<string, string>);
  } catch {
    res.status(400).send();
    return;
  }

  // the webhook body uses snake_case field names
  if (payload.event_type === "message.opened") {
    console.log(payload.open.message_id, payload.open.timestamp);
  }

  res.status(204).send();
});

app.listen(3000);
```

See [Webhook events](/docs/events) for every event type and [Verifying webhooks](/docs/webhook-verification) for more on signature verification. To receive the event over a WebSocket instead, subscribe with `message.opened` in `event_types`; see [WebSockets](/docs/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](/docs/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.