VeveeGuide · 9 min read
Guide · 9 min read

Test attribution with fake traffic in test mode

Check a cookieless attribution setup before real visitors arrive: a probe script that hits every call once, a seed script that fills test mode with believable traffic and refuses anything but an sk_test_ key, and a cross-check of the visit funnel against the Traffic page.

Last updated: 2026-10-28

Attribution bugs are quiet. A missing time zone moves visits to the wrong day, a typo in an event name keeps a funnel step at zero, and none of it throws. You find out weeks later, when the numbers you were waiting for are not there.

Vevee's test mode is a separate universe with the same app, plans and funnels, selected by using an sk_test_ key. This guide shows the four checks a study app ran there before shipping: a probe of every call, a real browser signup, seeded fake traffic, and a row by row comparison of the visit funnel with the Traffic page.

Step-by-step

1. Point a script at test mode, and only test mode

Create an sk_test_ key in the dashboard and put it in its own variable. Every test script should refuse to run with any other key. A seed script pointed at a live key would write hundreds of fake visits and orders into your real numbers, and there is no undo button for that.

// scripts/_vevee-test.ts
import { createClient } from "@vevee/sdk";

const key = process.env.VEVEE_TEST_KEY ?? "";
if (!key.startsWith("sk_test_")) {
  console.error("Refusing to run: VEVEE_TEST_KEY must start with sk_test_");
  process.exit(1);
}

export const vevee = createClient({ apiKey: key });

2. Probe each call once

Before seeding anything, send one of each: an anonymous $pageview with UTMs, an identify with isSignup and firstTouch, an order and a refund. Each should succeed, and the identify response should say firstTouchStored: true. If it says firstTouch: "ignored", the call was missing isSignup: true.

import { randomUUID } from "node:crypto";
import { vevee } from "./_vevee-test";

const visitId = randomUUID();
const userId = "probe_" + Date.now();
const ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1";

await vevee.analytics.capture({
  event: "$pageview",
  properties: { path: "/", visit_id: visitId, utm_source: "facebook", utm_medium: "paid_social", utm_campaign: "probe", landing_path: "/" },
  context: { geo: { country: "IT", city: "Milan" }, userAgent: ua },
});

const id = await vevee.analytics.identify(userId, { plan: "free" }, undefined, {
  isSignup: true,
  firstTouch: { started_at: new Date().toISOString(), utm_source: "facebook", utm_medium: "paid_social", utm_campaign: "probe", landing_path: "/" },
  context: { geo: { country: "IT" }, userAgent: ua },
});
console.log("firstTouchStored", id.firstTouchStored); // expect true

const orderId = "probe_ord_" + Date.now();
console.log(await vevee.revenue.order({ distinctId: userId, orderId, amount: 499, currency: "EUR", billingReason: "purchase" }));
console.log(await vevee.revenue.refund({ orderId, refundId: orderId + "_r1", amount: 499 }));

3. Sign up once from a real browser

Run a local build with your test key, open it on a URL like /?utm_source=facebook&utm_campaign=test, and sign up with a fresh account. In the dashboard (switched to test mode) the new person should have first_channel set to paid. While you are there, open the browser's storage panel: there should be no Vevee cookie and no Vevee key in localStorage or sessionStorage. Keys your own app writes for other purposes, such as a dashboard tour counter, are not analytics and never leave the device.

4. Seed believable fake traffic

A dashboard with three events tells you little. Seed enough visits that every chart and split row has something in it: several sources, real browser user agents, a handful of cities, some signups, some activations and some orders in more than one currency. Use real user agents on purpose: Vevee filters bot traffic out, so a made-up user agent can make seeded visits disappear. The study app seeded 90 visits from eight sources across nine cities.

import { randomUUID } from "node:crypto";
import { vevee } from "./_vevee-test";

const SOURCES = [
  { utm_source: "facebook", utm_medium: "paid_social", utm_campaign: "oct-pricing" },
  { utm_source: "newsletter", utm_medium: "email" },
  { referrer_host: "chatgpt.com" },
  { referrer_host: "www.google.com" },
  { referrer_host: "www.reddit.com" },
  {},
];
const CITIES = [["IT", "Rome"], ["IT", "Milan"], ["ES", "Madrid"], ["US", "Austin"]] as const;
const UAS = [
  "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
];
const pick = <T,>(a: readonly T[]): T => a[Math.floor(Math.random() * a.length)]!;

for (let i = 0; i < 90; i++) {
  const visit = { visit_id: randomUUID(), landing_path: "/", ...pick(SOURCES) };
  const [country, city] = pick(CITIES);
  const userAgent = pick(UAS);
  const context = { geo: { country, city }, userAgent };

  await vevee.analytics.capture({ event: "$pageview", properties: { path: "/", ...visit }, context });
  if (Math.random() > 0.3) continue;

  await vevee.analytics.capture({ event: "signup_completed", properties: visit, context });
  const userId = "seed_" + i;
  const { visit_id: _drop, ...firstTouch } = visit;
  await vevee.analytics.identify(userId, {}, undefined, {
    isSignup: true,
    firstTouch: { started_at: new Date().toISOString(), ...firstTouch },
    context: { geo: { country }, userAgent },
  });
  await vevee.analytics.capture({ distinctId: userId, event: "signed_up" });

  if (Math.random() < 0.3) {
    await vevee.revenue.order({
      distinctId: userId,
      orderId: "seed_ord_" + i,
      amount: 999,
      currency: Math.random() < 0.5 ? "EUR" : "USD",
      billingReason: "subscription_create",
    });
  }
}

