Tracking API
First-party link click tracking, email open tracking, and the event loop that connects tracking to PostHog and journeys.
Overview
Every outgoing email gets its links rewritten to redirect through your API, and a 1x1 tracking pixel injected for open detection. When recipients interact with the email:
- DB records are created —
tracked_links,link_clicks,emailSends.openedAt/clickedAt(first-touch) - Events are pushed —
email.link_clickedandemail.openedflow through the ingest pipeline - Events fan out on the durable spine —
email.clicked/email.openedare delivered per-hit to every subscribed destination, PostHog included (via akind="posthog"destination) — with retries, not a fire-and-forget capture - Journeys can react — journey code can branch on
ctx.history.hasEvent({ event: "email.opened" })
Tracking URLs use API_PUBLIC_URL as the domain (e.g., https://api.hogsend.com/v1/t/c/:id), so it's first-party — no third-party cookie issues, better deliverability.
Public Endpoints
These endpoints are hit by email clients. No authentication required.
GET /v1/t/c/{id} — Track Link Click
Records a click and redirects to the original URL.
Path Parameters
| Param | Type | Description |
|---|---|---|
id | string (uuid) | Tracked link ID |
Request Headers Used
| Header | Purpose |
|---|---|
x-forwarded-for | Client IP address (first IP if comma-separated) |
x-real-ip | Fallback IP address |
user-agent | Client user agent string |
Response 302 — Redirect to the original URL via Location header.
What happens on click:
- Insert
link_clicksrow with IP, user agent, timestamp - Increment
tracked_links.click_count - Set
email_sends.clicked_at(first click only —WHERE clicked_at IS NULL) - Fire-and-forget: push
email.link_clickedthrough the ingest pipeline (internal bus) and emitemail.clickedon the durable outbound spine, which fans out per-hit to every subscribed destination (PostHog rides this as akind="posthog"destination — no separate capture call)
Event properties pushed:
{
"emailSendId": "uuid",
"templateKey": "activation/welcome",
"linkUrl": "https://example.com/docs",
"linkId": "uuid"
}Fallback: Unknown link IDs redirect to API_PUBLIC_URL (your app's homepage).
# Simulating a click (in practice, email clients follow this automatically)
curl -v "https://api.hogsend.com/v1/t/c/link-uuid-here"
# → 302 Location: https://example.com/docsGET /v1/t/o/{id} — Track Email Open
Records an open and returns a 1x1 transparent GIF.
Path Parameters
| Param | Type | Description |
|---|---|---|
id | string (uuid) | Email send ID |
Response 200
- Content-Type:
image/gif - Body: 42-byte transparent 1x1 GIF
- Cache-Control:
no-store, no-cache, must-revalidate
What happens on open:
- Set
email_sends.opened_at(first open only —WHERE opened_at IS NULL) - Fire-and-forget: push
email.openedthrough the ingest pipeline (internal bus) and emit it on the durable outbound spine, which fans out per-hit to every subscribed destination (PostHog rides this as akind="posthog"destination — no separate capture call)
Event properties pushed:
{
"emailSendId": "uuid",
"templateKey": "activation/welcome"
}Subsequent opens are no-ops for the DB write (idempotent), but the event is only pushed on the first open.
curl -v "https://api.hogsend.com/v1/t/o/email-send-uuid"
# → 200 image/gif (42 bytes)Events Reference
| Event Name | Trigger | Properties |
|---|---|---|
email.opened | Email client loads tracking pixel | emailSendId, templateKey |
email.link_clicked | Email client follows tracked link | emailSendId, templateKey, linkUrl, linkId |
These events:
- Are stored in
user_events(queryable via admin API) - Are pushed to Hatchet (can trigger journeys or exit conditions)
- Are sent to PostHog (appear on person timeline)
Database Schema
tracked_links
One row per unique URL per email. Created at send time during HTML rewriting.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key — used in tracking redirect URL |
email_send_id | UUID | FK → email_sends. Cascade deletes. |
original_url | TEXT | The original destination URL |
click_count | INTEGER | Denormalized click counter (default 0) |
created_at | TIMESTAMP | When the tracked link was created |
updated_at | TIMESTAMP | Last updated (click count change) |
Indexes: email_send_id
link_clicks
One row per click event. Append-only — never updated or deleted.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
tracked_link_id | UUID | FK → tracked_links. Cascade deletes. |
ip_address | TEXT | Client IP (nullable) |
user_agent | TEXT | Client user agent (nullable) |
clicked_at | TIMESTAMP | When the click occurred |
Indexes: tracked_link_id, clicked_at
Link Rewriting
Links are rewritten automatically for every email sent through sendEmail(). The rewriting happens inside the engine-owned tracked email pipeline (createTrackedMailer) before the HTML reaches the email provider — so first-party open/click tracking is the single source of truth regardless of which provider (Resend by default) actually delivers the message.
What gets rewritten
All href="https://..." and href="http://..." attributes in the email HTML.
What gets skipped
| Pattern | Reason |
|---|---|
URLs containing /v1/email/unsubscribe | Functional — must not be tracked |
URLs containing /v1/email/preferences | Functional — must not be tracked |
mailto:, tel:, etc. | Non-HTTP schemes ignored by regex |
Deduplication
If the same URL appears multiple times in an email, only one tracked_links row is created. All occurrences in the HTML share the same tracking ID and redirect URL.
Open tracking pixel
A 1x1 transparent GIF <img> tag is injected before </body>:
<img src="https://api.hogsend.com/v1/t/o/{emailSendId}"
width="1" height="1" alt="" style="display:none" />Using Tracking in Journeys
Branching on email engagement
// Check if user opened any email in the last 2 days
const { found: opened } = await ctx.history.hasEvent({
userId: user.id,
event: "email.opened",
within: days(2),
});
// Check if user clicked any link in the last 3 days
const { found: clicked } = await ctx.history.hasEvent({
userId: user.id,
event: "email.link_clicked",
within: days(3),
});Sending engagement to PostHog and other tools
The journey context has no PostHog-capture or identify call — those shims were removed. Email engagement (email.opened, email.clicked, and the rest of the catalog) is fanned out durably to PostHog and any other subscriber via an outbound destination; you don't mirror it from journey code.
Exit conditions on tracking events
import { defineJourney, days, sendEmail } from "@hogsend/engine";
import { Events } from "./constants/index.js";
export const nurtureSequence = defineJourney({
meta: {
id: "nurture-sequence",
name: "Nurture Sequence",
enabled: true,
trigger: { event: Events.TRIAL_STARTED },
exitOn: [{ event: Events.EMAIL_LINK_CLICKED }],
},
run: async (user, ctx) => {
// If the user clicks any tracked link, this journey exits automatically
await sendEmail({ ... });
await ctx.sleep({ duration: days(3) });
await sendEmail({ ... }); // won't send if user already clicked
},
});defineJourney, days, and sendEmail all come from @hogsend/engine; Events is your own constants file (src/journeys/constants/). The built-in tracking events email.opened and email.link_clicked flow through the ingest pipeline, so exitOn, ctx.history.hasEvent, and trigger conditions can all branch on them.
SQL Examples
-- Links and clicks for a specific email
SELECT tl.original_url, tl.click_count, lc.ip_address, lc.clicked_at
FROM tracked_links tl
LEFT JOIN link_clicks lc ON lc.tracked_link_id = tl.id
WHERE tl.email_send_id = 'email-send-uuid'
ORDER BY lc.clicked_at DESC;
-- Open rate by template
SELECT
template_key,
COUNT(*) AS sent,
COUNT(opened_at) AS opened,
ROUND(COUNT(opened_at)::numeric / NULLIF(COUNT(*), 0) * 100, 1) AS open_rate_pct
FROM email_sends
WHERE template_key IS NOT NULL
GROUP BY template_key
ORDER BY sent DESC;
-- Click-through rate by template
SELECT
template_key,
COUNT(*) AS sent,
COUNT(clicked_at) AS clicked,
ROUND(COUNT(clicked_at)::numeric / NULLIF(COUNT(*), 0) * 100, 1) AS ctr_pct
FROM email_sends
WHERE template_key IS NOT NULL
GROUP BY template_key
ORDER BY sent DESC;
-- Most clicked links across all emails
SELECT tl.original_url, SUM(tl.click_count) AS total_clicks
FROM tracked_links tl
GROUP BY tl.original_url
ORDER BY total_clicks DESC
LIMIT 20;
-- Tracking events in user timeline
SELECT event, properties, created_at
FROM user_events
WHERE user_id = 'user-id'
AND event IN ('email.opened', 'email.link_clicked')
ORDER BY created_at DESC;