Tracking
Courier tracks email opens and link clicks automatically for every email it sends. It also handles unsubscribe links. All three are served by CourierController, which is registered under the /courier/ URL prefix.
How open tracking works
When Courier renders an email, it injects a {courier_tracking_pixel} placeholder. Before delivery, this is replaced with a 1×1 transparent GIF served from your app:
When the recipient's email client loads that image, Courier records the open event and timestamps opened_at on the send record. If the same pixel loads more than once, only the first open is timestamped — but all load events are recorded in courier_events.
Open tracking limitations
Many email clients block remote images by default, so open rates are an undercount. That's true across the industry — it's still a useful relative metric.
How click tracking works
Every http:// or https:// link in your email body is automatically rewritten through Courier's click redirect:
<!-- Original -->
<a href="https://acme.com/pricing">See pricing</a>
<!-- After wrapping -->
<a href="https://yoursite.com/courier/click/abc123token">See pricing</a>
When a recipient clicks, Courier records the click event and redirects them to the original URL. Only the first click is timestamped on the send record; subsequent clicks are still logged as events.
Unsubscribe links are not rewritten
The {courier_unsubscribe_url} placeholder is excluded from link rewriting — it goes directly to the unsubscribe handler.
Unsubscribes
When a contact clicks their unsubscribe link, Courier's controller handles it:
- Looks up the contact by their unique
unsubscribe_token - Sets their status to
unsubscribed - Cancels all active drip enrollments
- Triggers the
courier:contact.unsubscribedevent - Renders a confirmation view (
Views/courier/unsubscribe_success.php)
You can customize the unsubscribe success and invalid-token views using CodeIgniter's view overrides folder (Config\View::$appOverridesFolder, overrides by default). Create a file at the matching path under app/Views/overrides/ and CI4 will use it instead of Courier's bundled view — no publishing step needed, and your copy won't go stale when Courier updates its views:
app/Views/overrides/Myth/Courier/Views/courier/unsubscribe_success.php
app/Views/overrides/Myth/Courier/Views/courier/unsubscribe_invalid.php
app/Views/overrides/Myth/Courier/Views/courier/unsubscribe_expired.php
One-click unsubscribe (RFC 8058)
Most inbox providers show a native "Unsubscribe" button when an email carries a List-Unsubscribe header. Courier injects one automatically for every single-recipient send — no template changes needed.
The header points at the per-send unsubscribe URL, and Courier also emits List-Unsubscribe-Post, which tells the mail client it can unsubscribe with a single POST (the user never leaves their inbox):
List-Unsubscribe: <https://track.acme.com/courier/unsubscribe/abc123…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
That POST hits the POST /courier/unsubscribe/(:segment) route, which runs the exact same flow as a click. The GET link still works for anyone who opens the email in a browser.
Set an explicit header to override
If a Mailable sets its own List-Unsubscribe header, Courier leaves it alone — your value always wins.
Suppression
Sending to someone who already opted out hurts your deliverability. Courier prevents that by filtering recipients before dispatch.
Any contact whose status is unsubscribed, bounced, or complained is dropped from the send. When that leaves no one to deliver to, the send is recorded with the suppressed status instead of sent or failed — so you can tell a skipped send apart from a real delivery failure in your stats and in the courier_sends table.
This happens automatically for both campaign sends and transactional Mailables. There's nothing to call; it's wired into the mailer.
Using a custom tracking domain
By default, tracking URLs use your app's base_url(). If you want them to come from a separate domain (better deliverability, branded links), set $trackingHost in your config:
Point that domain at your CI4 app, and make sure the /courier/* routes are reachable there.
Reading stats
Aggregate stats for a campaign are available via CampaignService:
<?php
$stats = service('campaignService')->getCampaignStats($campaignId);
// [
// 'total' => 2500, // total send records
// 'sent' => 2487, // successfully delivered
// 'failed' => 13, // delivery failures
// 'opened' => 891, // unique opens (first open only)
// 'clicked' => 234, // unique clicks (first click only)
// ]
Raw event data (every open and click, with timestamps and metadata) is in the courier_events table.
Bounce and complaint webhooks
Open and click tracking tell you what contacts do with your emails. Bounce and complaint tracking tells you when emails can't be delivered — or when recipients mark them as spam. Without it, you'll keep sending to bad addresses until your ESP suspends your account.
Courier handles this via a webhook endpoint: POST /courier/webhook. Your ESP calls it whenever a bounce or complaint occurs, and Courier automatically suppresses the contact.
How it works
- A hard bounce or spam complaint arrives at
POST /courier/webhook - Courier verifies the request is genuinely from your ESP (RSA signature check for SES/SNS)
- The contact's status is updated to
bouncedorcomplained - All active drip enrollments for that contact are cancelled
- The
courier:contact.bouncedorcourier:contact.complainedevent fires
Soft bounces (temporary failures like a full mailbox) are logged as events but don't suppress the contact.
Setting up with AWS SES
Courier ships with a driver for AWS SES, which routes notifications through SNS.
Step 1 — Configure the driver in app/Config/Courier.php:
Step 2 — Exempt the route from CSRF in app/Config/Security.php:
SNS sends machine-to-machine POST requests with no browser session, so they'll never carry a CSRF token.
Step 3 — Create an SNS topic in your AWS console and subscribe your webhook URL:
SNS will send a SubscriptionConfirmation request to that URL. Courier confirms it automatically — you don't need to do anything.
Step 4 — Configure SES notifications to publish bounces and complaints to your SNS topic. In the SES console, go to your verified identity → Notifications → set the Bounce and Complaint SNS topics to the one you just created.
Custom drivers
If you're using a different ESP, implement WebhookDriverInterface:
<?php
use Myth\Courier\Webhooks\WebhookDriverInterface;
use CodeIgniter\HTTP\IncomingRequest;
class MailgunDriver implements WebhookDriverInterface
{
public function verifySignature(IncomingRequest $request): bool
{
// verify HMAC-SHA256 signature from Mailgun headers
}
public function isSubscriptionConfirmation(IncomingRequest $request): bool
{
return false; // Mailgun doesn't use subscription confirmations
}
public function confirmSubscription(IncomingRequest $request): void {}
public function parseEvents(IncomingRequest $request): array
{
// return [['type' => 'bounce'|'soft_bounce'|'complaint', 'email' => '...', 'message_id' => '...']]
}
}
Then set $webhookDriver = MailgunDriver::class in your config.