Skip to Content
The production modelFirst-touch attribution

First-touch attribution

Applies to The Fabrica v0.1.2. Documentation source: 1ee1bc9cb661, including post-release documentation corrections.

First-touch, not last-touch

When Alice clicks a Twitter ad, lands on /?utm_source=twitter&utm_medium=cpc, bounces, then comes back 2 days later via direct traffic and signs up, our attribution says Twitter. Not “direct”. Not “two sources, weighted”. Twitter, period.

The competitor pattern

Last-touch attribution: whatever the user came in from on the signup session wins. Simple — read URL params during sign-up, ship to backend, done.

// On the signup page itself const params = new URLSearchParams(window.location.search) await api.post("/me/signup-metadata", { utm_source: params.get("utm_source"), utm_medium: params.get("utm_medium"), })

Where last-touch breaks

The classic SaaS funnel:

  1. User clicks Twitter ad with utm_* params → lands on /blog/article-1.
  2. Reads, doesn’t sign up. Closes tab.
  3. Tomorrow remembers, types thefabrica.dev directly.
  4. Signs up.

Last-touch attribution: source = direct. Twitter ad gets zero credit. Your marketing dashboard shows organic / direct converting at twice the rate of paid → you cut the Twitter budget → conversions drop → you wonder why.

What we ship

// lib/utm.ts const STORAGE_KEY = `${PROJECT_SLUG}:utm:v1` const TTL_MS = 30 * 24 * 60 * 60 * 1000 export function storeIfEmpty(capture: UtmCapture): void { if (localStorage.getItem(STORAGE_KEY)) return // ⬅ first-touch lock localStorage.setItem(STORAGE_KEY, JSON.stringify(capture)) }
  • localStorage, not sessionStorage. Survives tab close + browser restart. Last-touch via sessionStorage loses the Twitter visit when the user closes the tab.
  • 30-day TTL. Long enough to capture the typical “read content, come back later, sign up” buyer journey. Short enough that a user who clears their cookies + reinstalls is treated as fresh on the next campaign.
  • storeIfEmpty, not store. First-touch hard lock. A returning visitor’s Reddit referrer doesn’t overwrite the original Twitter attribution.

Pair with the backend’s idempotent-once write semantics (/me/signup-metadata returns recorded: false if attribution already set) → end-to-end: even if the frontend flushes the same capture twice, the backend ignores the second write.

Why not multi-touch?

Multi-touch (track every campaign, weight by recency / position) is correct in theory and a nightmare in practice:

  • Schema bloat: one row per touch per user, not one row per user.
  • Reporting ambiguity: “Twitter contributed 40% to Alice’s conversion” doesn’t translate to “spend more on Twitter”.
  • Compliance overhead: each tracked touch is a PII row, all subject to GDPR delete-and-export requirements.

First-touch is wrong sometimes (a user who actually converted because of the third email gets attributed to the first ad). It’s wrong in a known direction — biased toward TOP-of-funnel campaigns. That’s the trade-off we ship: predictable, simple, easy to reason about when reallocating budget.

30-day TTL trade-off

A buyer running long-cycle B2B sales might want 90 days. Override:

// lib/utm.ts const TTL_MS = 90 * 24 * 60 * 60 * 1000

A buyer running short-cycle consumer might want 7 days (you trust that anyone who waits longer is converting on something else). Same file, same line.

Receipts

  • Capture: frontend/src/lib/utm.ts
  • Hook: frontend/src/hooks/use-utm-capture.ts
  • Provider: frontend/src/components/providers/utm-provider.tsx
  • Backend endpoint: backend/src/api/routers/account.py:record_signup_metadata

Why capture goes through the frontend

The factory’s POST /api/v1/account/me/signup-metadata accepts UTM fields from a frontend client. Couldn’t we just have the backend extract them from the request’s Referer header on the Clerk user.created webhook? No. Three reasons.

1. Clerk’s webhook isn’t the user’s request

Browser → Clerk → Clerk's webhook → our backend

The Referer header on the webhook request is https://clerk.com/... (Clerk’s server origin), not whatever URL the user landed on. The actual landing-page URL never reaches our backend in that flow — Clerk doesn’t forward it.

To capture UTM server-side we’d have to:

  1. Read params from Clerk’s signup URL via Clerk’s custom-data field.
  2. Configure Clerk to pass them through (different API per Clerk plan tier).
  3. Trust whatever the frontend wrote into the custom-data field (which is just localStorage → form-field → Clerk → us — same trust chain, more hops).

Frontend POST direct to backend is shorter and equally trustworthy.

2. The signup might be days after the landing

Day 0: Alice visits via Twitter ad → reads blog Day 0: Alice closes tab Day 5: Alice opens a new tab, types thefabrica.dev directly Day 5: Alice signs up

There is no request-time signal on Day 5 that the user came from Twitter on Day 0. localStorage IS the signal — and localStorage lives in the browser. The backend can’t read it server-side; the frontend has to ferry it.

3. The flow doesn’t gate on auth

Day 0: Anonymous user visits → captureFromCurrentPage() → storeIfEmpty() Day 5: Same user signs up → useAuth().isSignedIn flips true → flush

The first effect (capture) runs on any page visit, including fully anonymous ones. We don’t have a backend request to attach the capture to until the user actually signs up. The frontend holds the state in the meantime.

A backend-only design would require either:

  • An anonymous “pre-signup attribution” endpoint that mints a visitor_id cookie → tracks all attributions per visitor_id → later links visitor_id to user_id on signup. Three tables, GDPR surface, cookie-banner consent angle. Way more.
  • Skipping pre-signup capture entirely → losing the buyer journey pattern that first-touch is designed for.

What this means for buyers

The Pythonic side of the boilerplate doesn’t have to ship a tracking service. The frontend hook does the work. Backend stays minimal: one endpoint, one DB write, one idempotency rule. Buyer instinct to “do it server-side because frontend can be bypassed” doesn’t apply here — the worst a bypass does is fail to capture attribution (Alice’s attribution stays NULL), which is the same as the natural failure mode (Alice disables JS).

The one trade-off: if a buyer specifically wants server-side last-touch fallback (“if frontend didn’t ship a capture in N days, extract Referer from the first authenticated request”), they wire it in deps.py on top of the lazy-create path. That is buyer territory — the factory ships no hook for it.

Receipts

  • Frontend ferry: frontend/src/hooks/use-utm-capture.ts
  • Backend receiver: backend/src/api/routers/account.py:record_signup_metadata

Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).

Extracted from docs/recipes/first-touch-utm.md, docs/recipes/why-utm-via-frontend.md.

That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.

Last updated on