BlogAnalytics

We 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.

The week after the ad

This is the story of a study app for students that runs on Vevee, and of its first paid social ad. The ad ran for a week with a small budget. Every link carried utm_source, utm_medium and utm_campaign, set up carefully the way every marketing guide says to. When the week ended, the founder opened the tools to ask the only question that mattered: did anyone who clicked that ad end up paying? Nobody could answer it. The tags had been on every link, and every tag had evaporated somewhere between the landing page and the checkout. (All numbers in this post are mock data, shaped like the real ones.)

Four tools, four partial answers

The payment provider had the orders: amount, product, date, customer email. No source, no campaign, no landing page. As far as checkout was concerned, every buyer appeared out of nowhere. The product analytics tool knew the user well: the page they signed up on, the features they used, their answer to an onboarding question ("How did you hear about us?"). It had never been told about UTM tags, because nothing in the signup flow passed them along. By the time a user existed, the URL that brought them was three pages behind. The hosting provider saw every request, tags included. Its UTM report sat behind a paid add-on, and even with it, it reported visits. A visit on Tuesday and an order on Friday were two unrelated facts in two unrelated systems. The onboarding answer was the only thing that looked like attribution, and it was self-reported. In the mock week, the ad brought about 60 visitors to the site, and 1 person in 40 who finished onboarding picked "an ad" as their answer. People pick the first plausible option, or "a friend", or skip it. It is a fine question for flavor and a poor one for spending money.

The obvious fix, and why it was out

Ad platforms have an answer for this: install their pixel, or install a cookie-based analytics tool, and they will stitch the click to the purchase for you. Both write an identifier to the visitor's device. Under the EU rules on device storage, that needs consent first, which means a consent banner on the landing page. For a study app the landing page is the product's first impression, and most visitors are students on a phone between classes. A banner that covers half the screen before they have seen anything costs signups. The founder had watched that happen on a previous project. Paying for an ad and then putting a wall in front of the people it sends felt backwards. So the goal got written down in one line: attribute every signup and every order to the visit that started it, store nothing on the device, and keep the site free of banners.

What the app built

The fix had four parts, and each one gets its own post on this blog. The browser keeps the visit (UTM tags, referrer host, landing path, a random visit id) in a JavaScript variable, so it dies with the tab and nothing touches cookies or storage. The browser never talks to Vevee directly: it posts to the app's own route, which adds coarse location and device from the host's request headers and calls Vevee with the secret key. At signup, the server copies the visit's source onto the new person, once, as their first touch. And from the payment webhook, each order goes into the person's record, so revenue lands on the channel and campaign that first brought them. The visit and the person stay separate records. The only thing that crosses from one to the other is the source. That separation is what lets the site run without a banner, and it is also why the dashboard ends up with two funnels instead of one.

The missing column

The last piece was the one the founder had wanted from the start. The payment webhook now sends each paid order to Vevee, keyed on the provider's order id so a redelivered webhook is counted once. The app wraps the call so it never throws: an analytics outage should never make the provider retry a webhook that also grants the subscription.

// app/api/webhooks/payments/route.ts (inside the order-paid handler)
await vevee.revenue.order({
  distinctId,                      // the id the app passes to identify()
  orderId: order.id,               // idempotency key
  amount: order.netAmount,         // minor units, after discounts, before tax
  currency: order.currency,
  billingReason: 'subscription_create',
  product: order.product?.name,
  createdAt: order.createdAt,
});

// later, only once a refund has actually succeeded
await vevee.revenue.refund({ orderId, refundId, amount, createdAt });

What the next ad looked like

The next campaign ran with the same tags. This time the Traffic page showed its visits under the paid channel and its campaign name, and the person half of the funnel showed how many of those signups paid. In the mock month that followed, paid ads brought the most visits of any channel: 25 people signed up from them and 4 paid. That is a small number, and it was the first time the app had a number at all. The campaign could now be judged on orders per euro spent, which is the only judgment an ad budget deserves. The surprise was elsewhere in the same table. A channel the founder had never paid for converted visits to signups at almost three times the rate of the ad. That one gets its own post too. If you want to wire up the same thing, the traffic attribution guide in the docs (/docs/guides/traffic-attribution) walks through it for a Next.js app, and the revenue method reference is at /docs/methods/revenue.

See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist