Elido
13 min readtutorials
Cornerstone

How to track UTM campaigns end-to-end without a CDP

A practical playbook for marketers: workspace UTM templates, bulk import from Sheets, server-side conversion forwarding, and the QA dry-run that catches drift before launch

Ana Kowalska
Marketing solutions engineering
Five-step UTM pipeline: workspace template, campaign override, bulk import, server-side forward, GA4 DebugView verification

I've plumbed UTM tracking end-to-end at three companies. Each time, the same five things broke in the same order, and each time the fix was the same: pull the templating up to the campaign level, push the conversion forwarding down to the server, and put a dry run between them. That's most of what this post is. The rest is the QA checklist that catches the things you didn't think to break.

You don't need a Customer Data Platform for this. You will need one eventually if your attribution problem becomes "stitch four anonymous touches across three devices into one customer journey", but for the case I see most often — "tag every outbound link consistently, capture the click, forward the conversion to Meta and GA4 server-side, and survive Safari" — a URL shortener with templates plus a conversions API does the job. Below is the version that works, with the failure modes I've seen call out.

What goes wrong with UTM tracking#

The marketers I work with are not bad at UTMs. The problem is that the tools default to making it easy to type a UTM in once, hard to enforce one across an org, and impossible to fix after launch. Four failure modes show up over and over.

Drift. One person types utm_source=newsletter, another types utm_source=Newsletter, a third types utm_source=email. Six months later your "newsletter" channel is split across nine string variants in GA4. Cleaning it up after the fact is a regex-and-pray exercise. The original urchinTracker() script that introduced this convention — Google's pre-Analytics Urchin web analytics product, open-sourced briefly in 2003 before being absorbed — had no template layer either. The convention was always "type it consistently"; the tooling never enforced it.

Manual tagging at scale. A flyer campaign with 80 short links across four regional storefronts is 320 URLs you have to type out, paste into a spreadsheet, copy into your shortener, and pray. Half of them get the wrong utm_content. Nobody notices until the campaign is two weeks in.

Server-side conversion gaps. The pixel fires on the thank-you page, GA4 picks it up, Meta picks it up, and you go home. Then Safari ships another ITP version, ad-blocker installs go up, and your reported conversions drop by a third. Apple's ITP 2.3 release notes walk through exactly the mechanism: link decoration is throttled, document.referrer is stripped, and any analytics flow that depends on the browser executing third-party JS quietly degrades. The conversions are still happening on your server. They're just not making it to the ad surfaces.

No dry-run. The first conversion that flows through the new pipeline is the first real shopper. If something is misconfigured, you find out three days later when the optimisation algorithm has already pulled budget from a campaign that was actually working.

This post addresses the first three with templates + bulk import + server-side forwarding, and the fourth with a verification step that's easy to skip and expensive to skip.

Workspace and campaign UTM templates#

Templates push the consistency problem up the stack. You define your tagging convention once at the workspace level, layer per-campaign overrides where they're warranted, and let every link inherit. There's no place left for a typo to live.

Define the workspace defaults first. Literal values fix the variables that never change for your org (utm_medium = email for newsletter campaigns); placeholders fill from the link payload at create time:

curl -X PUT \
  https://api.elido.app/v1/workspaces/1/utm-template \
  -H "Authorization: Bearer $ELIDO_TOKEN" \
  -d '{
    "utm_source":   "{{ channel }}",
    "utm_medium":   "{{ medium }}",
    "utm_campaign": "{{ campaign }}",
    "utm_content":  "{{ creative }}",
    "utm_term":     "{{ audience.segment }}"
  }'

A few details that matter here:

  • The placeholders are filled in at link creation, not at click time. What lands in your analytics tool is what was intended at the moment the link was minted — not whatever the link's destination computed at click. This makes audit-log reconstruction much easier when something looks wrong six months later.
  • Unknown placeholders fail fast. If your bulk import is missing a creative column and the workspace template references {{ creative }}, the API returns a 422 with the unresolved variable name. No silent partial application.
  • The full template reference, including link.tag.<name> placeholders that read from the link's tag array (useful for multi-tenant agencies that need to embed a client identifier in every URL), is in the docs guide.

