Skip to content
imessageapi

Attribution

Your best channel is reporting as “direct traffic”.

A link tapped inside Messages generally passes no referrer. Without UTM parameters, every booking that started with a text gets filed under nothing at all. Five query parameters fix it permanently — build one below.

The builder

Generate a tagged link

Everything is normalised to lowercase with underscores, because most analytics tools treat Spring_Special and spring_special as two different campaigns and will happily split your numbers in half without telling you.

Presets

Where the customer lands after tapping.

The channel, not the vendor.

Keep sms so fallback aggregates.

Required. One name per push.

Optional. Which wording.

Optional. Which audience.

Tagged URL

https://acme.co/book?utm_source=imessage&utm_medium=sms&utm_campaign=spring_service_special

91 characters · valid

How it lands

Hi Sam — you're booked for Thursday at 2pm. Details: https://acme.co/book?utm_source=imessage&utm_medium=sms&utm_campaign=spring_service_special

144 chars

Same thing, in code

tagged("https://acme.co/book", {
  campaign: "spring_service_special",
})

Reference

What each parameter is for

Three of these are effectively mandatory for messaging. Two are how you learn something beyond “it worked”.

UTM parameter reference for messaging campaigns
ParameterExampleHow to use it
utm_sourceWhere the traffic came from.imessageUse the channel, not the vendor. You may switch providers next year; the channel stays the same and your historical data stays comparable.
utm_mediumThe kind of channel.smsKeep sms for anything landing in the Messages app so iMessage and SMS fallback aggregate into one honest number instead of splitting your reporting.
utm_campaignThe specific push.spring_service_specialRequired. One name per campaign, lowercase, underscores. The convention that holds up is {trigger}_{intent}.
utm_contentWhich variation of the message.variant_aOptional. This is how you A/B two wordings without inventing two campaigns.
utm_termWhich audience segment.lapsed_90dOptional. Originally a paid-search field; in messaging it is most useful for the segment you targeted.

Tag before you send, not after

UTM parameters are recorded at the moment of the click. There is no way to retroactively attribute a campaign you sent untagged — that data is simply gone. Get this in place before your first real send.

In your codebase

One helper, used everywhere

Hand-typed UTMs drift within about two weeks — someone capitalises a word, someone uses a hyphen instead of an underscore, and your report quietly splits in half. A single function is the entire discipline.

lib/tag.ts
type Tags = {
campaign: string;
content?: string;
term?: string;
source?: string;
medium?: string;
};
 
export function tagged(url: string, tags: Tags) {
const u = new URL(url);
const norm = (v: string) => v.trim().toLowerCase().replace(/\s+/g, "_");
 
u.searchParams.set("utm_source", norm(tags.source ?? "imessage"));
u.searchParams.set("utm_medium", norm(tags.medium ?? "sms"));
u.searchParams.set("utm_campaign", norm(tags.campaign));
if (tags.content) u.searchParams.set("utm_content", norm(tags.content));
if (tags.term) u.searchParams.set("utm_term", norm(tags.term));
 
return u.toString();
}

The length problem

A tagged URL is long. A long URL in a text looks like a scam.

Both things are true, and the fix is the same one: a short branded link on your own domain that redirects to the tagged destination.

Best

Your own short domain

go.acme.co/spring redirects server-side to the fully tagged URL. Customers see a link they recognise, analytics sees every parameter, and you can change the destination after the message has gone out.

Acceptable

Shortener with a custom domain

A paid shortener on your own domain gets you most of the way there. You are trusting a third party with a link that represents your brand, so pick carefully.

Avoid

Public shortener

Shared shortener domains carry the reputation of everyone else using them, which includes every spammer. Carrier filters treat them accordingly and your message may never arrive.

app/go/[slug]/route.ts — short in the bubble, tagged on arrival
import { redirect } from "next/navigation";
import { tagged } from "@/lib/tag";
 
const LINKS: Record<string, { url: string; campaign: string }> = {
spring: { url: "https://acme.co/offers/spring", campaign: "spring_service_special" },
book: { url: "https://acme.co/book", campaign: "booking_confirm" },
review: { url: "https://g.page/r/acme/review", campaign: "review_request" },
};
 
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const link = LINKS[slug];
if (!link) redirect("/");
 
redirect(tagged(link.url, { campaign: link.campaign }));
}

The point of all this

Report in dollars, not click-through rates.

Tagging is only the plumbing. The output you want is “this campaign cost $94 and returned $1,521”, which is the only version of the sentence anyone makes a decision from.