Attribution without a cookie banner
The visit lives in a JavaScript variable. It is born when the page loads and gone when the tab closes, and in between it is enough to tell where every signup came from.
Last updated: 2026-10-22
What a banner is actually asking about
Most of the friction around consent comes from one rule. The EU ePrivacy rules say that storing information on a visitor's device, or reading information already stored there, needs consent unless it is strictly necessary for the service the visitor asked for. A cookie that remembers "this browser came from a Facebook ad on Tuesday" is not strictly necessary for showing a study app's landing page, so it needs consent. So does the same thing in localStorage, sessionStorage or IndexedDB. The rule cares about the device, not about the word cookie. That points at a design question most analytics tools never ask: what can you learn about a visit if you never write to the device at all?
More than you would think
Everything attribution needs is already sitting in the page when it loads. The URL has the utm_* parameters the ad or newsletter put there. document.referrer has the page the visitor clicked from. window.location.pathname has the landing page. None of that requires storing anything; the browser hands it to every script on the page. The browser helper in @vevee/sdk/browser reads those three things once, adds a random visit id and a start time, and keeps the result in a module variable. That is the whole state. It writes no cookie, no localStorage, no sessionStorage and no IndexedDB entry. It reduces the referrer to its host, so a referring URL with a search query in it never travels further. It ignores click ids such as fbclid and gclid entirely.
import { attribution } from '@vevee/sdk/browser';
attribution.start();
attribution.current();
// {
// visit_id: '3f1c9a2e-7b4d-4e0a-9c55-0d2b8e61a7f4',
// started_at: '2026-10-27T09:14:02.511Z',
// utm_source: 'facebook',
// utm_medium: 'paid_social',
// utm_campaign: 'oct-pricing',
// landing_path: '/pricing'
// }
//
// referrer_host appears when the visitor came from another site.
// Nothing above is written anywhere. Reload the tab and it is gone.Getting it off the page
A variable that dies with the tab is useless unless something reads it before then. The helper sends a pageview for each route change, carrying the visit fields, through a send function you give it. A study app that adopted this mounts one client component in its root layout and points send at its own server route, so the browser never holds a Vevee key and never calls Vevee directly. Every other event the app tracks before signup picks up the same visit fields with attribution.properties(). From then on the visit is a set of anonymous events on the server. Vevee stores a salted hash of the visit id, with a salt that rotates daily and is deleted within about 48 hours, so pageviews of one visit can be grouped on one day and nobody can rebuild the link afterwards. The raw visit id is never stored.
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { attribution, type PageviewEvent } from '@vevee/sdk/browser';
function send(e: PageviewEvent) {
return fetch('/api/analytics/capture', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(e),
keepalive: true,
}).then(() => undefined);
}
export function SiteAnalytics() {
const pathname = usePathname();
useEffect(() => { attribution.start({ send }); }, []);
useEffect(() => { attribution.pageview(pathname ?? undefined); }, [pathname]);
return null;
}What you give up
A cookie exists to recognize the same browser tomorrow. Without one, you cannot. Be honest with yourself about what that costs before you switch:
- No unique visitors across visits. A person who reads the pricing page on Monday and comes back on Thursday is two visits, and the second one shows as direct.
- A reload or a new tab starts a new visit. A login flow that forces a full page load needs the visit carried in the URL (toParam on the way out, fromParam on the way back), or the source is lost at the worst possible moment.
- A visit that crosses midnight in the app's time zone counts as two.
- People who signed up before you shipped this have no first touch at all.
Why that trade was fine for a study app
The question the app needed answered was narrow: which channel and campaign started the visit that ended in a signup, and later in an order. Almost all of its signups happen in the first visit, often within minutes, on a phone. The returning-visitor blind spot mostly hits long B2B buying cycles, where someone reads your site for three weeks before booking a demo. For a product people try on the spot, the first visit is most of the story. On the other side of the scale was a banner on every landing page, shown to students who had not yet seen what the app does. The app chose the blind spot.
The app's reading of the rules
Here is how the study app reads its own setup. This is its reading, written for its privacy notice, and it is not legal advice; check your own case with someone qualified. Nothing is stored on or read from the device for analytics, so the ePrivacy device-storage rule does not come into play and no consent is asked for. The visit lives in page memory and is gone when the tab closes. For what happens on the server (anonymous events, and the first touch copied onto a person at signup), the app relies on legitimate interest under GDPR Article 6(1)(f), with data minimisation: landing paths lose their query string, the IP address is never stored, click ids are dropped, and the visit id is removed before the person record is written. Users the app knows to be under 16 are opted out automatically. The line it put in its privacy notice reads: "This is kept only in the page's memory while you browse: no cookie or other storage is used, your IP address is not stored, and ad click identifiers (such as fbclid or gclid) are removed." The full setup, including carrying the visit through an email login, is in the traffic attribution guide at /docs/guides/traffic-attribution.
More from the blog
The race condition in "if (usage < limit)" that is costing your AI app money
A user at nine of ten images opens six tabs and clicks generate in all of them. Every tab passes your limit check, every tab gets an image, and you pay for all six. The bug is one read-then-write - and your unit tests will never catch it.
engineering · 9 minHow to build a credits system for your AI app (ledger design, rollover, refunds)
A user emails: "I had 40 credits this morning and now I have 12, and I only made one image." If your balance is a column you mutated, you cannot answer that email. Here is the ledger design that can.
thinking · 7 minStripe metered billing for AI apps: what it solves and what it does not
You are sketching the pricing page for your AI app and every plan ends in "per generation" - because your costs arrive per generation. Stripe meters the charging half beautifully. The question is what happens at request time.
thinking · 8 minHelicone is in maintenance mode. An honest map of where to go
Somewhere in your codebase a base URL points at Helicone, and it has been quietly doing four jobs at once. The team got acquired - good for them - but now you have to replace each job separately, and the one everyone forgets is the one your users will exploit.
engineering · 8 minHow to stop free-tier abuse without killing signups
The usage graph spikes at 3:41am: four thousand free generations in an hour, all from disposable emails. Your first instinct is a credit-card wall. That instinct will cost you more than the abuse does.
thinking · 7 minTokens, credits, or requests: choosing the unit you meter (and price)
The pricing doc has three columns: $9, $19, $49. The prices took twenty minutes. The row above them - what a user actually gets for the money - has been blank for a week. That blank row is the real decision.
engineering · 3 minI rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
engineering · 3 minThe trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.
engineering · 3 minStop making users do math on your pricing page: recommend their plan from their own usage
Every pricing page asks the user to solve an estimation problem: "How many credits will I need per month?" Nobody knows. But for any user who has actually used your product, their usage history IS the answer - here is how to put it on the pricing page.
engineering · 3 minWin-back emails fail because they're written for "users." Write them for the one user instead.
Every dormant-user campaign in history: "We miss you! Here's what's new." Open rate: pity clicks. The email fails because it's about your changelog - and the user left because of something in their experience.
engineering · 3 minSpotify Wrapped is a growth loop, not a year-end gimmick. Ship one for your AI app in an afternoon.
Spotify Wrapped works because people love seeing their own behavior reflected back as a story. Every AI app with usage data can run that loop monthly - and almost none do, because turning usage rows into narrative used to be a content problem. It is now a schema problem.
engineering · 3 min"AI-personalized copy converts better" - prove it or delete it. Here is the 40-line A/B harness
Half of the "we added AI personalization and conversions went up 40%" posts have no control group. The other half measured clicks, not revenue. If LLM-generated copy is going in front of your paywall, you owe yourself a real experiment - and the harness is tiny.
thinking · 3 minAI personalization without the creepy part: opt-out as a first-class return value
Users have learned that "personalized for you" means "we mined everything you ever did" - and the backlash is rational. When I added LLM-generated personalized copy to my app, the part I sweated was not the generation. It was making declining it a real, respected choice.
engineering · 3 minI stopped walking into demo calls blind: every lead now comes with a usage brief written 10 seconds before the call
Founder-led sales has one structural weakness: you have no time to prep. If the lead has touched your product, their usage history is the best discovery call you'll never have to run - here's how I turn it into a one-page brief, automatically, before every call.
thinking · 4 minThe "founder email" converts like crazy and scales like garbage. Here is the middle path.
A personal email from the founder converts trials at a rate no automated sequence touches - and at 50 signups a week it stops scaling. The middle path: drafts generated from each user's real usage, that you read, edit, and send yourself.
engineering · 3 minChurn doesn't announce itself. My Monday Slack digest does it instead.
Every founder finds out about churn the same way: the cancellation email. By then the user has been gone for weeks - the decision happened earlier, quietly, in their usage. So I made the metering tables write me a memo.
engineering · 3 minHalf of every support ticket is asking what the user already did. Attach the answer instead.
The ticket says "it's not working" - and the next twenty minutes go to figuring out who this user is, what plan they're on, and whether "it" is a bug, a quota, or a misunderstanding. The actual fix usually takes two. All of that context lives in your usage data; here is how to attach it to every ticket automatically.
thinking · 4 minYour users are telling you your roadmap, in writing, every day. It's in your prompt logs
Founders pay for interviews and beg for survey responses to learn what people are actually trying to do. Meanwhile, your users type their intent into your product, in their own words, hundreds of times a day - and nobody performs for a prompt box.
thinking · 5 minWe ran a tagged ad and could not find one buyer
Every link in the ad had UTM tags. The checkout, the analytics tool and the host each saw part of the story, and none of them could say whether a single person who clicked ever paid.
thinking · 5 minVisits 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.
thinking · 4 minAI search is a channel now
Paid ads brought the most visits. A channel nobody paid for converted visits to signups at almost three times the rate. Until it had its own row, it was invisible.
engineering · 5 minSeven bugs we hit adding attribution
Zero visits on a day with traffic. Zero revenue after two orders. A funnel step that never fired because of one trailing dot. None of these were hard once found, and every one of them looked like something else first.
engineering · 5 minFirst 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.
engineering · 5 minHybrid 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.
engineering · 6 minReading 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.
engineering · 5 minHow to reset usage limits when a subscription renews
A weekly plan, used twice. First week: ten generations. Second week: zero, because the counter never reset. This is the cron-job mistake - and the fix is one field on one call.
engineering · 6 minHow to manage subscription renewals: aligning Vevee with Stripe
A user signs up on Jan 15. Stripe charges them on the 15th of every month. Your metering layer resets on the 1st. Two clocks. One angry support ticket per cycle.
engineering · 5 minStop hardcoding your pricing page - render it from your metering layer
Every B2B SaaS I have shipped repeats the same mistake: plans live in two places - the dashboard that enforces them, and a const PLANS = [...] on the marketing site. They drift within a quarter.
thinking · 4 minMeter AI by user, not by account - your margin depends on it
A few users will cost you 100x what your median user costs. If you only meter at the account level, you will not see them coming until your gross margin is gone.
engineering · 5 minreserve / commit / release: the only correct way to enforce AI quotas
Every team I have seen build per-user AI metering has shipped a version of canUse → call OpenAI → track. It looks correct in single-threaded tests. It is broken in production.
thinking · 4 minWhy Stripe Billing is not enough for AI products
Stripe is excellent at one thing: turning usage into invoices. AI products need three other things, and Stripe does not do any of them.
engineering · 6 minDynamic onboarding: a different first step for every user
A teacher and a student sign up the same minute. The teacher wants to build a quiz; the student wants to summarize a lecture. Your onboarding shows them both the same five-step checklist. One of them bounces.
thinking · 6 minPaywall copy that rewrites itself for every user
Your paywall says "Upgrade to Pro for unlimited generations." A teacher reads it and shrugs. A student on a budget reads it and closes the tab. The same words, two lost conversions - because the words were written for nobody in particular.
engineering · 5 minAdd usage limits to your AI app in 10 minutes (no backend required)
You shipped an AI feature on Friday. By Monday one user had burned $212 of OpenAI credit on your free tier. The fix is not a TODO comment that says "add rate limiting" - it is two method calls.
engineering · 5 minMeter LLM tokens, not requests - your flat per-request limit is lying
Two users, both at 100 requests. One sent tweets, the other sent novels. Your cost for them differed by 400x. Your limit treated them identically - and your margins noticed.
engineering · 7 minThe upgrade nudge that writes itself: convert free users before they hit the wall
By the time a user hits your paywall, they are blocked, annoyed, and halfway to a competitor’s signup page. The best moment to make the pitch was three days earlier - when they were winning. Here is how to catch it, automatically, for every user at once.
engineering · 5 minOne event, two limits: gate your premium model without forking your code
The premium model launch was going great until you looked at the bill: free users had figured out the good model and were living on it. You need a sub-limit. You do not need a second code path.
engineering · 5 minTest mode: break your pricing in the sandbox, not on your customers
You changed the free tier from 10 to 25 generations and somehow locked out every Pro user for an hour. Nobody tested it, because testing it meant tracking fake events into production analytics. There is a mode for this.
engineering · 5 minThe support ticket that solves itself: log the prompt behind every AI event
A user says your AI feature "broke" on Tuesday. You have a charge for the call, a timestamp, and no idea what they asked or what the model said. The evidence existed for exactly one request - the one you didn’t log.
engineering · 5 minYour funnel has one broken step. Find it without writing a single SQL query.
A hundred people saw your paywall this week. Three upgraded. Is that a copy problem, a price problem, or did ninety of them never generate anything worth paying for? You cannot fix what you cannot locate.
engineering · 5 minOne user, three ghosts: fix your funnel with identify()
Your funnel says signup conversion is 4%. It is actually 11%. The missing users didn’t bounce - they came back on another tab and got counted as someone new. Every number downstream of that split is wrong.
thinking · 5 minThe usage bar that sells the upgrade (build it with a public key in an afternoon)
An invisible limit feels like a trap. A visible one feels like a fuel gauge. Same quota, same plan, same user - and a measurably different reaction when the wall finally arrives.
engineering · 6 minHow to cancel a subscription without burning the bridge (or your data)
The user cancels. Do they drop to a free tier, or lose access when the paid month runs out? Those are different products, different SDK calls, and different mistakes when you get them wrong.