Deeplinkly

Glossary/Implementation artifacts

Query parameter

Definition

A query parameter is a key-value pair in the portion of a URL that follows a question mark, separated from other pairs by an ampersand, and used to pass data to the resource the URL identifies without changing which resource that is.

Query parameters are how a deep link carries everything that is not the destination: the campaign that produced the click, the click identifier used to match a deferred deep link, and whatever product or content the app should open on. They are also the part of a URL most often corrupted in transit, because the characters that delimit them are the same characters people put inside them.

The grammar, and the characters that break it

Every delimiter in a query string and what it does.
CharacterRoleInside a value it must be
?Begins the query string%3F
&Separates one pair from the next%26
=Separates a key from its value%3D
#Ends the query and begins the fragment%23
+Historically means a space in a query%2B
spaceNot permitted literally%20 or +
%Introduces an escape sequence%25

The `+` row is the one that silently corrupts data

In a query string a literal + is conventionally decoded as a space, so a base64 value, a signature or a timestamp offset containing + arrives with spaces where the pluses were. ?sig=ab+cd becomes ab cd. Encode it as %2B. Note this convention applies to the query only — in a path, + is just a plus.

The fragment rule catches teams out in the other direction. Everything after # is never sent to the server, so a parameter placed after the fragment marker is invisible to your redirect service, your analytics and your logs, while remaining perfectly readable to JavaScript on the page. A campaign parameter that only appears in client-side reporting and never in server logs has usually landed after a #.

Two behaviours have no standard at all and differ by framework: duplicate keys, where ?id=1&id=2 may yield the first value, the last, or an array; and ordering, which is preserved in the string but not guaranteed by any parser. Do not rely on either. If you need a list, name it explicitly — ?ids=1,2,3 — and parse it yourself.

Where you can match on a query parameter, and where you cannot

This is the part that is genuinely platform-specific, and getting it wrong produces a link that verifies, opens the app, and then lands on the wrong screen.

Query matching support across the link declaration surfaces.
SurfaceCan match on the query?
AASA legacy paths arrayNo — path only; the query is ignored
AASA modern components arrayYes — via the ? key
Android `<data>` intent filterNo — scheme, host, path, port only
assetlinks.jsonN/A — it authorises a whole domain
Your app's router, in codeYes — always
Server-side redirect rulesYes
Matching a query parameter in the AASA components format
{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.app"],
        "components": [
          {
            "/": "/product/*",
            "?": { "variant": "gift" },
            "comment": "Only gift-variant product URLs open in the app"
          },
          {
            "/": "/search",
            "?": "*",
            "comment": "Any query on /search is claimed"
          },
          {
            "/": "/product/*",
            "?": { "preview": "true" },
            "exclude": true,
            "comment": "Preview links stay in the browser"
          }
        ]
      }
    ]
  }
}

The components format is the only place in either platform's association file where the query is part of the matching decision, and the exclude flag combined with a ? matcher is the sanctioned way to keep a subset of URLs in the browser. Android has no equivalent — an intent filter matches on the path and nothing else, so every query-dependent routing decision on Android has to happen inside your app.

Reading parameters from the link the app was opened with
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val data: Uri? = intent.data
    // getQueryParameter decodes percent-escapes for you. Do not
    // decode again — "%2520" round-tripped twice becomes "%20",
    // which is the signature of a double-encoded link.
    val productId = data?.getQueryParameter("id")
    val clickId = data?.getQueryParameter("click_id")
    val source = data?.getQueryParameter("utm_source")

    // Duplicate keys: be explicit rather than trusting the default.
    val allIds: List<String> = data?.getQueryParameters("id") ?: emptyList()

    // A missing parameter is not an error. Route to a sensible
    // default screen rather than crashing or showing a blank state.
    route(productId ?: return goHome(), clickId, source)
}

The three ways links lose their parameters

The first is double encoding, which happens whenever a URL is put inside another URL. A redirect target containing its own query must be encoded as a single value, or its & will be read as a delimiter of the outer query and everything after it will be attributed to the wrong parameter.

Proving what survives a redirect chain
# WRONG. The nested & terminates the outer "redirect" value, so
# the service sees redirect=https://example.com/p and a stray
# id=42 parameter of its own.
curl -sI "https://links.example.com/l/abc?redirect=https://example.com/p?id=42&utm_source=news"

# RIGHT. The whole nested URL is one encoded value.
curl -sI "https://links.example.com/l/abc?redirect=https%3A%2F%2Fexample.com%2Fp%3Fid%3D42&utm_source=news"

