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. Created at send time during email HTML rewriting, or
by mintLink for a managed link on any channel (see
Link tracking).
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key — used in tracking redirect URL |
email_send_id | UUID | FK → email_sends (nullable — NULL for managed links). Cascade deletes. |
link_id | UUID | FK → links (nullable — NULL for email links). ON DELETE set null. |
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_id
links
The durable, named identity of a managed tracked link (Studio / mintLink).
Email's per-send rewritten links do not appear here — they keep
tracked_links.link_id NULL. The click count is computed on read by summing the
link's tracked_links.click_count; there is no counter on this table.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
original_url | TEXT | The destination the redirect 302s to |
type | TEXT | public (shareable, no identity) or personal (1:1, stitch-bearing) |
label | TEXT | Operator-facing name (nullable) |
campaign | TEXT | Campaign grouping (nullable) |
source | TEXT | Originating channel (studio, discord, …) |
distinct_id | TEXT | Canonical contact key to stitch — only set for personal links |
created_by | TEXT | The actor who minted it (nullable) |
archived_at | TIMESTAMP | Soft-delete marker; the short URL keeps redirecting |
Indexes: source, campaign, created_at
Managed via the admin API: POST /v1/admin/links (mint), GET /v1/admin/links
(list), GET /v1/admin/links/:id (one link + recent clicks), PATCH (rename /
regroup), DELETE (archive). The same surface is available in
@hogsend/client as hs.links.create/get/list/update/archive/qr, with an
idempotent create (keyed by the slug, or by an explicit idempotencyKey for
slugless links).
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 share the same tracking ID and redirect URL. Semantic links dedupe on the full (URL, event, properties) tuple instead, so two answers pointing at the same thanks page keep separate rows.
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" />Semantic links (email.action)
A semantic link is a tracked link that carries an event name + scalar properties, authored with EmailAction. The metadata is lifted into the tracked_links row at send time (event, event_properties columns) and stripped from the HTML.
At click time the link behaves like any tracked link (redirect, link_clicks row, email.link_clicked), plus the click is recorded as a provisional answer. A deferred confirmation task (confirm-semantic-click) judges it once the 30-second burst window around the click has fully elapsed:
- Burst suppression — if ≥ 3 distinct links of the send were clicked inside the window (before or after the candidate), the whole burst is treated as a security scanner (Outlook SafeLinks, Proofpoint) and the answer is suppressed. The deferral is what makes the scanner's first click suppressible.
- First answer wins — the confirmed answer is ingested with idempotency key
sem:<emailSendId>:<event>, so each (send, event name) pair gets at most one answer. The winning link'ssemantic_emitted_atis stamped. - Fan-out — confirmed answers route to journeys (waking
ctx.waitForEvent), persist touser_events, and emit anemail.actionoutbound envelope ({ event, properties, emailSendId, templateKey, userId, to, at, linkId, linkUrl }) with the samesem:key asdedupeKey. The PostHog destination preset captures it under the consumer's event name with properties flattened.
Reserved namespaces (email., journey., bucket., contact.) are rejected for semantic event names at send time, properties are scalars-only and size-capped, and a semantic link must have an absolute http(s) href — or the HOSTED_ANSWER_HREF sentinel (below).
GET /v1/t/a/:id — hosted answer page
The engine-served landing for a semantic link authored with href={HOSTED_ANSWER_HREF} (hogsend://answer). Shows the recorded answer and a free-text box. POST /v1/t/a/:id (form-encoded comment, ≤ 2000 chars) ingests <event>.comment with the answer's properties attached — idempotency key semc:<emailSendId>:<event>, so one comment per (send, event).
POST /v1/t/identify — redirect identity exchange
With TRACKING_IDENTITY_TOKEN=true, tracked-link redirects append an encrypted, one-hour hs_t token to the destination. The landing site posts { token } here and receives { distinctId, emailSendId } for its posthog.identify call. Tokens are AES-256-GCM encrypted with BETTER_AUTH_SECRET (a distinct id can be an email — nothing readable travels in the URL); invalid or expired tokens return 400.
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;