BlogAnalytics

First touch, stored once

A first touch is written exactly once, at the moment an account is created, and never again. Getting that "exactly once" right is most of the work.

Why once

A first touch answers "what brought this person here originally". Every later visit is a different question. If a login from a newsletter link could overwrite it, a customer who arrived from an ad in March would be credited to email in June, and the channel that actually paid for them would look worse every month. So the rule in Vevee is strict: the first touch is stored when identify is called with isSignup: true, and later calls never change it. Send firstTouch without isSignup and the server ignores it and tells you so. That way a returning user who logs in on a new device, with a fresh visit full of new UTM tags, can never get a fake first touch.

Step 1: the browser hands over the visit

The visit lives in memory in the browser, so the signup request has to carry it. A study app does this from its login page: when the session exists, it posts to its own auth init route with attribution.current() in the body. There is one trap. An email code or magic link forces a full page load, and the in-memory visit does not survive it. Before leaving the login page, the app appends attribution.toParam() to the next URL as a vv query parameter, and its root analytics component calls attribution.fromParam() right after start(). When the carried visit and the current one disagree, the one with the earlier start time wins, so a referrer like mail.google.com on the return trip cannot replace the ad that started it all.

// login page (client), once the session exists
import { attribution } from '@vevee/sdk/browser';

await fetch('/api/auth/init', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ entryPath, visit: attribution.current() }),
});

Step 2: the server decides it is a signup

Only the server knows for sure that the account was created in this request, so the identify call that marks the signup belongs there, with the secret key. The app computes isFreshSignup from its own database (the profile row was inserted just now), re-validates the visit it received, and removes the visit id before anything reaches the person record. Vevee would drop it anyway; the app prefers that it never leaves its server attached to a user. The visitor's country and user agent come from the request headers. A secret key call cannot see them on its own, since the request Vevee receives comes from the app's server.

// app/api/auth/init/route.ts
import { createClient, contextFromHeaders, type FirstTouch } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

const body = (await req.json()) as { visit?: FirstTouch | null };
const { visit_id: _dropped, ...firstTouch } = sanitizeVisit(body.visit);

const res = await vevee.analytics.identify(user.id, { plan: 'free' }, undefined, {
  isSignup: isFreshSignup,
  ...(isFreshSignup ? { firstTouch } : {}),
  context: contextFromHeaders(req.headers),
});

// res.firstTouchStored === true on the signup call.
// On any later call: firstTouchStored === false, and if a firstTouch was
// sent without isSignup, res.firstTouch === 'ignored'.

Step 3: Vevee fills in the rest

What gets stored is small. The UTM tags, the referrer host, the landing path, a day, a country and a device description. The day (touched_at) is a calendar date in the app's time zone, not a timestamp. From the context, only the country and the device class, browser and OS are kept; a region or city sent along is accepted and discarded for a first touch. The values are mirrored into person properties so funnels and filters can use them like any other: first_utm_source, first_utm_medium, first_utm_campaign, first_utm_term, first_utm_content, first_referrer_host, first_landing_path, first_channel, first_touch_at, first_country and first_device. People who are opted out, or waiting to be deleted, get no first touch at all.

Why the channel is derived on the server

first_channel is the property everyone ends up splitting by, so it has to mean the same thing for every person. If the browser sent it, two versions of the app's code could disagree about whether utm_source=newsletter with no medium is email or referral, and old rows would keep whatever the old code thought. A channel sent by the client is ignored. The server applies one ordered rule list to the stored UTM tags and referrer host: paid, then email, then ai, organic_search, social, referral, and direct when there is nothing at all. A visit tagged utm_source=facebook with utm_medium=paid_social is paid, because the medium matches the paid rule before the social rule is reached. The same link with no medium is social. When the rules improve, every person is judged by the same version.

The edge cases worth testing

Before shipping, the app ran each of these against test mode and checked the response and the person record:

  • A signup landing on ?utm_source=facebook&utm_medium=paid_social: firstTouchStored is true and first_channel is paid.
  • The same user logging in again from a different campaign link: firstTouchStored is false and the person record is unchanged.
  • A signup with no visit at all (start() never ran): isSignup: true alone records a direct first touch for that day.
  • A signup through the email code flow: the source from before the reload survives, because vv carried it.
  • A user who signed up before the integration shipped: no first touch, shown as "(no first touch)" in person splits.

Where it goes next

Once the first touch is on the person, everything that happens to that person can be read by it. Revenue sent from the payment webhook is credited to the person's first touch, so the Traffic page can show orders by channel and campaign. The person funnel can split signup to paid by first_channel. The identify reference is at /docs/methods/identify, and the full browser to server flow is in the traffic attribution guide at /docs/guides/traffic-attribution.

See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist