Hogsend is brand new.Try it
Hogsend
API Reference

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:

  1. DB records are createdtracked_links, link_clicks, emailSends.openedAt/clickedAt (first-touch)
  2. Events are pushedemail.link_clicked and email.opened flow through the ingest pipeline
  3. Events fan out on the durable spineemail.clicked / email.opened are delivered per-hit to every subscribed destination, PostHog included (via a kind="posthog" destination) — with retries, not a fire-and-forget capture
  4. 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.

Records a click and redirects to the original URL.

Path Parameters

ParamTypeDescription
idstring (uuid)Tracked link ID

Request Headers Used

HeaderPurpose
x-forwarded-forClient IP address (first IP if comma-separated)
x-real-ipFallback IP address
user-agentClient user agent string

Response 302 — Redirect to the original URL via Location header.

What happens on click:

  1. Insert link_clicks row with IP, user agent, timestamp
  2. Increment tracked_links.click_count
  3. Set email_sends.clicked_at (first click only — WHERE clicked_at IS NULL)
  4. Fire-and-forget: push email.link_clicked through the ingest pipeline (internal bus) and emit email.clicked on the durable outbound spine, which fans out per-hit to every subscribed destination (PostHog rides this as a kind="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/docs

GET /v1/t/o/{id} — Track Email Open

Records an open and returns a 1x1 transparent GIF.

Path Parameters

ParamTypeDescription
idstring (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:

  1. Set email_sends.opened_at (first open only — WHERE opened_at IS NULL)
  2. Fire-and-forget: push email.opened through 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 a kind="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 NameTriggerProperties
email.openedEmail client loads tracking pixelemailSendId, templateKey
email.link_clickedEmail client follows tracked linkemailSendId, 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

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

ColumnTypeDescription
idUUIDPrimary key — used in tracking redirect URL
email_send_idUUIDFK → email_sends (nullable — NULL for managed links). Cascade deletes.
link_idUUIDFK → links (nullable — NULL for email links). ON DELETE set null.
original_urlTEXTThe original destination URL
click_countINTEGERDenormalized click counter (default 0)
created_atTIMESTAMPWhen the tracked link was created
updated_atTIMESTAMPLast updated (click count change)

Indexes: email_send_id, link_id

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.

ColumnTypeDescription
idUUIDPrimary key
original_urlTEXTThe destination the redirect 302s to
typeTEXTpublic (shareable, no identity) or personal (1:1, stitch-bearing)
labelTEXTOperator-facing name (nullable)
campaignTEXTCampaign grouping (nullable)
sourceTEXTOriginating channel (studio, discord, …)
distinct_idTEXTCanonical contact key to stitch — only set for personal links
created_byTEXTThe actor who minted it (nullable)
archived_atTIMESTAMPSoft-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).

One row per click event. Append-only — never updated or deleted.

ColumnTypeDescription
idUUIDPrimary key
tracked_link_idUUIDFK → tracked_links. Cascade deletes.
ip_addressTEXTClient IP (nullable)
user_agentTEXTClient user agent (nullable)
clicked_atTIMESTAMPWhen the click occurred

Indexes: tracked_link_id, clicked_at


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

PatternReason
URLs containing /v1/email/unsubscribeFunctional — must not be tracked
URLs containing /v1/email/preferencesFunctional — 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" />

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's semantic_emitted_at is stamped.
  • Fan-out — confirmed answers route to journeys (waking ctx.waitForEvent), persist to user_events, and emit an email.action outbound envelope ({ event, properties, emailSendId, templateKey, userId, to, at, linkId, linkUrl }) with the same sem: key as dedupeKey. 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;