# Follow every hop and print only the Location headers. Any hop
# that drops your click_id is the one to fix.
curl -sIL "https://links.example.com/l/abc?click_id=xyz789&utm_source=news"   | grep -i '^location:'

# Confirm the final URL still carries everything.
curl -s -o /dev/null -w '%{url_effective}\n' -L   "https://links.example.com/l/abc?click_id=xyz789&utm_source=news"

The second is the redirect chain itself. A hop that rewrites the URL without copying the query forward discards it, and this is common in CDN rules, vanity-domain forwarders and marketing platforms that wrap links. The symptom is an install with a destination but no campaign — the routing works and the attribution does not. The curl -sIL invocation above localises the offending hop in one command.

The third is the surface the link is opened in. In-app browsers append their own parameters and occasionally strip others, which both pollutes your reporting and changes the URL your app receives — see deep links in in-app browsers. Treat unknown parameters as ignorable rather than fatal, and never key a cache or a signature on the full query string, because you do not control what gets added to it.

The five UTM parameters, which are ordinary query parameters by convention only.
ParameterCarriesExample
utm_sourceWhere the traffic came fromnewsletter
utm_mediumThe channel typeemail
utm_campaignThe campaign namespring_launch
utm_contentWhich creative or link varianthero_cta
utm_termPaid keyword, where applicablerunning_shoes

Nothing enforces UTM spelling

They are a convention, not a standard, and no platform validates them. utm_Source, utm_souce and utm_source are three distinct parameters to every parser, which is why campaign reports fragment into near-duplicate rows. Generate them from one place rather than typing them per link — the same discipline that makes a cross-platform campaign taxonomy reportable.

UTM builder

The builder encodes every value correctly and keeps parameter spelling consistent across links, which removes both the double-encoding bug and the near-duplicate campaign rows that come from typing utm_source by hand.

Open the utm builder

Frequently asked questions

What is a query parameter in a URL?
It is a key-value pair in the part of a URL that follows a question mark, with pairs separated by ampersands and keys separated from values by equals signs. Query parameters pass data to a resource without changing which resource the URL identifies, which is what makes them the standard way for a deep link to carry campaign data, a click identifier and the content to open.
How do I pass a URL inside a query parameter?
Percent-encode the entire nested URL as a single value, so https://example.com/p?id=42 becomes https%3A%2F%2Fexample.com%2Fp%3Fid%3D42. If you do not, the nested URL's own ampersand is read as a delimiter of the outer query string, truncating your value and turning the remainder into stray parameters of the outer URL. Decode exactly once when reading it back.
Can iOS Universal Links match on a query parameter?
Yes, but only in the modern components format of the apple-app-site-association file, which supports a ? key alongside the path. The legacy paths array matches on the path alone and ignores the query entirely. Android has no equivalent at all — an intent filter matches scheme, host, port and path, so query-based routing on Android must happen in your app's code.
Why does my deep link arrive without its query parameters?
Almost always a redirect hop that rewrites the URL without copying the query forward, which is common in CDN rules, vanity-domain forwarders and link-wrapping marketing platforms. Running curl with -sIL and printing only the Location headers shows which hop drops them. The other causes are parameters placed after a # fragment marker, which never reach the server, and in-app browsers rewriting the URL.
Why does a plus sign in a query parameter turn into a space?
Because in a query string a literal plus is conventionally decoded as a space, inherited from form encoding. Any value containing a plus — a base64 string, a signature, a timezone offset — arrives corrupted. Encode it as %2B. The convention applies to the query string only; in the path portion of a URL a plus is treated as a literal plus character.

Related terms

  • Apple App Site Association (AASA)The apple-app-site-association file is a JSON document hosted at a domain's /.well-known/ path that tells iOS which app is allowed to handle which URLs on that domain.
  • Intent FilterAn intent filter is an element in an Android app's manifest that declares which intents an activity can handle, including the URL patterns that should open it.
  • Branded Link DomainA branded link domain is a domain or subdomain you own that is used to serve your short links and deep links instead of a link provider's shared domain.
  • Deep links in in-app browsersAn in-app browser is an embedded webview inside another app that renders links without handing them to the operating system, which prevents Universal Links and App Links from ever reaching the app that claims the domain.
  • Cross-platform attributionCross-platform attribution is the practice of measuring one advertising campaign across iOS, Android and the web, where each platform supplies a different attribution signal at a different level of granularity and the results cannot be directly summed.