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.
TL;DR: A QR code deep link is an ordinary HTTPS URL printed as a scannable pattern, and everything useful follows from what the camera does with it. Neither the iOS Camera app nor the Android camera opens your app on its own: they decode the URL, show it, and wait for a tap. Because that tap is a genuine user tap, a Universal Link or a verified App Link on the scanned domain hands off into your app, and falls back to the browser when the app is missing. So the code must encode a redirect you control, never a store URL and never a raw custom scheme. The redirect routes per platform, records the scan as a click, and preserves the destination through an install with deferred deep linking. Print one link per placement, size the code at roughly a tenth of the reading distance, and keep the quiet zone clear. The scan then shows up in real-time analytics as a click, and the install that follows shows up attributed to it.
What a QR Code Deep Link Actually Does When Someone Scans It
A QR code deep link is an HTTPS URL, printed or displayed as a scannable pattern, that routes the person who scans it into a specific screen in your app.
Start with the mechanics, because every decision below depends on them.
On iOS, the stock Camera app has decoded QR codes since iOS 11. It detects the code, shows a notification banner near the top of the viewfinder with the destination, and does nothing else until the user taps that banner (Apple Support). That deliberate pause is the most important detail in this guide, because the tap that follows is a real user tap, exactly the interaction Universal Links require. A server redirect does not fire a Universal Link. A JavaScript redirect does not fire a Universal Link. A person tapping a banner does.
So the chain is: decode, show, user taps, iOS resolves the URL. If the domain in that URL is associated with an installed app through a valid apple-app-site-association file, iOS hands the URL to the app; otherwise it loads in the browser, which since iOS 14 is whichever browser the user has set as default. Treat the exact banner wording as version-dependent; the decode, show, tap sequence has been stable from iOS 11 through iOS 18.
On Android the shape is the same. QR decoding is built into the camera on effectively every recent device, with Google Lens as the fallback where it is not, and a tap on the surfaced chip opens the URL in the default browser. Verified App Links take over from there as Universal Links do, provided assetlinks.json verification succeeded for the scanned host and the user has not turned off link handling for your app.
Two Android caveats, both variable. Some camera apps open scanned URLs by targeting the browser package directly rather than dispatching a plain view intent, which bypasses App Links resolution even though verification is fine, so test across OEMs. And newer Android releases have added a confirmation step before a scanned link opens, so plan for a possible extra tap.
| What the code encodes | iOS Camera | Android camera or Lens |
|---|---|---|
https:// URL, app installed and association verified | Banner, tap, app opens | Chip, tap, app opens if link handling is on |
https:// URL, app not installed | Banner, tap, default browser | Chip, tap, default browser |
Custom scheme such as myapp://item/42 | Inconsistent, and nothing without the app | Same, and nothing without the app |
One consequence shapes everything below. A scan reaches your redirect as a cold, anonymous browser request: no cookies, no referrer, no session, no app context. Whatever you want to know about it has to be encoded in the URL itself, which is why the identity of the printed link matters more than any other choice here.
Why a Printed QR Must Encode a Redirect You Control
Printed things do not get patched. A code on a poster, a receipt, a table tent, or a shipping box is frozen at print time and stays in circulation for years. That rules out two of the three things teams commonly encode.
Not a store URL
An App Store or Play Store URL is wrong for half your audience before anyone scans it: an iOS user who scans a Play Store link gets a web page about an Android app. You cannot fix it after printing, so a rebrand or a store URL format change breaks the code permanently. It also throws away the scan. A store URL carries no destination beyond the listing, so someone who scans next to a specific product installs and lands on your home screen with no idea what they scanned, with no click record and nothing for a later install to be attributed to.
Not a raw custom scheme
The second instinct is to encode myapp://product/42 so the app opens directly. A custom-scheme URL is an in-app route, not a web address: there is nothing on the other end of it for a device without your app, so a scan from a non-user produces an error or silence. Scanner behaviour on non-HTTP schemes is also inconsistent, and some generators corrupt them by prepending http://.
Android market URLs land in the same bucket. A market://details?id=... address counts as a custom scheme, so a link service routes it to your fallback or store URL rather than following the scheme. That is what keeps Android attribution deterministic: the Play Install Referrer is attached only when the visitor is sent to the https://play.google.com address, so a link that stopped at market:// would lose the referrer.
One HTTPS short URL, on a domain you own
That leaves the option that works: encode a single short HTTPS URL such as https://aplnk.to/x7k2pq or https://go.yourapp.com/spring-sale, and put every decision behind it.
At scan time the redirect resolves at the edge, parses the User-Agent to detect iOS, Android, or desktop, and returns a 302. If the visitor has your app and the association files are in place, the Universal Link or App Link intercepts before the browser paints. If not, the fallback cascade decides: the link-level fallback URL, then the app-level store URL, then the organization default. The same request produces the click record the rest of this guide depends on.
A branded domain earns its place here more than anywhere else, because printed links are read by humans before they are scanned. Association files are generated and served automatically for every verified custom domain, so the branded host still opens your app. Two things are on you: add the domain to the Associated Domains entitlement on iOS and the App Links intent filter host on Android, and declare it to the SDK with linkDomains (or the plist or manifest key) so a fresh install resolves it on the first launch.
The Install Case: the Scan Is the Click
Most posters are scanned by people who do not have your app. That is the point of a poster, so the install path is the primary path for print. Without deferred deep linking the sequence collapses at the store: the user scans, taps, installs, opens the app, and arrives on the home screen, because the store passes nothing about why they came. Deferred deep linking stitches the two halves back together:
- The scan. The redirect resolves, records the click with the signals the request exposes (the IP derived server-side, the normalized language, the timezone), and sends the visitor to the App Store or the Play Store.
- The install and first launch. The SDK detects first launch, collects the device's own signals (preferred language, IANA timezone name and offset, platform identifiers), and sends them to the attribution endpoint. A completion marker is written once the check finishes, so it runs exactly once per install.
- The match. The server compares install-time signals against click-time signals and returns the original link, its destination, and its parameters.
The match runs as a cascade, strongest signal first:
| Priority | Method | Signal | Confidence | Platform |
|---|---|---|---|---|
| 1 | Referrer | Play Install Referrer | 1.0 | Android |
| 2 | Device ID | IDFV | 1.0 | iOS |
| 3 | Fingerprint | IP (server-derived), normalized language, timezone | 0.20 to 0.85 | Both |
| 4 | Raw signals | accept_language, timezone, timezone_offset | 0.20 to 0.85 | Both |
The first two tiers are deterministic and return match_guaranteed: true, exposed as matchGuaranteed on the SDKs. The bottom two are probabilistic, and their score decays with the gap between click and install: on the strongest fingerprint variant, 0.85 inside an hour, 0.65 inside three, 0.50 inside six, and 0.30 out to the 24 hour ceiling.
Print is unusually well served by that decay, because a scan-to-install gap in a physical space is typically minutes. Someone reads a poster, scans, installs while standing there, and opens the app before walking away. That lands in the top band, the strongest a probabilistic match ever gets. The match window defaults to 6 hours and is capped at 24, set per link on the server, so a code scanned in the morning and acted on at night is outside it by design.
Two failure modes are specific to physical placements, and both are about the network. The probabilistic key includes the request IP, so a visitor who scans on a venue's WiFi and installs on cellular produces two keys and no match. And on a dual-stack network the browser can reach the redirect over IPv6 and the app over IPv4: same network, different address. Android sidesteps both, because the Play Install Referrer is a handoff rather than an inference.
When the match returns, branch on matchGuaranteed before the score. It is true only for a deterministic match, and it is what should gate anything sensitive: automatic sign-in, restoring a session, showing personal data. One setup detail decides whether any of this works at all: the mobile SDK must be configured with an SDK key, created in the dashboard under API Keys. An ordinary API key still resolves deep links, so the integration looks healthy, but every attribution call is rejected and no installs appear.
One Link Per Placement, or You Cannot Attribute Anything
The mistake that quietly wastes a print run: one QR code for the whole campaign. Every scan of that code is indistinguishable from every other, so the only question you can answer is whether the campaign worked.
The rule is one link per placement. Not per campaign, not per creative, per physical thing in the world: the table tent and the window decal in one shop are two links. The slug is the identity, so let it read like one. Slugs are 2 to 100 characters, lowercase alphanumeric with hyphens, either auto-generated as a 6-character string or chosen: poster-union-station, booth-devcon-backdrop, decal-queen-west.
Campaign context rides along as query parameters on the destination. The blunt way is to write the UTM parameters into destination_url. The better way for a fleet of printed codes is the link's parameters object, which appends them at redirect time:
{
"parameters": {
"injected": [
{ "key": "utm_source", "value": "qr", "mode": "always" },
{ "key": "utm_medium", "value": "print", "mode": "always" },
{ "key": "utm_campaign", "value": "spring-2026", "mode": "always" },
{ "key": "utm_content", "value": "poster-union-station", "mode": "always" }
],
"inheritOrgInjected": true,
"suppressedOrgKeys": []
}
}
mode is always to override an incoming parameter of the same name, or default to fill one in only when it is absent. Organization-level injected parameters are inherited and merged with the link's own, with the link winning on a key conflict, so utm_source=qr can live once at the organization level while utm_content stays per placement, and suppressedOrgKeys opts a single link out of an inherited key. Incoming-parameter forwarding, controlled by forwarding on the same object, does almost nothing for print, since a camera scan carries only what the code encodes.
Use tags on the link (up to 20, 50 characters each) to group a run. The payoff lands on both sides: the destination receives tagged traffic, and the deferred match returns the original link's custom parameters, so the campaign that produced an install is readable during onboarding:
if let campaign = deepLink.customParams["utm_campaign"]?.stringValue {
tagOnboarding(campaign: campaign)
}
Dynamic and Static QR Codes
The distinction is what the modules encode. A static code encodes the final destination in the pattern. It needs no service behind it and never expires, but it can never change: correcting a typo or retiring a product means reprinting everything in the field. It also cannot count scans, because nothing you own is in the path.
A dynamic code encodes a short redirect URL, and the destination lives on the server behind it. The printed pattern is permanent; what it resolves to is not. Any QR encoding a short link is dynamic by construction, which buys three things on a poster that outlives its campaign:
- Repointing and lifecycle control. Update
destination_url,ios_url, orandroid_urland every code in the world follows on the next scan.expires_atretires a seasonal code on a date,is_activeswitches one off, andpasswordgates one behind a passphrase. - Per-platform routing. One printed code sends an iPhone, an Android phone, and a laptop to three different places.
- Measurement. The scan passes through something you own, the only reason it can be counted.
The honest tradeoff is that a dynamic code depends on the redirect resolving, forever, which makes the durability of that redirect a real question before you print at volume.
Dynamic codes are also physically better codes. Module count scales with the length of the encoded data, so a 6-character slug on a short domain produces a sparse code with large modules that reads faster, from further away, and through more glare and damage than a 90-character tracked URL in the same square.
Printing It: Size, Error Correction, and the Quiet Zone
Physical constraints are where QR projects fail, long after the linking is correct.
Size follows distance. The working rule is 10:1: the code is roughly one tenth of the intended scanning distance (Scanova). A table tent read at arm's length can be small; a wall code read from three metres wants about 30 cm. The practical floor for print is around 2 cm square, and codes below that fail often on low-resolution prints, curved surfaces, and dim light (Wave Connect).
Error correction is a size decision, not a quality setting. The four levels recover roughly 7 percent (L), 15 percent (M), 25 percent (Q), and 30 percent (H) of a damaged code. Higher levels add modules, so the same URL at level H needs more physical space than at level L to keep the modules resolvable. Signage, anything outdoors, and anything with a logo overlaid in the centre wants Q or H.
The quiet zone is part of the code. The specification calls for a clear margin of four modules on all sides, and designers reliably eat it by tucking the code against a border or a photo edge.
The rest is print hygiene: dark modules on a light background rather than inverted, real contrast, vector output or at least 300 DPI raster, and never a JPEG, whose compression artifacts blur module edges. Avoid gradients across the modules, account for glare on gloss laminate and curvature on bottles and cans, and print the URL as readable text near the code, because someone will want to type it. Then do a physical proof: print one at final size on the final material, scan it on an old iPhone and a mid-range Android in bad light, and confirm it opens the app on a device that has it.
Measuring Scans in Real-Time Analytics
A scan is a click, so it lands in analytics like any other click and needs no separate scan-tracking concept. Per link you get total clicks, unique clicks, installs, a time series, a device breakdown, top countries, top referrers, a recent activity feed, and CSV export. Uniqueness is computed server-side at write time rather than at the cache layer, per combination of link, IP address, and 24-hour window, the right number for a poster the same person may scan twice while deciding.
One reading is specific to print: referrer is empty for camera scans, so a print link whose traffic is overwhelmingly direct is behaving exactly as it should, and a sudden slug of referred traffic usually means the URL leaked onto the web.
Then the part that closes the loop. Because each placement is its own link and each install is matched back to a click, the install column is readable per placement. "The station poster produced 1,240 scans and 180 installs, the booth backdrop 310 and 95" is a sentence about where to spend the next print budget, and a single campaign-wide code cannot produce it.
One planning note: retention runs 90 days on Free, 180 on Starter, 365 on Growth, and 3 years on Scale. Print campaigns outlive most digital ones, so if you intend to compare this spring's run to last spring's, check that retention covers the gap before the codes go out.
What Goes Wrong in the Field
- The code went to a store URL. Half the scans land on the wrong store, none carry a destination, and nothing can be attributed. The most common defect, and the only one that cannot be fixed without reprinting.
- One code for the whole campaign. Every scan is anonymous relative to every other. Reprint as one link per placement; the slug is free.
- The proof was never scanned, the quiet zone was designed away, or the code is too small for its reading distance. Print one at final size on the final stock, keep four modules of clear margin, and re-check the 10:1 rule after raising error correction.
- The app is installed but the link opens the browser. An association problem, not a QR problem: on iOS the
apple-app-site-associationfile or the entitlement, on Androidassetlinks.jsonverification or the SHA-256 fingerprint. Debug guides are linked below. - The deferred match never fires. The network changed between the scan and the install, the match window elapsed, or the SDK was configured with an API key instead of an SDK key, which resolves deep links normally while rejecting every attribution call. A custom domain that works only after a launch or two was never declared to the SDK.
Frequently Asked Questions
Does a QR code open my app directly, or does it open a browser first? Neither camera opens your app on its own. Both iOS and Android decode the code, show the URL, and wait for the user to tap it. That tap is a real user tap, so a Universal Link or a verified App Link on the scanned domain can hand off to your app from there. If the app is not installed, or the association is not verified, the URL loads in the default browser instead.
Can a QR code deep link work if the app is not installed? Yes, through deferred deep linking. The scan is recorded as a click with the signals the browser exposes, the visitor is sent to the store, and on first launch the SDK asks the server which recent click this install belongs to. The original destination and its parameters come back, so the new user lands on the scanned content instead of a cold home screen.
Should I put my App Store or Play Store link in the QR code? No. A store URL serves one platform, so half your scans land on the wrong store or on a desktop page, and it is frozen the moment it is printed. Encode one HTTPS short URL on a domain you control and let the redirect pick the right destination per platform at scan time.
What is the difference between a static and a dynamic QR code? A static code encodes the final destination in the modules themselves, so changing it means reprinting. A dynamic code encodes a short redirect URL, and the destination lives on the server behind it. Any QR that encodes a WarpLink short link is dynamic by construction: the printed code never changes, and what it resolves to can.
How big does a printed QR code need to be? Follow the 10:1 rule: the code should be roughly one tenth of the intended scanning distance, so a code read from one metre wants about 10 cm across. Two centimetres square is the practical floor for close-range print. Higher error correction adds modules, so a code set to level Q or H needs more physical space than the same URL at level L.
How do I know which poster a scan came from? Create one link per physical placement rather than one per campaign, and let the slug carry the placement name. Add campaign context with injected parameters on the link so every scan arrives at the destination tagged, and use link tags to group placements. Because installs are matched back to the click, the install count is readable per placement too.
Related Guides
- Start with the concepts: Deep Linking: The Complete Guide for Mobile Developers covers how Universal Links, App Links, and deferred deep links fit together.
- The install path in code: Deferred Deep Linking on iOS, on Android, and in React Native.
- What the matched installs tell you: Install Attribution Without an MMP.
- When the app does not open: triage iOS with Universal Links Not Opening and Apple App Site Association Not Working, and Android with Android App Links autoVerify Failed.
- Reference: deep linking, deferred deep links, install attribution, custom domains, and the Links API.
How WarpLink Helps
A printed code needs a redirect that resolves fast enough to feel instant, keeps resolving for as long as the poster exists, and records the scan on the way through. WarpLink is three pillars behind one short URL: the deep link that routes each scan per platform, the install attribution that ties the install back to the placement, and the real-time analytics that turn a wall of posters into a ranked list.
Creating the link for a placement is one request, and every field below exists on POST /v1/links:
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/products/blue-running-shoes",
"slug": "poster-union-station",
"ios_url": "https://example.com/products/blue-running-shoes",
"ios_fallback_url": "https://apps.apple.com/app/id123456789",
"android_url": "https://example.com/products/blue-running-shoes",
"android_fallback_url": "https://play.google.com/store/apps/details?id=com.example.app",
"tags": ["qr", "print", "spring-2026"],
"parameters": {
"injected": [
{ "key": "utm_source", "value": "qr", "mode": "always" },
{ "key": "utm_medium", "value": "print", "mode": "always" },
{ "key": "utm_campaign", "value": "spring-2026", "mode": "always" },
{ "key": "utm_content", "value": "poster-union-station", "mode": "always" }
],
"inheritOrgInjected": true,
"suppressedOrgKeys": []
}
}'
The response carries short_url (for example https://aplnk.to/x7k2pq), which is the string that goes into the code, alongside id, slug, domain, and is_active. Leaving android_fallback_url set to the https://play.google.com address keeps Android attribution deterministic through the Play Install Referrer, and setting app_id associates the link with a registered app so the app-level store URLs cascade in when a per-link fallback is absent.
The dashboard does the same without the terminal: create the link, open its QR dialog, pick a size, toggle the border to include the quiet zone or a transparent background for a coloured layout, and copy or download the PNG at level H error correction. The code encodes the short URL, so repointing the link later needs no new code.
One thing worth knowing before you commit a design to print: redirects are never blocked. Passing a plan's click limit gates new analytics behind an upgrade prompt, and the links keep resolving, including on the free plan.
Create a free WarpLink account and get deep linking, install attribution, and real-time analytics behind every printed code, with 10,000 clicks a month on the free plan and no time limit. The deep linking concepts page is where to start.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
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.
Deferred Deep Linking in React Native: One API for iOS and Android
Deferred deep linking in React Native routes the pre-install link on first launch through one JavaScript API, backed by the Play referrer and iOS matching.
Deferred Deep Linking on iOS: How to Implement It in Swift
A vendor-neutral Swift walkthrough of deferred deep linking on iOS: why iOS has no native support, the match cascade (IDFV, fingerprint, raw signals), the ATT and Private Relay reality, and a one-call first-launch implementation.