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
  2. Events are pushedemail.link_clicked and email.opened flow through the ingest pipeline
  3. PostHog gets synced — events appear on the person timeline for cohort building and analytics
  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 event through ingest pipeline + PostHog

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 event through ingest pipeline + PostHog

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 per email. Created at send time during HTML rewriting.

ColumnTypeDescription
idUUIDPrimary key — used in tracking redirect URL
email_send_idUUIDFK → email_sends. Cascade deletes.
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

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 in the sendTrackedEmail pipeline (the default path when emailService.send() is used with a database).

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 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),
});

Setting PostHog person properties

// Set traits on PostHog person profile
ctx.identify({ activated: true, plan: "pro" });

Firing custom PostHog events

// Analytics event (PostHog only, not internal pipeline)
ctx.posthog.capture({
  event: "journey.milestone_reached",
  properties: { milestone: "first_email_opened" },
});

Exit conditions on tracking events

const journey = defineJourney({
  meta: {
    id: "nurture-sequence",
    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
  },
});

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;

On this page