Guides
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.
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, with an override for each send or broadcast. Every open and click is an event on the email's timeline, a webhook and a number in 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 requestshttps://t.pidgely.com/o/<token>and the open is recorded. - Clicks. Every
http(s)link of the HTML body is rewritten tohttps://t.pidgely.com/c/<token>. A click is recorded and answered with a302redirect to the original link, in a few milliseconds.
The email you sent is stored unchanged: GET /emails/{id} 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:
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);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
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);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)
}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 resultcurl -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:
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, 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 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=… 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 8058List-Unsubscribeheader. One-click unsubscribe must reach Pidgely directly. mailto:,tel:,sms:,#anchorand relative links, and anything that is nothttp(s).- Links inside HTML comments, including Outlook conditional comments (
<!--[if mso]> … <![endif]-->): the VML buttons Outlook draws there keep their originalhrefand 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-trackandclicktracking="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:
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, 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 |
{
"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 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 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-Unsubscribeheader are never rewritten, so opting out never depends on tracking.
Related
- Metrics: open, click, bounce and complaint rates, by day, domain, tag, template, broadcast and mailbox provider.
- Webhooks: every event and payload.
- Domains & DNS: the tracking settings and the custom tracking host record.