Cookieless traffic and revenue attribution: which ad brought paying users
How a study app tied every signup and every order to the visit that brought it, without a cookie banner. The visit lives in browser memory, the first touch is stored once at signup, and orders from the payment webhook give each channel a revenue column.
Last updated: 2026-10-28
The problem
A study app ran its first paid social ad with UTM tags on every link. A few days later nobody could say whether a single buyer had come from it.
The checkout knew nothing about the source, campaign or landing page. The analytics tool knew the user and their onboarding answer to "How did you hear about us?", but never saw the UTMs. The hosting provider's analytics saw the URLs, kept its UTM report behind a paid add-on, and could not join a visit to a later purchase. The onboarding answer was self-reported: in one example week, 1 person in 40 answered "an ad" while the ad brought 60 visitors.
The usual fix, a cookie tool or an ad pixel, comes with a consent banner. On an app for students, a banner on the landing page costs signups. The team wanted every signup and order attributed to its first visit, nothing stored on the device, and a site with no banner.
The solution
The browser keeps the visit in memory. @vevee/sdk/browser reads the landing URL once and holds a random visit id, the UTM tags, the referrer host and the landing path in a JavaScript variable. No cookie, no storage. A reload or a new tab starts a new visit, unless the app carries it through the URL with toParam and fromParam.
The browser never talks to Vevee. It posts to the app's own route, which re-validates the visit, adds the visitor's coarse location and device from the host's headers, and calls Vevee with the secret key (hybrid mode).
At signup the server calls identify with isSignup: true and the visit as firstTouch. Vevee stores it on the person once and derives a channel from it. Orders and refunds come from the payment webhook through vevee.revenue and are credited to that first touch.
Anonymous events count visits; identified events count people. Vevee never links one to the other, and the source is the only thing that crosses at signup.
Example
The signup route stores the first touch on the new person and sends an anonymous twin of the signup that closes the visit funnel. Nothing joins the two events.
import { createClient, contextFromHeaders, type FirstTouch } from "@vevee/sdk";
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
export async function onSignup(req: Request, userId: string, visit: (FirstTouch & { visit_id?: string }) | null) {
const context = contextFromHeaders(req.headers);
const { visit_id, ...firstTouch } = visit ?? {};
// Identified half: the person keeps their first touch, without the visit id.
await vevee.analytics.identify(userId, { plan: "free" }, undefined, {
isSignup: true,
...(visit ? { firstTouch } : {}),
context, // only country and device class are kept at signup
});
// Anonymous half: closes the visit funnel. No distinctId.
if (visit_id) {
await vevee.analytics.capture({
event: "signup_completed",
properties: { visit_id, ...firstTouch },
context,
});
}
}
// Later, in the payment webhook:
export async function onOrderPaid(order: { id: string; userId: string; netAmount: number; currency: string }) {
await vevee.revenue
.order({
distinctId: order.userId,
orderId: order.id, // idempotent on the provider's id
amount: order.netAmount, // minor units, after discounts, before tax
currency: order.currency,
billingReason: "subscription_create",
})
.catch((err) => console.error("[vevee] revenue.order", err)); // never fail the webhook
}Let your coding agent do it
Paste this into Claude Code, Cursor or another coding agent from your app's repository.
Add Vevee cookieless traffic attribution to this app. Use only the official docs below as the source of truth: fetch them before writing code, and follow them over anything you remember. Read first (whole SDK reference plus the docs in one file): https://www.vevee.org/llms-full.txt Then the pages for this feature: - Step by step guide: https://www.vevee.org/docs/guides/traffic-attribution - Browser helper (@vevee/sdk/browser): https://www.vevee.org/docs/methods/browser-attribution - identify at signup (isSignup, firstTouch, context): https://www.vevee.org/docs/methods/identify - capture with context: https://www.vevee.org/docs/methods/capture - Revenue from the payment webhook: https://www.vevee.org/docs/methods/revenue - How channels are assigned: https://www.vevee.org/docs/concepts/channels - Funnel splits and the JSON import: https://www.vevee.org/docs/guides/funnel-splits - Next.js walkthrough: https://www.vevee.org/guides/cookieless-attribution-nextjs - Webhook walkthrough: https://www.vevee.org/guides/revenue-from-payment-webhook - Testing with fake traffic: https://www.vevee.org/guides/test-attribution-fake-traffic Requirements: - Install @vevee/sdk@^1.14.0. The browser uses only @vevee/sdk/browser (no API key there). The secret key stays on the server. - Hybrid mode: the browser posts to our own API route, which forwards to Vevee with contextFromHeaders(req.headers). Never send or store the IP. - No cookies, localStorage or sessionStorage for attribution: the visit lives in memory only. - At signup: identify with isSignup: true and firstTouch from attribution.current(). - Send revenue.order / revenue.refund from the existing payment webhook. Amounts are integer minor units; the order id is the idempotency key. - Ad links must use a paid utm_medium (for example utm_medium=paid_social), or the visit counts as social, not paid. - If this app is not Next.js, apply the same pattern with its own routing and server. - Test everything with an sk_test_ key before going live. Before coding, read the codebase and give me a short plan: which files you will add or change, and where our signup and payment webhook live. Wait for my OK, then implement.
Three roads into Vevee
Every piece of data takes one of three paths, and each path lands in a different place.
- Pageviews go into the anonymous half, with the visit fields and coarse geo.
- The signup goes into both halves as two unlinked events: identify with the first touch on the person, and an anonymous signup_completed with the visit.
- Revenue goes into the person's record, from the payment webhook, and is credited to their first touch.
What the dashboard showed (example data)
Mock numbers from an example month, to show the shape of the result. Across 7 channels and 1,070 visits, paid ads brought the most visits, but AI search converted visits to signups at almost three times the rate. Revenue net of tax was $703.78: $0.66 per visit across all channels and $1.64 per visit for AI search. The top campaign was oct-pricing (paid social), the top country was Italy with 31% of visits, and the top device was iPhone with 44%. In the signup to paid funnel split by first_channel, ai had 7 paid out of 24 signups and paid ads had 4 out of 25.
Two funnels instead of one
Because visits and people are never joined, the study app keeps two funnels in one JSON file: visit to signup split by channel (counts visits), and signup to paid split by first_channel (counts people). The visit funnel matches the Traffic page row by row for the same days, since both read the channel from the earliest event of each visit and both count by the app's local day.
Privacy, as the app reads it
Nothing is stored on or read from the device for analytics, so the study app's reading is that the ePrivacy device-storage rule does not ask for consent here. For the rest it relies on legitimate interest (GDPR Art. 6(1)(f)) with data minimisation. This is the app's own reading, not legal advice. The rules it follows:
- The visit lives in a JavaScript variable and dies with the tab.
- visit_id is dropped before identify. Signed-in users send no pageviews, and identified events carry no visit fields.
- Landing paths lose their query string. IP addresses and ad click ids (fbclid, gclid) are never stored.
- At signup only the country and device class are kept with the first touch.
- Opted-out people are recorded under a random code. Users under 16 are opted out automatically and send no revenue events.
What it cannot do
There are no unique visitors across visits, so a visitor who comes back in a new visit before signing up shows as direct. A visit crossing midnight in the app's time zone counts as two. People who signed up before the integration have no first touch and sit in their own "(no first touch)" row. The study app accepted these limits in exchange for running without a banner.
Build it yourself
Four guides cover the same setup step by step: "Add cookieless traffic attribution to a Next.js app", "Send revenue from your payment webhook", "Build the two attribution funnels from JSON" and "Test attribution with fake traffic in test mode". The reference lives at /docs/guides/traffic-attribution.
Frequently asked questions
Which SDK version do I need?
@vevee/sdk 1.14.0 or later. It adds the @vevee/sdk/browser helper, the isSignup, firstTouch and context options on identify, context on capture, and the vevee.revenue namespace.
How is the channel decided?
On the server, from the UTMs and the referrer of the first touch. It is one of paid, email, ai, organic_search, social, referral or direct. A channel sent by the client is ignored.
Does it work without a proxy route?
The browser helper can send events straight to Vevee with a public pk_ key. The study app uses a proxy so the server can validate the visit, keep the secret key off the client, and add coarse geo from the host's headers without touching the IP.
Is the visit linked to the person after signup?
No. The person keeps their first touch, and the visit never learns who they became. Vevee stores a salted daily hash of the visit id instead of the id itself, and deletes each day's salt within about 48 hours.
Are these numbers real?
No. Every number on this page is example data chosen to illustrate the setup.
Other use cases
LLM usage metering: track tokens per end-user, across providers
Meter LLM token usage per end-user across OpenAI, Anthropic, Gemini, Mistral, and any other provider. Composite events for prompt + completion tokens, real-time per-user limits, atomic enforcement. The drop-in pattern for AI apps.
Use caseImage generation quotas: per-user limits for DALL·E, Flux, Stable Diffusion
Enforce per-user quotas on image generation across DALL·E, Flux, Stable Diffusion, Midjourney API, and Replicate. Atomic reservation pattern stops parallel renders from overshooting. Free tier, premium tier, hard caps - drop in.
Use caseAI agent billing: meter multi-step agents and tool calls
Metering AI agents is harder than metering single LLM calls. One "agent run" can fan out into 20 tool calls and 50 LLM calls. Vevee handles agent-level and step-level metering with composite events and atomic reservations.
Use caseFreemium AI SaaS: ship a free → paid funnel without a backend
Build a freemium AI product where the free plan has hard quotas, the paid plan unlocks more, and "you have used 80% of your free renders" nudges drive upgrades. Drop-in implementation, ten minutes from zero to live.
Use caseToken-based pricing: charge users for actual AI consumption
Charge AI app users by tokens, requests, or compute seconds. Pre-paid credits, post-paid invoicing, hybrid models - implementation patterns and trade-offs from someone who has shipped all three.