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

> **Warning**
>
> Open tracking is in **private beta**. It requires the **Developer plan or above** and must be enabled on your organization by our team. Email [support@agentmail.cc](mailto:support@agentmail.cc) to request access.

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.

## 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](/knowledge-base/custom-domain-setup) if you do not have one yet.

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,
});
```

Until open tracking is enabled on your organization, either call returns `403` with code `forbidden`. Once it is enabled, either call adds a `link.<domain>` CNAME to the domain's `records`. 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
```

Certificate issuance for the `link.` subdomain usually takes a few minutes after the CNAME resolves.

## Sending a tracked message

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

**`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, and reply-all. 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.

## Getting access

Open tracking is in private beta on the Developer plan and above. Email [support@agentmail.cc](mailto:support@agentmail.cc) with your organization and we will enable it. We are actively collecting feedback on this feature, so tell us what is missing.