utm.new
story ·

How we rebuilt our UTM conventions after a messy Q3

What we were doing wrong, what broke, and the two-hour meeting that fixed it.

In Q3 2025, our paid-social numbers started lying to us.

Not dramatically. Not in a way any dashboard would flag. Just a slow drift — our Meta reports inside GA4 were about 9% lower than what Meta’s own ads manager said. Close enough that we assumed it was the usual cookie-loss gap. Far enough that, three months in, the gap had grown to 14% and the CFO wanted to know why.

What was actually happening

When we finally dug in, it wasn’t one bug. It was four tiny ones stacked on each other:

  1. Two people on the team used different utm_source values. One used meta, one used facebook. Our GA4 reports separated them. Our reconciliation logic joined by utm_source, so half the Meta spend had nothing to match to.

  2. utm_id was blank on every campaign. Nobody had set it up. GA4 was falling back to name-based joins, which work until a campaign gets renamed mid-flight — which happened twice.

  3. One campaign had utm_campaign=Spring Sale with a capital S and a space, URL-encoded as Spring%20Sale. GA4 treated that as a different campaign from spring-sale. The URL came from a partner link we’d handed off without auditing.

  4. A QR code on a trade show flyer had utm_medium=offline but the campaign was classified as “social” in our internal naming. When we cross-referenced, that spend showed up in the wrong bucket.

None of these were catastrophic on their own. Together, they were why our numbers disagreed with Meta’s.

The two-hour fix

We booked a room. We wrote the convention on a whiteboard:

  • Everything lowercase.
  • Hyphens, not spaces or underscores.
  • utm_id always set, always a platform macro for paid.
  • utm_source from a fixed list, documented in Notion.
  • One person owns the UTM spreadsheet for a given campaign.

Then we wrote a checklist — literally three lines — and taped it above the desk of anyone who launches a campaign.

Then we cleaned up the live links. For anything running right now, we added utm_id={campaignid} as a Final URL suffix in Google Ads (retroactive, no URL rebuilds). For Meta, same deal with the platform’s URL parameter setting. For email, we had to rebuild, but it was only two live broadcasts.

Two weeks later, the gap was under 4%.

What we’d do differently

Three things, looking back.

We should have picked a builder and enforced it. We had two builders in play — one a Google Sheet with formulas, one someone’s personal browser bookmarklet. That’s where the inconsistency crept in. One tool, one template, everyone uses it.

We should have written down what each utm_source value is. A one-line definition. “meta = Facebook + Instagram paid ads only.” If we’d had that, the facebook vs meta split wouldn’t have happened.

We should have audited monthly, not quarterly. A 4% drift is a rounding error. A 14% drift is a crisis. Catching it early means nothing to fix.


This is one of the reasons we built utm.new — so there’s one place, one template, one shape. The less variation, the less chance of a messy Q3 ever again.

Have a story like this one? Tell us about it.