Skip to content
Pidgely Docs

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.

View as Markdown

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 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} 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:

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

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

  • 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:

Shell
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
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 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-Unsubscribe header are never rewritten, so opting out never depends on tracking.
  • 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.