# Open and click tracking

> Know who opened your emails and which links they clicked. How the pixel and tracked links work, turning tracking on per domain or per send, custom tracking hosts, machine opens and bot clicks, and privacy.

Source: https://docs.pidgely.com/guides/tracking/

Open and click tracking tells you when a recipient loads your email and which links they follow. Open and click tracking are on by default for new domains; you change them per [domain](https://docs.pidgely.com/guides/domains/), with an override for each send or broadcast. Every open and click is an event on the email's timeline, a [webhook](https://docs.pidgely.com/guides/webhooks/) and a number in [Metrics](https://docs.pidgely.com/guides/metrics/).

## How it works

Tracking happens **at send time, separately for each recipient**, after your content is rendered and before the message is DKIM-signed:

- **Opens.** A transparent 1×1 image is added at the end of the HTML body (before `</body>`). When the recipient's mail client loads images, it requests `https://t.pidgely.com/o/<token>` and the open is recorded.
- **Clicks.** Every `http(s)` link of the HTML body is rewritten to `https://t.pidgely.com/c/<token>`. A click is recorded and answered with a `302` redirect to the original link, in a few milliseconds.

The email you sent is stored unchanged: [`GET /emails/{id}`](https://docs.pidgely.com/api-reference/emails/retrieve/) and the dashboard preview show your original HTML. The **plain-text part is never tracked**, so its links stay exactly as you wrote them.

Tokens are signed (HMAC) and contain only the delivery id and the link number: no email address, no personal data and **not the target URL**. The targets are kept in the message's link table on Pidgely's side, so a tracked link cannot be edited to redirect anywhere else, and a forged or altered token is refused. Tracked links keep working as long as message metadata is kept (400 days).

## Turn it on

Tracking is a domain setting, `open_tracking` and `click_tracking` (both `true` by default; Resend defaults both to `false`, so pass `"open_tracking": false, "click_tracking": false` when creating the domain to keep Resend's behaviour). Change it with [Update a domain](https://docs.pidgely.com/api-reference/domains/update/):

```js tab="Node.js"
import { Pidgely } from 'pidgely';

const pidgely = new Pidgely(process.env.PIDGELY_API_KEY);

const { data, error } = await pidgely.domains.update({
  id: '0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51',
  openTracking: true,
  clickTracking: true,
});
console.log(data ?? error);
```

```python tab="Python"
import os
import pidgely

pidgely.api_key = os.environ["PIDGELY_API_KEY"]

result = pidgely.Domains.update({
    "id": "0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51",
    "open_tracking": True,
    "click_tracking": True,
})
print(result)
```

```php tab="PHP"
<?php

require __DIR__ . '/vendor/autoload.php';

$pidgely = Pidgely::client(getenv('PIDGELY_API_KEY'));

$result = $pidgely->domains->update([
    'id' => '0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51',
    'open_tracking' => true,
    'click_tracking' => true,
]);
var_dump($result);
```

```go tab="Go"
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/pidgely/pidgely-go"
)

func main() {
	ctx := context.Background()
	client := pidgely.NewClient(os.Getenv("PIDGELY_API_KEY"))

	result, err := client.Domains.Update(ctx, "0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51", &pidgely.UpdateDomainRequest{
		OpenTracking:  true,
		ClickTracking: true,
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(result)
}
```

```ruby tab="Ruby"
require "pidgely"

Pidgely.api_key = ENV["PIDGELY_API_KEY"]

result = Pidgely::Domains.update({
  id: "0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51",
  open_tracking: true,
  click_tracking: true
})
puts result
```

```bash tab="cURL"
curl -X PATCH 'https://api.pidgely.com/domains/0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51' \
  -H "Authorization: Bearer $PIDGELY_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "open_tracking": true,
  "click_tracking": true
}'
```

In the dashboard: **Domains** → your domain → **Advanced settings** → **Open tracking** and **Click tracking**. The setting applies to emails sent after the change; emails already queued keep the decision made when they were accepted.

### Per send

`tracking` (Pidgely extension) overrides the domain for one email. A field you leave out follows the domain:

```bash
curl -X POST https://api.pidgely.com/emails \
  -H "Authorization: Bearer $PIDGELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <news@acme.com>",
    "to": ["steve@example.com"],
    "subject": "Your weekly summary",
    "html": "<p>Your summary is ready. <a href=\"https://acme.com/summary\">Open it</a></p>",
    "tracking": { "opens": true, "clicks": false }
  }'
```

Over [SMTP](https://docs.pidgely.com/guides/smtp/#special-headers), use the header `X-Pidgely-Track: opens=on, clicks=off` (`on`/`off`, `true`/`false` or `1`/`0`; either part can be left out). Pidgely removes the header before sending.

### Broadcasts

A [broadcast](https://docs.pidgely.com/guides/broadcasts/) follows its sending domain, or its own `tracking` object: `{"opens": true, "clicks": true}`. A `null` field follows the domain. Once sent, [`GET /metrics/links?broadcast_id=…`](https://docs.pidgely.com/api-reference/metrics/list-links/) and the broadcast's statistics in the dashboard show its opens and the clicks per link.

### Pidgely's own emails

Account emails Pidgely sends (verification, password reset, invites, billing notices) and emails from the test sender (`onboarding@test.pidgely.com`) are never tracked, whatever the settings.

## Links that are never rewritten

- **Unsubscribe links**: `{{{PIDGELY_UNSUBSCRIBE_URL}}}` / `{{{RESEND_UNSUBSCRIBE_URL}}}` and the RFC 8058 `List-Unsubscribe` header. One-click unsubscribe must reach Pidgely directly.
- `mailto:`, `tel:`, `sms:`, `#anchor` and relative links, and anything that is not `http(s)`.
- Links inside **HTML comments**, including Outlook **conditional comments** (`<!--[if mso]> … <![endif]-->`): the VML buttons Outlook draws there keep their original `href` and keep working. Content in `<style>`, `<script>` and `<textarea>` is never touched either.
- Links that already point at a tracking host.
- Links you opt out: add `data-pidgely-track="off"` to the `<a>` tag. `ses:no-track` and `clicktracking="off"` (the Amazon SES and SendGrid attributes) are honoured too, so migrated templates keep their exclusions.
- Links with template tags left unfilled (`{{…}}`).
- Links after the first 500 distinct links of an email.

The same link used several times in an email shares one link number, so clicks per link add up.

## Custom tracking host

By default tracked links point at `t.pidgely.com`. To show your own domain instead, give the domain a tracking subdomain, for example `links`:

```bash
curl -X PATCH https://api.pidgely.com/domains/0199d7f0-1c2b-7e4d-a5f6-0b9c8d7e6f51 \
  -H "Authorization: Bearer $PIDGELY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tracking_subdomain": "links" }'
```

The domain gets one more record, `Tracking`:

| Type | Name | Value |
|---|---|---|
| `CNAME` | `links` (`links.acme.com`) | `t.pidgely.com` |

The record is checked with the domain's other records. Once it is verified, `tracking_host` on the domain becomes `links.acme.com`, new emails use `https://links.acme.com/…`, and the HTTPS certificate for it is issued automatically on the first request. Until then, links keep using `t.pidgely.com`, so sending is never interrupted. The record is optional: it never affects the domain's own verification. An empty `tracking_subdomain` removes it.

## Events and webhooks

Each open and each click is an event on the email's timeline ([List the events of an email](https://docs.pidgely.com/api-reference/emails/list-events/), and the dashboard's email page) and a webhook:

| Event | `data` object |
|---|---|
| `email.opened` | `open`: `timestamp`, `user_agent`, `ip_address` (anonymised), `ua_class`, `machine`, `machine_reason`, `first` (first open of this recipient), `count` |
| `email.clicked` | `click`: `link` (the original URL), `link_index`, `timestamp`, `user_agent`, `ip_address` (anonymised), `ua_class`, `bot`, `bot_reason`, `first`, `count` |

```json
{
  "type": "email.clicked",
  "created_at": "2026-10-12T17:02:31.512Z",
  "data": {
    "email_id": "0199d8a2-6f3c-7c51-9e2a-3b1f0c6d4e21",
    "from": "Acme <news@acme.com>",
    "to": ["steve@example.com"],
    "subject": "October update",
    "tags": { "campaign": "october" },
    "recipient": "steve@example.com",
    "click": {
      "link": "https://acme.com/changelog",
      "link_index": 0,
      "timestamp": "2026-10-12T17:02:31.498Z",
      "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
      "ip_address": "203.0.113.0",
      "ua_class": "mobile",
      "bot": false,
      "first": true,
      "count": 1
    }
  }
}
```

`ua_class` is `desktop`, `mobile`, `outlook`, `apple_privacy_proxy`, `gmail_image_proxy`, `yahoo_image_proxy`, `security_scanner` or `unknown`. An email's `last_event` becomes `opened`, then `clicked` (a later bounce or complaint still wins).

Webhooks created through the Resend-compatible `POST /webhooks` get Resend's field names inside the objects, `click.ipAddress`, `click.userAgent`, `click.link` and `click.timestamp`, plus the extra fields above, so existing Resend handlers keep working.

## Machine opens and bot clicks

Not every open is a person reading, and not every click is a person clicking. Pidgely flags these, records them anyway and never drops them; [Metrics](https://docs.pidgely.com/guides/metrics/) show both the human numbers and the raw ones.

**Machine opens** (`open.machine: true`):

| `machine_reason` | What happened |
|---|---|
| `apple_privacy_proxy` | **Apple Mail Privacy Protection** loads every image of every message on delivery, through Apple's proxies, whether the person reads it or not. |
| `image_proxy_prefetch` | The Gmail or Yahoo image proxy fetched the image within 10 seconds of delivery: a prefetch, not a person. Later proxy loads count as human opens. |
| `scanner_user_agent`, `scanner_network` | A security gateway or a script fetched the image. |
| `head_request`, `no_user_agent` | Not a mail client. |

**Bot clicks** (`click.bot: true`):

| `bot_reason` | What happened |
|---|---|
| `scanner_network` | The request came from a link-scanning service: Microsoft Defender Safe Links / Exchange Online Protection, Proofpoint, Mimecast, Barracuda. |
| `scanner_user_agent`, `no_user_agent` | Security scanner, crawler or script user agent. |
| `head_request` | Scanners check links with `HEAD`; browsers don't. |
| `too_fast_after_delivery` | Clicked less than 3 seconds after delivery. |
| `many_links_at_once` | The third and later clicks of a burst, each less than 2 seconds after the previous one: a scanner following every link. |

Bot clicks still redirect normally: a person behind a scanner never notices.

## What tracking cannot tell you

- **Opens are a lower bound and an upper bound at the same time.** Clients that block images (many corporate Outlook installs, people who turned images off) never load the pixel. Apple Mail Privacy Protection loads it for everyone. Use the human open rate as a trend, and clicks as the stronger signal.
- **Gmail clips messages over about 102 KB.** The pixel is at the end of the HTML, so in a clipped message it only loads when the reader clicks "View entire message". Keep HTML light.
- **Spam folder placement, deletion, forwarding and reading without images** are invisible to any sender. A delivered email may be in the spam folder; watch [complaints and bounces](https://docs.pidgely.com/guides/metrics/#alerts) instead.
- **A forwarded email** carries the original tracked links: clicks by the new reader count for the original recipient.

## Privacy, LGPD and GDPR

- **IP addresses are anonymised before they are stored**: the last octet of IPv4 addresses (a `/24`) and everything after the first 48 bits of IPv6 addresses. The full address is never written anywhere.
- The **user agent** is kept (up to 300 characters) to classify the client and filter machines.
- Opens and clicks live in the event timeline, kept for 90 days by default (`EVENT_RETENTION_DAYS`). Per-recipient counters and the daily metrics follow the message metadata retention (400 days); metrics are aggregated and contain no personal data.
- **You are the controller** of your recipients' data and Pidgely is your processor. Tracking opens and clicks is processing of personal data under the LGPD and the GDPR: make sure you have a legal basis (consent or legitimate interest, depending on the message and the country), say in your privacy notice that your emails measure opens and clicks, and turn tracking off where you don't need it, per domain, per broadcast or per email.
- Unsubscribe links and the `List-Unsubscribe` header are never rewritten, so opting out never depends on tracking.

## Related

- [Metrics](https://docs.pidgely.com/guides/metrics/): open, click, bounce and complaint rates, by day, domain, tag, template, broadcast and mailbox provider.
- [Webhooks](https://docs.pidgely.com/guides/webhooks/): every event and payload.
- [Domains & DNS](https://docs.pidgely.com/guides/domains/): the tracking settings and the custom tracking host record.
