Hogsend is brand new.Try it
Hogsend
Building

Link tracking

First-party tracked links for any channel — mint personal or public links, vanity slugs, and re-targetable QR codes; count clicks and stitch identity outside the email pipeline.

Email already rewrites its links to redirect through your own domain and counts every click (see Tracking API). Link tracking is that same machinery as a standalone primitive: mint a first-party tracked link for any channel — a Discord message, an SMS, a QR code, a share link on your site — and get the clicks back as events, with optional per-person identity.

A tracked link is a short /v1/t/c/:id URL on your own API_PUBLIC_URL. It 302-redirects to the real destination and records the click first-party — no third-party cookie, no external tracker, no deliverability hit.

Personal vs public

Every link is one of two types, and the type is a contract about identity:

  • public — shareable. A campaign-style link you can post anywhere. It counts clicks and attributes them to the link's campaign, but carries no person identity. Forward it and every click still rolls up to the same campaign — which is the point.
  • personal — one recipient. It carries a distinctId (a canonical contact key) so a click can stitch the visitor's session to that person. Send it to one person; don't post it publicly.

The split exists because a shared link can't identify a person — whoever clicks a forwarded "personal" link would be mis-attributed to the original recipient. The engine enforces this: a public link never stores a distinctId, even if you pass one.

mintLink is the channel-agnostic counterpart to email's send-time rewriter. Call it anywhere you hold the container's db (a workflow task, a connector action, a custom route):

import { mintLink } from "@hogsend/engine";

const { url } = await mintLink({
  db,
  url: "https://yourapp.com/welcome",
  baseUrl: env.API_PUBLIC_URL,
  source: "discord", // where it originated (open string)
  type: "personal",
  distinctId: contactKey, // honoured only for personal links
  label: "Discord welcome", // operator-facing name
  campaign: "discord-onboard", // grouping (mostly for public links)
});

// → url = https://api.yourapp.com/v1/t/c/<id>
// post `url` in the DM / channel / SMS / page

It inserts a durable links row (the named identity you manage) plus a tracked_links click-counter row, and returns the short redirect URL. The destination must be http(s) — non-http schemes are rejected at mint time, so a managed link is never an open redirect.

In Studio

The Links view mints and manages links without code: pick a destination, name it, choose public or personal, and copy the short URL. The list shows each link's live click count and lets you archive the ones you're done with. Archiving is a soft-delete — the short URL keeps redirecting and the click history survives.

The Links view in Hogsend Studio, listing tracked short links with click counts

The same surface is a REST API: POST /v1/admin/links to mint, GET /v1/admin/links to list, GET /v1/admin/links/:id for one link with its recent clicks, PATCH to rename/regroup/re-target, DELETE to archive.

From @hogsend/client, the same calls are the hs.links resource:

const link = await hs.links.create({
  url: "https://example.com/launch",
  slug: "spring-mailer", // idempotent: re-running returns the same link
});
// link.id, link.vanityUrl (/l/spring-mailer), link.existing

await hs.links.update(link.id, {
  originalUrl: "https://example.com/spring-offer-v2", // re-point a printed QR
});
const png = await hs.links.qr(link.id, { format: "png", size: 1024 }); // Uint8Array

create is idempotent: re-minting a slug with the same destination and type returns the existing link (existing: true) instead of a 409. For a slugless link (a standalone QR, say), pass an idempotencyKey and re-runs dedupe the same way. get, list, update, and archive round out the resource.

Vanity slugs

A managed link can carry a memorable slug layered over the UUID short URL:

https://api.yourapp.com/l/black-friday
  • Shape: 1–64 characters of a-z, 0-9, - — no leading or trailing hyphen. Input is lowercased before validation, so /l/Black-Friday resolves the same link.
  • Unique per instance. Minting or PATCHing a taken slug is a 409, with one exception: a re-mint that repeats a live link's destination and type returns that link (existing: true) instead.
  • Same click, same stats. /l/:slug resolves the link's canonical tracked row and runs the exact click pipeline /v1/t/c/:id runs — one counter, one link_clicks history, whichever URL was hit.
  • Lifecycle. Set a slug at mint (slug on mintLink or POST /v1/admin/links), replace it via PATCH, or clear it with slug: null — clearing frees the slug for reuse and stops the vanity path resolving (the UUID short URL keeps working). Archived links keep their slug reserved and keep redirecting.

QR codes

Every managed link can render a QR code:

GET /v1/admin/links/:id/qr?format=svg|png&size=64..2048&transparent=true|false

From the client, hs.links.qr(id, { format: "png" }) fetches the same code as bytes (a Uint8Array), and hs.links.qrUrl(id, { format: "svg" }) builds the URL without fetching it.

The code encodes the link's durable scan URL — never the vanity slug — so a printed code keeps working through slug changes and destination re-targets. Scans are recorded on a dedicated per-link scan row, which means link responses carry both clickCount (all entry paths) and scanCount (QR scans only).