Then layer a campaign template. Campaigns inherit from the workspace and replace the subset that's campaign-specific:

curl -X POST \
  https://api.elido.app/v1/campaigns \
  -H "Authorization: Bearer $ELIDO_TOKEN" \
  -d '{
    "name": "Spring 2026 — DACH",
    "utm_template": {
      "utm_campaign": "spring_2026_dach",
      "utm_term":     "{{ audience.locale }}"
    }
  }'

Anything not set on the campaign falls through to the workspace defaults. The two-level layering covers most real org structures: shared conventions at the workspace level, team-specific or season-specific overrides at the campaign level. If you find yourself wanting a third level of inheritance, that's a smell — usually it means two campaigns should be one campaign with smarter placeholder values.

Campaigns page in the Elido dashboard, four campaigns with their UTM defaults filled in: Spring 2026 launch (newsletter / email), Newsletter weekly, Influencer DACH Q2 (creator / partner), Paid social Meta retargeting

The thing per-link overrides give up: an override fires regardless of the template. The thing they keep: the override is recorded in the audit log with actor + timestamp + the resolved-vs-final diff. Six months from now, when someone asks why one link in a 200-link campaign has utm_term=manual_override, you can answer.

Bulk import from Sheets — the workflow most marketers actually use#

Marketers don't sit in curl all day. The campaign brief lands as a spreadsheet with destination URLs and campaign metadata, the launch deadline is Friday, and the question is how that spreadsheet becomes 200 short links without anyone typing the same UTM string 200 times.

The CSV column names match the placeholder names from your template (case-insensitive). Columns Elido doesn't recognise are dropped with a warning rather than silently copied — this is deliberate. Silent copy is how you end up with utm_brand_color showing up in GA4 because someone added a column for an internal note.

destination_url,channel,medium,creative
https://shop.example.com/de,newsletter,email,hero_a
https://shop.example.com/fr,newsletter,email,hero_a
https://shop.example.com/de,paid_social,meta,carousel_v2
https://shop.example.com/fr,paid_social,meta,carousel_v2

POST it as multipart:

curl -X POST \
  https://api.elido.app/v1/links/bulk \
  -H "Authorization: Bearer $ELIDO_TOKEN" \
  -F "csv=@launch_q2.csv" \
  -F "campaign_id=cmp_8a2f"

Two things this validation flow buys you that one-link-at-a-time UI doesn't:

  • All-or-nothing commit. A single bad row aborts the whole upload and returns the offending line numbers plus the reason — row 47: unresolved variable {{ creative }} is a much better error than discovering at 4pm on a Friday that 47 of your 200 links resolved to a placeholder string.
  • Pre-launch preview. The dashboard's bulk-import preview row shows the resolved URL, including the rendered utm_* query string, before commit. Look at the second link to make sure your template did what you expected, then look at the last link to make sure rows down the file didn't drift. Two glances, one minute.

If your spreadsheet doesn't have a stable shape — column orders shuffle, headers get renamed — the bulk-import endpoint is going to be unpleasant. The fix is not in our tooling; the fix is to commit to a CSV schema for your campaign briefs and treat schema drift as a process bug. We discuss the broader pattern in the marketers' solutions page.

Server-side conversion forwarding to Meta CAPI and GA4#

Pixel-only attribution drops 20-40% of conversions to Safari ITP, ad-blockers, and consent banners. The number varies by industry — DTC ecommerce sees the high end of the range, B2B SaaS the low end — but every measurement I've seen post-iOS 14 puts pixel reliability well below the 95% mark that ad platforms assume. The optimisation algorithm gets noisier inputs and your CPA looks worse than it is.

Meta's Conversions API documentation is explicit about this: server events are what you want, browser-side pixel is the supplement. GA4's Measurement Protocol makes the same case. Both protocols accept the same shape: a server-side event with the conversion details, an event_id for deduplication, and ideally hashed user identifiers so the platforms can stitch the conversion to a known visitor.

The plumbing that closes the gap is mechanical. Three steps.