5. Cross-check the visit funnel against Traffic

Open the visit to signup funnel, split by channel, and the Traffic page for the same days. Visit counts per channel should match row by row, because both read the channel from the earliest event of each visit and both count by the app's local day. If they do not match, look at the time zone first, then at events that were sent without visit fields.

Symptoms and fixes

Issues a study app hit while testing, with the cause and the fix for each.

  • Traffic showed 0 visits for today: the app time zone was unset, so data landed on the previous UTC day. Set it under Settings, Time zone.
  • Revenue showed 0 after seeding EUR and USD orders: the day's exchange rate had not loaded yet. Rates load daily, and unconverted orders are counted in a "not converted" note until then.
  • An activation step stayed at 0: the event was saved as its name plus a trailing dot. Removing the dot fixed it.
  • Signups failed on preview deploys: the secret key was missing from the Preview environment. Add it there, or test against a production build in test mode.
  • A visit funnel row looked empty: the funnel used the person filter together with a visit split, which always gives an empty table.

Test data is temporary

Test mode keeps its own people, events and orders, apart from live. Test data older than 30 days is deleted, so re-run the seed script when you need fresh charts. Test calls still count toward your workspace plan, the same as live ones, so keep seed runs modest.

Example run (mock data)

One example seed run for a study app (made-up numbers): 90 visits from eight sources, 27 signups, 9 orders in EUR and USD. With the funnel split by channel, each of the eight rows matched the Traffic page for the same day, and every seeded person had a first_channel matching their source.

Related guides and docs

Set up the integration first with "Add cookieless traffic attribution to a Next.js app", then build the funnels with "Build the two attribution funnels from JSON". Reference: /docs/guides/traffic-attribution, /docs/methods/capture, /docs/methods/identify and /docs/methods/revenue.

Frequently asked questions

Why refuse anything but an sk_test_ key?

Because a seed script writes fake visits, signups and orders. Pointed at a live key, it pollutes the numbers you will make decisions with. Checking the prefix costs one line.

Do I need a separate app for testing?

No. Test mode shares the app, plans and funnels with live and keeps its own data. Switch the dashboard to test mode to see it.

Why do seeded visits need real user agents?

Vevee filters bots out before counting. A user agent that does not look like a real browser can be filtered, and the seeded visit never shows up.

Can I seed traffic for past days?

capture accepts a timestamp and revenue.order accepts createdAt. Keep in mind that a visit only converts within the same app day, so give all events of one seeded visit the same day.

Other guides

8 min · Guide

How to track OpenAI API usage by user (with quotas, in real time)

Step-by-step guide to tracking OpenAI API usage per end-user with real-time quotas. Pure-TypeScript pattern using @vevee/sdk. Concurrency-safe, provider-agnostic, ten-minute integration.

7 min · Guide

How to implement per-user rate limits in your AI app

Per-user rate limits for AI apps need atomic enforcement, plan awareness, and refundable reservations. Here is the pattern that works under load - using @vevee/sdk and ~30 lines of code.

9 min · Guide

How to implement usage-based pricing for an AI product

Decide between per-token, per-action, and hybrid pricing. Implement quotas, refunds, and Stripe meter sync. Step-by-step with code, from a developer who has shipped all three models.

6 min · Guide

The reserve / commit / release pattern: atomic AI quota enforcement

Why naive AI usage metering breaks under concurrency, and the only correct pattern that fixes it. Reserve / commit / release explained, with full TypeScript example and the failure modes it prevents.

8 min · Guide

How to charge AI app users by token usage (with refunds and live balance)

Step-by-step: charge users for actual token consumption with pre-paid credits, post-paid invoicing, and a live balance display. Atomic reservations, accurate refunds, and Stripe meter sync.

10 min · Guide

Build a freemium image generator: end-to-end tutorial

Ship a freemium AI image generator with hard caps, upgrade prompts, real-time analytics, and a live "renders left" badge - using @vevee/sdk + your image provider of choice. Full code, ten-minute build.

12 min · Guide

Add cookieless traffic attribution to a Next.js app

Six copy-paste steps to attribute every signup and every order to the visit that brought it, with @vevee/sdk 1.14. The visit lives in browser memory, a server route forwards events with the secret key, and nothing is written to the device.

8 min · Guide

Send revenue from your payment webhook to Vevee

Record paid orders and refunds with vevee.revenue.order and vevee.revenue.refund so every channel and campaign gets a revenue column. Covers amounts in minor units net of tax, idempotency on the provider id, refunds that arrive early, and a webhook that never fails because of analytics.

7 min · Guide

Build the two attribution funnels from JSON

Paste one JSON file into Vevee's funnel import to get a visit to signup funnel and a signup to paid funnel, both split by channel. Covers the split forms, step filters, units, and how to read the split table.