The Studio QR codes view is the print-first lens over the same links:

  • New QR code mints a link from just a destination, a label, and a description (what/where the printed code physically is — how you tell codes apart in bulk).
  • The QR dialog previews the code and downloads it as PNG, PNG with a transparent background (for placing on your own artwork), or SVG.
  • Re-target in place. Change the destination and every already-printed copy redirects to the new target on its next scan.

Stats per destination

Every click and scan records the destination that was live when it landed. When you re-target a link, GET /v1/admin/links/:id returns a destinations array — one bucket per destination, each with its own clicks, scans, and first/last hit times — and the Studio QR dialog shows the same breakdown. The QR code on your door can point at this month's offer while last month's numbers stay attributed to last month's page.

Arrival attribution — "did a known user scan this?"

A redirect can't recognize the visitor: your app's cookies live on your site's domain, not the tracking host. Arrival attribution closes that gap with one extra hop — the landing page reports the visitor back.

  • Opt in per link (appendRef on mint/PATCH; the Studio toggle — default ON in the New QR code dialog). The redirect then appends hs_ref=<hit id> to the destination. Leave it off for destinations that reject unknown params (e.g. OAuth redirect URIs). Reserved params on tracked destinations: hs_t, hs_ref.
  • The landing page reports back to POST /v1/t/arrive with { ref, anonymousId? | userToken? }. With @hogsend/js this is automatic: the SDK reads hs_ref on init, sends the beacon with the session's identity, and strips the param (captureRef: false + hogsend.captureRef() for SPAs that route before init).
  • Identity is evidence-based, same rules as everywhere: a userToken (server-minted via generateUserToken) proves a userId — that's a known contact arrival. A bare anon id is provenance-only: it can never attach to an identified contact, and one that collides with an identified contact's key is discarded. The endpoint answers 200 {"ok":true} to every outcome.
  • What you get: the hit row is stamped with the visitor (first-write-wins — replays can't re-attribute), link detail carries arrivalCount + identifiedArrivalCount, the Studio QR dialog shows "N confirmed arrivals · M from known contacts", and a link.arrived event fires on the bus (trigger journeys on it; filter by linkId, campaign, or source: "qr") and on outbound webhooks.
  • link.arrivedlink.clicked: it only fires when the link opts in AND the landing page integrates — never trigger a journey on arrived expecting every click.

Server-rendered site without @hogsend/js? Read hs_ref from the request URL and POST it yourself — with a userToken minted server-side when the viewer is logged in:

import { generateUserToken } from "@hogsend/engine";

await fetch(`${HOGSEND_URL}/v1/t/arrive`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    ref: url.searchParams.get("hs_ref"),
    userToken: generateUserToken({
      userId: session.userId,
      secret: process.env.BETTER_AUTH_SECRET,
    }),
  }),
});

Click counts

The count is computed on read by summing tracked_links.click_count, so a click never writes back to the links row. Each click also records a link_clicks row (IP, user agent, timestamp), which the Studio link detail shows newest-first.

Identity, the share-safe way

A personal link can stitch a click to a person across devices. With TRACKING_IDENTITY_TOKEN=true, the redirect appends a short-lived, encrypted hs_t token; your landing site exchanges it for the distinct id and identifies the session:

// on the landing site, after arrival from a tracked link
const res = await fetch("https://api.yourapp.com/v1/t/identify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    token: params.get("hs_t"),
    currentDistinctId: posthog.get_distinct_id(), // optional: fold this session in
  }),
});
const { distinctId } = await res.json();
posthog.identify(distinctId); // the link click and the web session merge

The exchange is single-use: the first exchange of a token wins, and a replayed or reshared token is a 200 no-op. A forwarded personal link can therefore stitch at most once — it can't keep folding new sessions into the original person. (See Semantic links → cross-device identity for the token's AES-256-GCM encryption and the anti-hijack model.)

A public link has no token and no distinctId — there's nothing to identify, by design. You still get the click and its campaign.

How it relates to email

Email links and managed links share one click spine (tracked_links + link_clicks) but stay independent:

  • Email rewrites HTML at send time and leaves tracked_links.link_id NULL — its links belong to an email_sends row, not a managed links row.
  • mintLink creates a managed links row and points tracked_links.link_id at it.

So the Studio Links view lists only your managed links, not the per-send links inside every email. Both emit the same first-party click event.

What it is not

  • Not a public-link person tracker. A shared/public link attributes by campaign only — it can't tell you who clicked, because you can't put one person's identity on a link many people share.
  • Not a URL shortener for untrusted input. Destinations are validated http(s) and links are operator-minted (Studio or your own code); the redirect follows the stored URL, so it isn't an open redirect for arbitrary callers.

See also: Tracking API for the click/open endpoints and schema, and Semantic links for links whose clicks carry an answer.