How to Pass UTM Parameters Through Deep Links Into Your App
How to pass UTM parameters through deep links: where the query string survives, where it dies, how to read them on iOS and Android, and how to report them.
TL;DR: UTM parameters are query fields on a URL, and a deep link is still a URL, so they travel exactly as far as the URL does. When the app is installed the whole URL reaches it: iOS delivers the tapped Universal Link as an
NSUserActivitywhosewebpageURLkeeps the query string, and Android delivers the App Link as the intent data URI, soURLComponentsandUri.getQueryParameterread the values directly. When the app is missing, the tap goes to a redirect that records the values and then to a store, and the store round trip drops the query string on both platforms. Android keeps one field, thereferrervalue on a Play Store URL, and a link service claims that field to identify the link while merging in any other keys you set. So campaign values that must reach a brand new user after an install belong on the link's own per-platform deep link URL, which the deferred match returns asdeepLinkUrl, not on the query string of the shared URL. On the server side a link can either append its own values to the destination or forward the ones already on the incoming URL, and that choice decides where they turn up: the click record keeps whatever was on the URL that was tapped, while appended values tag the destination for the analytics running there. Keep every key and value lowercase, because a query string is case sensitive and one capital letter splits a campaign into two rows.
UTM Parameters in Deep Links: What Survives Each Hop
A UTM parameter is nothing more than a labelled query field: utm_source, utm_medium, utm_campaign, utm_term, and utm_content, appended to a URL so that whatever receives it can say where the visit came from. On the web that receiver is a page, and the convention works because the page gets the whole URL.
Mobile breaks that assumption in a specific place. The URL is handed off between four things on the way to a screen in your app, and each hop has its own rules about what it carries:
| Hop | What it is | Does the query string survive |
|---|---|---|
| The share surface | The message, post, email, or printed code holding the URL | Yes, whatever was written into it |
| The redirect | Your link resolving on a server you control | Yes, and this is where it can be recorded or rewritten |
| The system handoff | iOS or Android opening your app for a Universal Link or App Link | Yes, the app receives the exact URL |
| The store round trip | A visitor without the app installing from a store listing | No, apart from one Android field |
Three of those four carry the values. The fourth is where every "our campaign data says direct" investigation ends up, and which one a user meets depends only on whether they already had the app. With the app installed, the system intercepts the tap and launches your app with the URL, so your code holds the query string from the first frame. Without it, the tap reaches your redirect and the URL ends at a store listing.
The rest of this guide covers both: where the values die, the two ways a link can carry them, how to read them on iOS and Android, what to do about the install case, how to name them so a report groups the way you expect, and the three mistakes that account for most of the damage.
Where UTM Parameters Die
Three failure points, in rough order of how much traffic they cost.
The store round trip. This is the big one, and it is structural rather than fixable. On iOS, a visitor who taps https://links.example.com/spring?utm_source=newsletter without the app goes to an App Store listing, installs, and opens an app that has never seen that URL. There is no facility for a store to hand a URL to an app it just installed, and Apple's own campaign fields on a store URL report inside Apple's analytics rather than inside your app.
Android is one field better. The Play Store honours a referrer value on a play.google.com listing URL, stores it against the install, and returns it to the app afterwards through Google's Play Install Referrer library. That is a real handoff, and it is why Android install attribution can be exact. It is also partly spoken for. A link service claims the field to identify the link, which for WarpLink means utm_source=warplink&utm_content={link_id}. A referrer you set yourself on a Play Store URL is merged rather than refused: WarpLink's pair goes first and yours is appended, so your own keys survive for your own tooling to read while utm_source and utm_content are the ones a first-occurrence parser sees. Treat it as the identity of the link rather than as a campaign channel, and let your campaign values hang off the link.
Redirect chains. A query string does not follow a redirect by itself. Each hop is a fresh URL, and it carries a query string only because the server that issued the redirect wrote one into the Location header. Most do not, so a chain of a marketing tracker, then a shortener, then your link preserves the values only if every hop was built to, and one hop that was not loses everything. Keep the chain short and put the hop you control first, so the values are recorded before anyone else can drop them.
In-app browsers. Nothing is lost here in the URL sense: a tap inside a chat app, a feed, or a webmail client keeps the query string exactly as written. What is lost is the handoff. An embedded webview is not the system browser, and a Universal Link or App Link generally does not fire from inside one, so the values land on your web page and the app is never opened, however correct your app-side parsing is. The fixes are in Deep Linking in In-App Browsers. Email click tracking is the same problem one step earlier, since it rewrites your URL onto the sending provider's tracking domain: Email Deep Linking covers the ways out.
Step 1: Choose Between Appending and Forwarding
There are exactly two places a campaign value can come from, and picking the wrong one is what makes UTM tagging feel like manual labour.
Appending means the link carries the values as configuration and writes them onto the destination when it redirects. The URL you share is the bare short link, and every visit through it is tagged identically.
Forwarding means the values arrive on the incoming URL, because whoever shared it put them there, and the link passes them through to the destination. One link then serves many campaigns, at the cost of depending on every sharer to tag correctly.
Most links want appending. Forwarding earns its place for a link that is genuinely reused, such as one install link a partner programme hands out with a per-partner utm_source.
Both are configuration rather than code. In the WarpLink API that configuration is the parameters object on a link, and it holds both halves at once:
{
"parameters": {
"injected": [
{ "key": "utm_source", "value": "newsletter", "mode": "default" },
{ "key": "utm_medium", "value": "email", "mode": "always" }
],
"forwarding": {
"enabled": true,
"allowlist": ["utm_source", "utm_campaign"]
},
"defaultMode": "default",
"inheritOrgInjected": true,
"suppressedOrgKeys": ["utm_campaign"]
}
}
Read that as a set of small decisions:
injectedis the appending half. Each entry is a key, a value, and amode.alwaysoverrides an incoming parameter of the same name,defaultfills the field only when nothing else supplied it, andnullinherits whateverdefaultModesays. So the example forcesutm_medium=emailon every visit while letting a sharer overrideutm_source. An appended value reaches the destination rather than the click record, which step 5 comes back to.forwardingis the pass-through half. Withenabledtrue and anallowlist, only the named incoming parameters travel to the destination, which keeps a shared URL's unrelated tracking noise out of your analytics. Anullallowlist forwards everything, and anullforwarding object inherits the organization default.inheritOrgInjectedandsuppressedOrgKeyshandle the fleet case. Organization level parameters merge into every link, the link wins on a key conflict, andsuppressedOrgKeysopts a single link out of an inherited key. In the example the organization's houseutm_campaignis suppressed precisely so the forwarded one can win.
Keys are capped at 64 characters and values at 512, which is more than any sane campaign name needs and less than a URL fragment you were thinking of smuggling through.
Step 2: Read the Values Inside the App
When the app is installed, this is the whole job. The system hands your app the tapped URL, query string intact, and the only way to lose the values from here is to not read them. The samples below use the WarpLink SDK for the routing calls, because a sample has to name something concrete. The parsing around them is platform API and works the same whatever resolves your links.
iOS: the browsing web user activity
A Universal Link arrives as an NSUserActivity with the activity type NSUserActivityTypeBrowsingWeb, and its webpageURL is the exact URL the user tapped. Read it before you hand the activity anywhere else:
import SwiftUI
import WarpLink
struct RootView: View {
var body: some View {
ContentView()
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL {
recordCampaign(from: url)
}
_ = WarpLink.continue(activity)
}
}
}
func recordCampaign(from url: URL) {
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
var values: [String: String] = [:]
for item in items where item.name.hasPrefix("utm_") {
guard let value = item.value, !value.isEmpty else { continue }
values[item.name] = value
}
guard !values.isEmpty else { return }
analytics.setCampaign(values)
}
Two details matter. URLComponents percent-decodes queryItems for you, so a value written as spring%202026 arrives as spring 2026 and you should not decode it twice. And the same activity is what the SDK needs to resolve the link, so read first and forward second. WarpLink.continue(_:) hands back a Bool that is true when the URL is a link it will handle, which is a signal for your own router and never a reason to skip the parsing above it.
Android: the intent data
An App Link arrives as the data URI on the intent that started or resumed your Activity. Cold start reads it from intent in onCreate, warm start from the intent delivered to onNewIntent:
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import app.warplink.WarpLink
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
intent?.data?.let { recordCampaign(it) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { recordCampaign(it) }
WarpLink.onNewIntent(intent)
}
private fun recordCampaign(uri: Uri) {
if (uri.isOpaque) return
val values = UTM_KEYS.mapNotNull { key ->
uri.getQueryParameter(key)?.takeIf { it.isNotEmpty() }?.let { key to it }
}.toMap()
if (values.isNotEmpty()) analytics.setCampaign(values)
}
private companion object {
val UTM_KEYS = listOf(
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
)
}
}
The isOpaque guard is not decoration. Uri.getQueryParameter only works on a hierarchical URI, the kind with // after the scheme, and an opaque one such as myapp:spring has no query component at all. An https App Link is always hierarchical, but the same helper often gets pointed at a custom scheme later, and that is where it throws.
React Native: the launch URL and the url event
React Native gives you the same URL through Linking, once for a cold start and again for every link that arrives while the app runs:
import { useEffect } from 'react';
import { Linking } from 'react-native';
const UTM_KEYS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
export function readCampaign(url: string): Record<string, string> {
const withoutHash = url.split('#')[0] ?? '';
const query = withoutHash.split('?')[1] ?? '';
const values: Record<string, string> = {};
for (const pair of query.split('&')) {
if (pair === '') continue;
const eq = pair.indexOf('=');
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
const key = decodeURIComponent(rawKey);
if (!UTM_KEYS.includes(key)) continue;
const value = decodeURIComponent(rawValue.replace(/\+/g, ' '));
if (value !== '') values[key] = value;
}
return values;
}
export function useCampaign(onCampaign: (v: Record<string, string>) => void): void {
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url !== null) onCampaign(readCampaign(url));
});
const subscription = Linking.addEventListener('url', ({ url }) => {
onCampaign(readCampaign(url));
});
return () => subscription.remove();
}, [onCampaign]);
}
The hand written parser is deliberate. React Native does not guarantee a complete URL implementation across engines and versions, and a parser you own is explicit about the two decoding rules a query string has: percent escapes everywhere, and + meaning a space.
Step 3: Carry the Campaign Through an Install
Everything above assumes the app was already there. For a brand new user it was not, and the URL they tapped is gone by the time your code runs.
What arrives instead is a deferred deep link: the server matches the first launch of the app back to the click that preceded it, inside a match window, which is simply how far back the server is willing to look for a matching click, six hours by default and capped at twenty-four. The result the SDK delivers carries linkId, destination, deepLinkUrl, isDeferred, and the match fields. The mechanics of that match, tier by tier, are in Deferred Deep Links.
The field that matters here is deepLinkUrl. It is the per-platform deep link configured on the link itself, returned verbatim, which makes it the one carrier that crosses the store round trip with your own values in it. So put the campaign on the link, not only in the query string of what you share:
curl -X POST https://api.warplink.app/v1/links \
-H "Authorization: Bearer wl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://example.com/collections/spring?utm_source=newsletter&utm_medium=email&utm_campaign=spring-2026",
"ios_url": "myapp://collections/spring?utm_source=newsletter&utm_medium=email&utm_campaign=spring-2026",
"android_url": "myapp://collections/spring?utm_source=newsletter&utm_medium=email&utm_campaign=spring-2026",
"app_id": "YOUR_APP_ID"
}'
The same parsing helper from step 2 then covers both journeys, since both are just a URL:
import WarpLink
func route(_ deepLink: WarpLinkDeepLink) {
guard let raw = deepLink.deepLinkUrl, let url = URL(string: raw) else {
showHome()
return
}
if deepLink.isDeferred {
recordCampaign(from: url)
}
navigate(to: url)
}
Two things are worth being blunt about.
The first is that most teams do not actually need the values in the app. The install is already joined on the server to the click that produced it, and that click names the link, so which placement produced a user is answerable in reporting without a line of app code. Reading them in the app buys in-session decisions: which onboarding to show, which product to feature, which welcome copy to use. If you are not making one of those, skip it. The install side of that join is in How to Track App Installs From a Link.
The second is a temptation worth resisting. A probabilistic match can be wrong, so campaign values recovered from a deferred install are good enough to shape a screen and not good enough to gate anything sensitive. The result carries matchGuaranteed, true only for an exact match, and that is the field to branch on before you personalise anything that touches an account.
Step 4: Name Every Value the Same Way
Reporting groups on exact values, so a breakdown is only as good as the discipline behind the strings. Fix the vocabulary before the first link exists, in a document the whole team can see.
| Field | Answers | Good values |
|---|---|---|
utm_source | Where the tap came from, as a name | newsletter, reddit, partner-acme |
utm_medium | What kind of surface it was, as a category | email, social, print, referral |
utm_campaign | Which effort it belongs to | spring-2026, launch-week |
utm_term | The paid keyword, when there is one | deep-linking |
utm_content | Which individual placement or creative | footer-button, poster-lobby |
The distinction that keeps reports readable is that source is a proper noun and medium is a category. One utm_source maps to one place in the world, and many sources share a medium. Blur the two and you get utm_source=email on one link and utm_source=newsletter on another, with no honest way to add them up.
Three conventions cover the rest. Write everything in lowercase, for the reason in step 6. Use hyphens rather than spaces or underscores, since a space has to be encoded and spring_2026 splits from spring-2026. And never rename a value mid quarter: the old clicks are already recorded under the old spelling and no join will merge them.
Per channel, that lands somewhere like this:
| Channel | utm_source | utm_medium | utm_content |
|---|---|---|---|
| Monthly newsletter | newsletter | email | hero-button |
| Community post | reddit | social | r-reactnative-thread |
| Printed code in a shop | store-queen-west | print | window-decal |
| In-app share sheet | app-share | referral | product-page |
| Partner integration page | partner-acme | partner | docs-sidebar |
A printed placement is a physical thing rather than a channel, so each one wants its own link and its own utm_content: QR Code Deep Linking covers the placement level discipline that goes with it. The in-app share row is the one where your own code writes the values, which makes it the easiest to get right and the easiest to forget.
Step 5: Read the Numbers Back
A click records the five UTM values found on the URL the visitor tapped, alongside device, country, referrer, and the link itself. That covers every tap that went through the redirect. A Universal Link or App Link that opens your app directly is recorded as a click too, but it carries no UTM values, because the SDK resolves the link rather than following the tapped URL. So the UTM breakdown describes the visitors on their way to a store or a web page, which is the population a campaign report is really about.
Which half of step 1 you chose decides where the values show up. Forwarded values were on the tapped URL by definition, so they reach the click record and the breakdown below. Appended values are written onto the destination, so they reach the analytics running there instead, and on the click side the link itself is the identity: one link per placement gives you the same comparison, one row per placement, with no UTM value involved. Do both if you want both, by giving the link its own values and tagging the URL you share as well.
From there the values are read in three ways.
Grouped in the dashboard. A link's clicks break down by source, medium, and campaign over a date range, ranked by volume, next to the same breakdowns for device, country, and referrer. Empty values are left out rather than shown as a blank row: an untagged share is not a campaign called nothing, it is simply not in the breakdown, and a link whose values are appended rather than tapped looks the same way here. The real-time analytics page is the fuller tour.
Exported as CSV, when the numbers need to sit in a spreadsheet or a warehouse next to revenue.
Queried from an AI client. A client of the MCP (Model Context Protocol) server can read a link's click analytics with the analytics:read scope, which suits the question you ask once a month and do not want to build a report for. Click analytics is read through those two surfaces rather than through a REST endpoint of its own.
How far back any of that reaches is a plan question rather than a technical one: retention runs from 90 days on the free plan up to three years on the largest ones, and a year over year campaign comparison wants a plan that keeps a year.
Step 6: Rule Out the Three Ways They Break
When a campaign reports as direct traffic, it is nearly always one of these three.
Overwriting. Two rules can want the same key, and the loser is silent. A value written into destination_url by hand, a parameter injected by the link, an inherited organization default, and a value forwarded from the incoming URL all address the same field, and the mode decides which survives: always overrides, default yields to anything already there. Write the intent down per key rather than per link. If utm_medium is a property of the channel it is always, and if utm_source is something a sharer may know better than you do it is default. When a link inherits an organization value it should not have, suppressedOrgKeys removes that one key rather than turning inheritance off wholesale.
Case. A query string is case sensitive on both sides of the equals sign. utm_Source is not utm_source and most reporting will not look for it, so those clicks arrive with no source at all. Values split the same way, quieter and more expensive: Spring-2026 and spring-2026 are two rows in a breakdown that should have been one, and nothing will correct them for you, because no service can know two spellings meant the same campaign. Lowercase everything, including the values a colleague types into a form.
Encoding. A value containing a space, an ampersand, a hash, or a plus sign has to be percent-encoded, and a value containing a whole URL has to be encoded as a unit. This is the failure that looks like data corruption: utm_campaign=spring & summer truncates at the ampersand and everything after it becomes a parameter nobody asked for. The clearest example of doing it right is the Android install referrer, a whole query string nested inside a single query parameter, so its own = and & are escaped and it arrives as referrer=utm_source%3Dwarplink%26utm_content%3D{link_id}. Never assemble a tagged URL by string concatenation. Use URLComponents on iOS, Uri.Builder on Android, and URLSearchParams on the web, and let each of them do the escaping.
One cheaper check comes before all three: confirm the URL actually shared is the URL you think it was. A query string lost to a copy and paste, to a social network's link rewriting, or to a colleague shortening the link a second time looks identical from your side to a parsing bug in your app.
Frequently Asked Questions
Do UTM parameters survive an app install? The query string does not. A visitor who taps a tagged URL without the app goes to a store listing, and neither store hands your app the URL that sent them there. Android passes exactly one field through the install, the referrer string attached to a Play Store URL. A link service claims that field to identify the link, though any other keys you set on it are merged in rather than dropped. Anything your app must read on first launch belongs on the link's own per-platform deep link URL, which comes back with the deferred match.
Where do I read UTM parameters inside an iOS or Android app? On iOS the tapped Universal Link arrives as an NSUserActivity whose webpageURL is the exact URL, query string included, so URLComponents gives you the values. On Android the App Link arrives as the intent data URI, and Uri.getQueryParameter reads each field. Both are the URL the system handed you, not the destination a link service resolved, so read them before you pass the URL on to anything else.
Should I put UTM parameters on the short link or on the destination URL? Put them on the link when the campaign is a property of the link itself, which is the case for a printed code, a bio link, or one newsletter placement. Configuring them on the link keeps the shared URL short and stops every share from having to carry a hand written query string. Leave them on the incoming URL only when the same link genuinely serves many campaigns and the sharer is the one who knows which.
Do UTM parameters work on App Store and Play Store links? Not in any way your app can read. A store URL accepts query parameters and the store's own campaign fields report inside the store's analytics, but none of that reaches your code on first launch. The one exception is the referrer value on a play.google.com URL, which the Play Store stores against the install and hands back to the app through the Play Install Referrer library.
Are UTM parameters case sensitive? Yes, on both the key and the value. A query string is case sensitive by specification, so utm_Source is a different field from utm_source and most reporting will simply not see it. Values split the same way, so Spring-2026 and spring-2026 become two rows in a campaign breakdown that should have been one. Write every key and value in lowercase and the problem never appears.
Do I still need UTM parameters if every campaign has its own link? They answer different questions. The link identifies one placement, which is what an install is attributed to, while the UTM fields group many links into a source, a medium, and a campaign you can read across. Without them a hundred links are a hundred rows with no way to roll them up, and the same values also reach whatever analytics runs on your web destination.
Related Guides
- The mechanism underneath all of this: The Complete Guide to Deep Linking covers Universal Links, App Links, custom schemes, and how a tap becomes a screen.
- When the tapped domain is not yours: Email Deep Linking explains why click tracked email links break the app handoff, and the ways to keep both.
- Campaign values on physical placements: QR Code Deep Linking applies one link per placement to printed and on-screen codes.
- The install side of the join: How to Track App Installs From a Link is the setup for attributing an install back to the click that produced it.
- Reading the numbers: the real-time analytics page shows how clicks and installs group by source, medium, and campaign.
How WarpLink Helps
The parts of this you cannot host inside your app are the redirect that records the click before the store takes over, the parameter rules that decide whether a value is appended or forwarded, and the match that joins a first launch back to a click hours later. That is WarpLink's job. Set injected and forwarding once per link, put the campaign on the per-platform deep link URL, and the same values are readable in the app on iOS, Android, and React Native without a second vendor in the path.
One workflow, three pillars. Deep linking puts the tap on the right screen, install attribution credits the link that produced a new user, and real-time analytics turns a month of that into a campaign report. Sub-10ms redirects on the way in, one SDK on the way out.
Create a free WarpLink account and get deep linking, install attribution, and real-time analytics in one SDK, with 10,000 clicks a month on the free plan. The Links API reference documents the parameters object behind every example above.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
QR Code Deep Linking: Route Scans Into Your App, Even Before Install
A QR code deep link is an HTTPS URL that a scan opens with a real user tap, so it must point at a redirect you control. How to route and attribute every scan.
How to Track App Installs From a Link on iOS and Android
How to track app installs from a link on iOS and Android: the redirect, the first-launch match, what the confidence scores mean, and a seven step setup.
Deferred Deep Linking on Android: How to Implement It in Kotlin
Deferred deep linking on Android hands the tapped link to the app on first launch through the Play Install Referrer, with fingerprint matching as the fallback.