Step one — capture the click_id. Every Elido redirect response carries an X-Elido-Click-Id header. The TS / Python / Go SDKs surface it on the redirect response object; raw HTTP works too:

curl -sI https://elido.me/launch | grep -i click-id
# X-Elido-Click-Id: clk_01HYZ7T8WV6KQX3M

Stash it in a first-party cookie on the destination page (elido_click_id, 90-day TTL — long enough to cover a typical SaaS evaluation, short enough to satisfy ePrivacy guidance). Read it back at checkout.

Step two — wire the destinations. PUT credentials for the surfaces you want to forward to. Any subset works; missing surfaces are skipped silently:

curl -X PUT \
  https://api.elido.app/v1/workspaces/1/conversion-forwarding \
  -H "Authorization: Bearer $ELIDO_TOKEN" \
  -d '{
    "meta_capi": {
      "pixel_id": "1234567890",
      "access_token": "EAA…",
      "test_event_code": null
    },
    "ga4_mp": {
      "measurement_id": "G-ABC123",
      "api_secret": "abc_def_ghi"
    },
    "mixpanel": {
      "project_token": "pm_…",
      "service_account": "sa@team.mixpanel.com"
    }
  }'

Step three — POST the conversion. When the order fires, send the event with the click_id and the order details. event_id is your idempotency key:

curl -X POST \
  https://api.elido.app/v1/conversions \
  -H "Authorization: Bearer $ELIDO_TOKEN" \
  -d '{
    "click_id":   "clk_01HYZ7T8WV6KQX3M",
    "event_name": "purchase",
    "event_id":   "ord_98231",
    "value":      89.00,
    "currency":   "EUR",
    "user": {
      "email":  "shopper@example.com",
      "phone":  "+4915123456789",
      "external_id": "cust_5128"
    }
  }'

User identity fields are SHA-256 hashed before forwarding to Meta and GA4 — this is what both platforms require. The UTM context is pulled from the click row that matches click_id, so the forwarded event carries the original campaign attribution even if the user wandered around the site for an hour before checking out. The full mechanics, including refund handling and the multi-touch attribution model toggle, are in the conversion forwarding guide.

Conversion-tracking pixel admin tab in the Elido dashboard, with fields for Meta Pixel ID, Google Ads / GA4 ID, LinkedIn Insight Tag, and TikTok Pixel Code

This is most of the gap closed. There's a residual hole — visitors who block the click_id cookie, or arrive through a non-Elido path — but for the campaigns you actually drive traffic into, you've moved from "60-80% pixel reliability" to "95%+ server reliability".

Three edge cases the audit log will save you on#

Templates and forwarding handle the happy path. The cases below show up in week three of any non-trivial campaign, and the right answer to all of them lives in the audit log + the conversions panel — not in trying to design a more elaborate template.

Refunds. A purchase conversion fired, the customer returned the item a week later, and your reported revenue is now 8% too high. The fix is to POST the same event_id with event_name: "refund". Meta and GA4 treat this as a negative conversion against the original; Mixpanel records it as a separate event you subtract in your funnel. The reason event_id is shaped this way: idempotency at the event id level means you can't double-count the refund either. The full pattern is documented in the conversion forwarding guide's edge-cases section — refunds, partial refunds, and store credit each have a slightly different shape.

Click-id misses. A conversion fires with a click_id that doesn't match any known click — typo, expired beyond retention, wrong workspace. The conversion is still recorded against the workspace but forwarded with empty UTM context. This is intentional: catch-all attribution is more useful than dropping the conversion on the floor, and the click_id_unknown flag in the audit log lets you filter for the unattributed slice when reporting. If the slice is bigger than 5% of conversions, something is wrong with how you persist the click_id on the destination page — usually the cookie's SameSite attribute or the path scope.

