This site runs on PostHog, so this walkthrough uses it — but the shape is identical in any product-analytics tool. The pattern is always: capture the UTMs on landing, persist them for the session, attach them to the conversion, then report on the conversion rather than the visit.
Step 1 — Let the tool capture the UTMs
PostHog reads UTM parameters off the landing URL automatically and stores them as properties on the pageview. You do not need to parse anything yourself. What you do need is for the parameters to actually be there — which is what tagging every link is for.
import posthog from "posthog-js"; posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, { api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, defaults: "2026-05-30", // Profiles for anonymous visitors too, so a first-touch text click // still connects to a conversion that happens on a later visit. person_profiles: "always",});Step 2 — Persist first touch
The problem with messaging is the gap. Someone taps your link on Tuesday, looks around, and books on Thursday from a search. Without persistence, Thursday gets the credit and your text campaign looks like it did nothing.
const KEY = "first_touch"; export function captureFirstTouch() { if (typeof window === "undefined") return; try { if (localStorage.getItem(KEY)) return; // first touch wins const params = new URLSearchParams(window.location.search); const campaign = params.get("utm_campaign"); if (!campaign) return; localStorage.setItem(KEY, JSON.stringify({ campaign, source: params.get("utm_source"), medium: params.get("utm_medium"), content: params.get("utm_content"), at: new Date().toISOString(), })); } catch { // Private browsing throws. Attribution is nice to have, not load-bearing. }}Decide first touch or last touch, and write it down
Both are defensible. What is not defensible is different reports using different rules, which is what happens when nobody decides. For messaging, first touch is usually more honest — the text is what started it.
Step 3 — Fire an event on the thing that pays you
Not the landing-page view. The booking, the payment, the confirmed appointment. Attach the campaign and, critically, a value — a conversion without a number cannot be turned into a revenue report.
import posthog from "posthog-js";import { firstTouch } from "@/lib/attribution"; export function trackBooking(booking: Booking) { const touch = firstTouch(); posthog.capture("booking_completed", { value: booking.totalCents / 100, service: booking.service, utm_campaign: touch?.campaign ?? "none", utm_content: touch?.content ?? null, days_since_touch: touch ? daysSince(touch.at) : null, });}Step 4 — Tag the send side too
Analytics only sees people who tapped. To compute a conversion rate you need the denominator — how many you sent. Emit a server-side event when a campaign goes out, with the same campaign name, and the two halves join up.
// Same utm_campaign value on both sides is what makes this joinable.await posthog.capture({ distinctId: customer.id, event: "message_sent", properties: { utm_campaign: "appointment_reminder", channel: result.deliveredAs, // "imessage" | "sms" | "rcs" template: "reminder24h", },});Step 5 — Build the one report that matters
A funnel from message_sent to booking_completed, broken down by utm_campaign, with the value property summed. That single view answers what each campaign cost and what it returned, which is the only question worth asking. Then add a holdout so you are measuring your effect rather than your customers' existing habits.
Break down by delivered channel too
Because you recorded whether each message went out as iMessage, RCS or SMS, you can compare conversion rates across bubble colours on identical copy. That is a genuinely interesting number, and almost nobody measures it.
Next step
Generate a tagged link for whatever you send next with the UTM builder, see what this looks like in your industry, or compare the services that can send it on the providers page.