Visits and people are different funnels
Everyone wants one funnel from ad click to payment. Building it means linking an anonymous visit to a named person, and that link is exactly what lets a site skip the consent banner.
The report everyone draws on the whiteboard
Ask a founder to sketch their funnel and you get one line of boxes: landed, viewed pricing, signed up, used the product, paid. One unit flows through it, one conversion rate comes out the end, and every box can be split by channel. That line hides a switch. The first two boxes are about a visit: an anonymous browser session with a source and a landing page and no name. The last three are about a person: an account with an id, a plan and a payment history. To draw one line through both, something has to say "this visit became this person". In most analytics tools that something is a cookie set on the first page and read again at signup, and that is the piece that needs consent.
Two halves, one thing crossing
With cookieless attribution the visit has no stored identifier to match later, so Vevee keeps two halves on purpose. Anonymous events count visits. Identified events count people. No identifier joins them. The only thing that crosses from one half to the other is the visit's source: at signup, the server copies the channel, UTM tags, referrer host and landing path onto the new person as their first touch, once, and drops the visit id before it does. The person keeps where they came from. The visit never learns who they became. That also fixes a quieter problem with the whiteboard funnel: it mixes units. Step one counts visits, step three counts people, and a visit that converted on the second try is a different number from a person who converted. A rate computed across that boundary does not measure anything precise.
One signup, two events
This is where most integrations go wrong the first time. The signup is the last step of the visit funnel and the first step of the person funnel, so it has to exist in both halves. A study app that set this up sends two events from its auth route when an account is created, and neither one points at the other. The anonymous one, signup_completed, carries the visit fields and the visit id and no distinct id. It closes the visit funnel. The identified one comes from identify with isSignup, which stores the first touch and lets the app's signed_up event open the person funnel. Leave out the anonymous twin and the visit half has no signup step at all.
// app/api/auth/init/route.ts, after the account was created in this request
import { contextFromHeaders } from '@vevee/sdk';
const { visit_id, ...firstTouch } = visit; // visit came from the browser
const context = contextFromHeaders(req.headers);
// 1. Anonymous half: closes the visit funnel. No distinctId.
if (visit_id) {
await vevee.analytics.capture({
event: 'signup_completed',
properties: { provider, visit_id, ...firstTouch },
context,
});
}
// 2. Person half: stores the first touch. Never sees visit_id.
await vevee.analytics.identify(user.id, { plan: 'free' }, undefined, {
isSignup: true,
firstTouch,
context,
});The pair, written down
The app keeps its funnels in a JSON file and pastes it into the funnel builder's Import JSON dialog, which accepts an array. Each funnel names its split: the visit funnel splits by the visit's channel, the person funnel by the person's first_channel. The string "channel" is shorthand for the visit split.
[
{
"name": "Visit to signup",
"window": "24h",
"range": "last_30_days",
"split": "channel",
"steps": ["$pageview", "login_page_viewed", "signup_completed"]
},
{
"name": "Signup to paid by channel",
"window": "14d",
"range": "last_30_days",
"split": { "source": "person", "field": "first_channel" },
"steps": ["signed_up", "generate_succeeded", "checkout_completed"]
}
]Reading them side by side
Put the two tables next to each other and read them as two questions. The visit funnel asks: of the visits each channel sent, how many signed up? The person funnel asks: of the people each channel brought, how many paid? Multiply them in your head if you like, but know that you are multiplying two rates with different units, so the product is an estimate. The signup counts at the seam will usually be close and rarely identical. In a mock month for the study app, paid ads show 25 signups in the visit funnel and 25 people in the person funnel, and the AI channel shows 24 in both. On real data they drift apart for ordinary reasons: a visit only converts within the same app day, so someone who signs up the next morning counts as a new visit; a signup with no visit id (the login flow lost it across a reload) gets no anonymous twin but still gets a direct first touch; people who signed up before the integration have no first touch and sit in their own row. When the two counts differ by a few, that is the design working.
What you lose, plainly
You cannot ask "which pages did this paying customer read before signing up". There is no path from a person back to their anonymous pageviews, and Vevee will not build one. If a funnel has a visit split and a person filter, it comes back empty, because anonymous visits have no person properties. What you keep is the question that pays for ads: which channel brings people who pay. For that, the source crossing over once at signup is enough. The setup for both halves is in the traffic attribution guide at /docs/guides/traffic-attribution.
Keep reading
Reading a split funnel
The funnel chart tells you how many got through. The split table under it tells you who. Here is a row by row reading of one, from a study app's mock month.
AnalyticsHybrid mode for privacy: put your own server in the middle
The browser never talks to Vevee. It talks to a forty-line route on the app's own server, which decides what leaves and what does not.