VeveeGuide · 7 min read
Guide · 7 min read

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.

Last updated: 2026-10-28

Once attribution is live you have two kinds of data: anonymous visits and identified people. Vevee keeps them apart on purpose, so the question "which channel brings paying customers" becomes two funnels. One counts visits up to signup. The other counts people from signup to payment, grouped by the channel of their first touch.

Building them by hand in the funnel builder works, but a JSON file is easier to review, keep in your repo and re-import into another app. This guide shows the file a study app uses and explains each part.

Step-by-step

1. Check the events you need

The visit funnel needs anonymous events that carry the visit fields: the $pageview events from @vevee/sdk/browser, any anonymous step you track in between, and an anonymous signup_completed sent from your server at signup. The people funnel needs identified events: your signup event and your payment event, for people who have a stored first touch. If you followed the Next.js attribution guide, you already send all of these.

2. Write the funnel file

The import accepts one funnel object or an array. Each funnel needs a unique name. window is a number of hours or a string like "24h" or "14d" (1 to 720 hours). range is last_24_hours, last_7_days, last_30_days, last_90_days or { from, to }. A step is an event name, or an object with event, where, optional, label and breakdown.

[
  {
    "name": "Visit to signup",
    "window": "24h",
    "range": "last_30_days",
    "split": { "source": "visit", "field": "channel" },
    "steps": ["$pageview", "login_page_viewed", "signup_started", "signup_completed"]
  },
  {
    "name": "Pricing to signup",
    "window": "24h",
    "split": "channel",
    "steps": [
      { "event": "$pageview", "where": { "path": "/pricing" } },
      "login_page_viewed",
      "signup_started",
      "signup_completed"
    ]
  },
  {
    "name": "Signup to paid by channel",
    "window": "14d",
    "split": { "source": "person", "field": "first_channel" },
    "steps": ["signed_up", "generate_succeeded", "checkout_completed"]
  }
]

3. Pick the right split for each half

split takes three forms. A string is shorthand for a visit split, so "channel" means { "source": "visit", "field": "channel" }. Visit splits accept twelve fields: channel, utm_source, utm_medium, utm_campaign, utm_term, utm_content, referrer_host, landing_path, country, device_type, browser and os. Person splits take any person property, and the first_* first touch properties are the useful ones here (first_channel, first_utm_campaign, first_referrer_host, first_landing_path and so on). Event splits read a property of the step 1 event. An invalid split is an error, never silently dropped.

{ "split": "channel" }
{ "split": { "source": "visit", "field": "utm_campaign" } }
{ "split": { "source": "person", "field": "first_channel" } }
{ "split": { "source": "event", "field": "plan" } }

4. Filter a step with where

where narrows a step to events with matching properties. A plain value means equals; an object picks another operator (eq, neq, contains, gt, lt). The "Pricing to signup" funnel above only starts at pageviews of /pricing, because the browser helper sends the page path as the path property.

{ "event": "$pageview", "where": { "path": "/pricing" } }
{ "event": "checkout_completed", "where": { "tier": { "neq": "trial" } } }

5. Import it

Open the funnel builder, choose "Import JSON" and paste the file. Every entry is parsed before anything is saved. If one fails, nothing is imported and the error names the entry and the path, for example [1] ("Pricing to signup").steps[0].where. Unknown keys are errors in this shorthand shape, which catches typos early. With an array, the dialog shows "Imported N funnels" and takes you to the list.

Why two funnels and not one

A funnel is either about visits or about people. Vevee never links an anonymous visit to a person; only the visit's source is copied into the person's first touch. A single funnel from visit to payment would break that rule, and it would also mix units (visits at step 1, people at step 2), so its step rate would mean nothing. The split table links the two halves by name only: the "Open in Traffic" link navigates to the Traffic page with the same range and filter, and joins nothing.

What each split counts

The table header names the unit, and the funnel totals with a split on are the sum of the table rows in that unit.

  • Visits, for a visit split. Only visit scoped anonymous events enter. The channel and other fields come from the earliest event of each visit that carries visit fields, the same rule the Traffic page uses, so the numbers match Traffic for the same days.
  • People, for a person split. Only events sent with a distinct id enter. People are grouped by the current value of the property; first_* properties never change, so for them this makes no difference.
  • Entries, for an event property split. It counts the same keys as the unsplit funnel.

Reading the split table

Each row shows the step 1 count, then a count and rate per later step (such as "12 · 41%"), then the overall conversion. A few rules shape what you see.

  • The top 10 values by step 1 count get a row; the rest fold into "Other". "Show all" expands it.
  • Missing values get their own row and are never folded into Other: "(none)" on visit and event splits, "(no first touch)" on first_* splits.
  • The "(no first touch)" row starts large: it holds everyone who signed up before you shipped first touch capture.
  • Rows with fewer than 20 entries at step 1 are greyed out but keep their numbers.
  • A visit only converts within the same app day, because the visit hash rotates at local midnight.
  • Visit scoped $pageview rows are kept for 90 days, so a visit funnel that starts at $pageview has no data beyond that.

Example reading (mock data)

In an example month for a study app (made-up numbers), the visit funnel split by channel showed paid ads bringing the most visits while AI search converted visits to signups at almost three times the rate. The people funnel split by first_channel showed 7 paid out of 24 signups for ai and 4 out of 25 for paid. Small rows like these are exactly the ones the table greys out below 20 entries, so read them as an early hint until the rows grow.

Sharing a view

A funnel view is shareable by URL. split=visit:channel or split=person:first_channel overrides the saved split without saving it, split=none removes it, row selects a row ($none is the missing row, $other the Other row), and sort sets the column, for example sort=r2:desc.

Gotchas from a real import

Problems a study app hit while building these funnels:

  • An activation step stayed at 0 because the event name had been saved with a trailing dot. Check names character by character.
  • description is accepted at any length and validated, but it is not stored on the funnel yet.
  • breakdown is accepted on individual steps (and stored as that step's breakdown property), separate from split.
  • A visit split combined with a person filter gives an empty table, because anonymous visits have no person properties.

Frequently asked questions

Can a single funnel go from first visit to payment?

No. Visits and people are kept apart so the site can run without a cookie banner. Use the visit funnel for acquisition and the people funnel, split by first_channel, for conversion to paid.

Why does my visit funnel have no signup step?

identify never carries the visit id. Send a separate anonymous signup_completed from your server with the visit fields and no distinct id. Without it, use the signup count by channel on the Traffic page instead.

Does "split": "channel" work for person splits?

No. The string shorthand always means a visit split. Person and event splits need the object form with source and field.

What if a name already exists?

Names must be unique within the file (case insensitive) and per app. A name that already exists as a saved funnel fails at save time; earlier entries in the array stay saved and the message says how many.

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.

9 min · Guide

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.