Late-arriving conversions. A B2B SaaS sale closes 47 days after the original click. Elido's default click retention is 30 days, so by the time the conversion fires, the click has aged out and you're in the click-id-miss case above. Two fixes, depending on your sales cycle: bump retention to 90 days on the workspace (Pro plan and up), or capture the click_id on a long-lived first-party identifier (your customer record's original_click_id column) so you can stitch it back at conversion time even if the cookie is gone. We've seen both patterns in production.

The audit log shows the resolved-vs-final UTM diff per link, the forwarding response code per destination per conversion, and the click_id-to-conversion join state. When the optimisation algorithm pulls budget from a campaign that looks underperforming, the audit log is what lets you say "no, the campaign is fine; we lost three days of forwarding to a rotated GA4 api_secret". Look at it.

The pre-launch QA — dry-run the whole pipeline#

Don't let the first conversion that flows through this be a real shopper. The cost of a 30-minute dry run is borne entirely by you; the cost of a misconfigured pipeline is borne by the optimisation algorithm pulling budget from your best-performing campaign for two days before you notice. The asymmetry is bad.

Three steps, in order.

Bulk-import dry run. The bulk-import endpoint accepts dry_run=true as a query parameter. It runs the validation, resolves the templates, and returns the would-be created links without committing. Open the response in any JSON viewer; the resolved URL of every row is visible. Spot-check 3-5 rows: the second link, the last link, and any rows that overrode workspace defaults. Verify the utm_* query string is exactly what your campaign brief says it should be.

Conversion forwarding test mode. Meta CAPI accepts a test_event_code parameter, which routes the event into the Test Events tab in Events Manager instead of production. Set it on the workspace forwarding config, send 10-20 sample conversions, and confirm they land. Same idea for GA4: set debug_mode: true on the events and verify in DebugView. Both are real-time. The point isn't to spot-check that the API works; the point is to catch a misconfigured pixel_id or an api_secret that was rotated and never updated.

End-to-end smoke. Click one of your real short links from a clean browser session. Watch the click in the Elido dashboard's recent-clicks panel. Pretend you bought something — POST a purchase conversion with that click_id from your terminal. Confirm the conversion appears in Meta Test Events and GA4 DebugView with the right UTM context attached. The whole loop is under 10 minutes once you've done it once.

After all three pass, drop the test_event_code, set debug_mode: false, and ship. The first real shopper will have a clean pipeline waiting for them.

Conversions panel in the Elido dashboard showing total conversions and revenue, top links by revenue, daily revenue chart over the last 30 days, and revenue by platform breakdown

When you'd actually want a CDP#

Templates plus bulk import plus server-side forwarding gets you most of the way. There's a class of problems where it doesn't, and reaching for a CDP is the right call.

Cross-device identity stitching. A visitor clicks a link on mobile, doesn't convert, comes back on desktop, signs up. You want both touches attributed to the same person. UTM tracking + click_id is touch-level; the user-identity layer that makes the two touches one journey is what a CDP (Segment, mParticle, RudderStack) is built for. Elido stores up to 30 days of clicks per visitor and supports last-touch / first-touch / position-based attribution within that window, but the cross-device join needs an identity graph we deliberately don't operate.

Sub-100ms personalisation. If you're rendering the destination page based on the visitor's prior touches in real time — pulling the cohort from a feature store and varying the hero headline — you need the identity resolution close to the render. That's CDP territory or, more often, an experimentation platform like PostHog or LaunchDarkly layered on top.

Multi-touch attribution at scale. Last-touch is fine for most campaigns. If your sales cycle has six touches over four months and you genuinely need to credit each, you're in the territory where Markov-chain or Shapley-value attribution starts to matter. Elido does last-touch / first-touch / position-based; anything more sophisticated wants a tool with a proper identity graph and a model layer.

For everything else — and "everything else" is most marketing teams I've worked with — the templates + bulk import + server-side forwarding pattern is enough. Set up the workspace template once, the campaign template per launch, the forwarding config once per platform integration, and run the dry run before every launch. If you do all four, you'll have a tighter UTM pipeline than 80% of the marketing teams I've audited.

Build it once, dry-run it before each launch, and move on to the next campaign.

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier — no credit card.

Tags
utm tracking
utm template
utm builder
utm attribution
conversion forwarding
ga4
meta capi

Continue reading

How to track UTM campaigns end-to-end without a CDP · Elido