> 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.

# How do I know when an email is opened?

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: the `opened` label on the message, which you can poll or filter on, and the `message.opened` event, which is pushed to your webhook 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.

## 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 domain setup](/docs/knowledge-base/custom-domain-setup) 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** and check **Open tracking**.
* **Existing domain:** open the domain's actions menu, choose **Edit**, and check **Open tracking**.

The console then shows the domain's records, including the new `link.<domain>` CNAME.

### 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 every record at your DNS provider, then wait for the domain to report `VERIFIED`:

**`Python`**

```python title="Python"
domain = client.domains.get(domain_id="mail.yourcompany.com")

print(domain.status)            # VERIFIED when ready
print(domain.tracking_enabled)  # True
```

**`TypeScript`**

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

console.log(domain.status); // VERIFIED when ready
console.log(domain.trackingEnabled); // true
```

Once the CNAME resolves, AgentMail requests a TLS certificate for `link.<domain>`. Issuance usually takes a few minutes.

> **Warning**
>
> Turning on tracking for a domain that is already `VERIFIED` moves it back to `PENDING`. It returns to `VERIFIED` once the CNAME resolves and the certificate is issued. Until then, you cannot create new inboxes on the domain, and sends with `track_opens` are rejected. Sending without `track_opens` is not affected.

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 the domain cannot verify.
* **CAA records.** If your domain or a parent domain has CAA records, they must allow Amazon to issue the certificate. Add `0 issue "amazon.com"` next to your existing CAA records. If you have no CAA records, there is nothing to change.

If the certificate is refused, the domain moves to `FAILED` with the reason `tracking_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. It shows 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 "opened" in message.labels:
    print("Opened")
```

**`TypeScript`**

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

if (message.labels.includes("opened")) {
  console.log("Opened");
}
```

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. The event payload carries the open time, which the label alone does not:

**`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](https://docs.agentmail.to/events) for every event type and [Verifying webhooks](https://docs.agentmail.to/webhook-verification) for more on signature verification.

## 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, and Apple Mail Privacy Protection can suppress loads. A message without the `opened` label was not necessarily unread.
* **Opens can fire without a human.** Gmail and some security scanners pre-fetch images through a proxy, which can register an open within seconds of delivery. Treat a very fast open with suspicion.

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.

## Current limits

* **First open only.** The label and the event fire once per message. Repeat opens are not counted.
* **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.