Deeplinkly

Glossary/Implementation artifacts

Custom URI Scheme

Definition

A custom URI scheme is a non-standard URL protocol, such as myapp://, that an app registers with the operating system so that URLs beginning with it open that app.

It is the oldest form of mobile deep linking and the only one that works with no server, no domain and no verification — which is exactly why it is also the least trustworthy. Nothing stops a second app from registering the same scheme, and nothing happens at all if the app is not installed. Modern setups use Universal Links and App Links for user-facing links and keep a scheme for the jobs those cannot do.

Declaring a scheme on both platforms

iOS registers schemes in Info.plist; Android registers them as another <data> element on an intent filter. Neither declaration is checked against anything.

Info.plist (iOS)
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <!-- Reverse-DNS, so the OS can distinguish duplicate claims -->
    <key>CFBundleURLName</key>
    <string>com.example.shop</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>shopapp</string>
    </array>
  </dict>
</array>

<!-- Required before canOpenURL: can return true for another app -->
<key>LSApplicationQueriesSchemes</key>
<array>
  <string>partnerapp</string>
</array>
AndroidManifest.xml
<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />

  <!-- shopapp://products/42 -->
  <data android:scheme="shopapp" android:host="products" />
</intent-filter>

Keep the scheme out of your autoVerify filter

On Android, android:autoVerify="true" applies to the whole filter, and a custom scheme has no domain to verify. A filter mixing shopapp with https fails verification for the HTTPS hosts in it too. Declare the scheme in its own filter, as above.

A valid scheme starts with a letter and contains only letters, digits, +, - and .. Keep it lowercase — Android matches schemes case-sensitively and only against lowercase, so ShopApp in the manifest matches nothing.

Why schemes are not trustworthy

There is no registry. Registration is a claim in a bundle, verified by nobody, so a second app can declare shopapp and receive links intended for yours — including links carrying an OAuth authorisation code or a password reset token.

What each platform does when two apps claim one scheme.
PlatformBehaviourPredictable?
iOSOne app wins; the choice is undefined and not surfaced to the userNo
Android (unverified)The system shows a chooser, or opens the user's default for that schemeOnly if the user has chosen
iOS Universal LinkOwnership is proved by the AASA file on the domainYes
Android App LinkOwnership is proved by assetlinks.json plus signing keyYes

Never carry a secret in a custom-scheme URL

This is why OAuth for native apps requires PKCE. A redirect to shopapp://callback?code=… can be intercepted by any app that registered the same scheme, and the interception is invisible to the user. If you must use a scheme for an auth callback, treat the value in it as public and bind it to a proof key.

The second structural problem is what happens when the app is missing. A scheme URL has no fallback: iOS shows an error, and Android throws ActivityNotFoundException. There is no way to route the user to the store from the URL alone, which is what the fallback chain exists to solve.

Where a scheme is still the right tool

Custom scheme versus HTTPS deep link, by job.
Custom schemeUniversal Link / App Link
Setup requiredManifest entry onlyDomain, hosted file, entitlement
Ownership verifiedNoYes
Works if app not installedNo — errorYes — the web page loads
Works from an in-app browserOften, via intent:// on AndroidFrequently blocked
Works from a QR code or NFC tagYes, if the app is installedYes
Usable as an OAuth redirectYes, with PKCEYes, and safer
Usable for testing in the simulatorYesAwkward — the Simulator lies about associations
Appears in a browser address barRejected as invalidWorks

The honest list of remaining jobs is short: local testing, app-to-app handoffs between apps you control, OAuth callbacks where the provider does not support HTTPS redirects, and the last hop of an intent:// URL used to escape an in-app browser.

Testing a scheme
# iOS Simulator
xcrun simctl openurl booted "shopapp://products/42"

# Android device or emulator
adb shell am start -W -a android.intent.action.VIEW \
  -d "shopapp://products/42" com.example.shop

# Android: scheme wrapped in an intent:// URL with a web fallback,
# which is how you escape an in-app browser
adb shell am start -W -a android.intent.action.VIEW -d \
  "intent://products/42#Intent;scheme=shopapp;\
package=com.example.shop;\
S.browser_fallback_url=https%3A%2F%2Fexample.com%2Fproducts%2F42;end"

Do not detect installation by racing a timer

The old trick — open the scheme, then setTimeout to the store if the page is still alive — is unreliable on every current browser. iOS shows a modal error that stops the timer, Chrome blocks scheme navigation without a user gesture, and in-app browsers behave differently again. Use an HTTPS link with a real server-side fallback instead.

Receiving the URL in code

A scheme URL and a Universal Link arrive through different entry points on iOS, which is why an app can handle one and ignore the other.

Both entry points, SwiftUI
@main
struct ShopApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                // shopapp://products/42
                .onOpenURL { url in
                    Router.shared.handle(url)
                }
                // https://example.com/products/42
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    Router.shared.handle(url)
                }
        }
    }
}

In UIKit the equivalents are application(_:open:options:) for schemes and application(_:continue:restorationHandler:) for Universal Links. On Android both arrive as an Intent on the same activity, so the split does not exist there.

Deep link debugger

Custom schemes cannot be validated from outside the device, which is precisely their weakness. The debugger checks the half that can be: whether your domain is correctly configured for Universal Links and App Links, so you can move user-facing traffic onto links that prove ownership.

Open the deep link debugger

Frequently asked questions

What is the difference between a custom URI scheme and a Universal Link?
A custom URI scheme is a made-up protocol such as myapp:// that any app can register, with no verification and no behaviour when the app is missing. A Universal Link is a normal HTTPS URL that iOS routes to your app only after confirming, via a file hosted on your domain, that you own it — and that opens your website when the app is not installed.
Can two apps register the same URI scheme?
Yes, and nothing prevents it. On iOS the winner is undefined and not shown to the user; on Android the system falls back to a chooser or the user's existing default. This is why a custom scheme should never carry a secret such as an OAuth authorisation code without PKCE, and why HTTPS deep links replaced schemes for user-facing links.
Are custom URI schemes deprecated?
Neither Apple nor Google has deprecated them, and both platforms still support the declarations. They are, however, no longer appropriate as the primary link format: they cannot prove ownership, cannot fall back to the web, and cannot be used in a browser address bar. Keep one for testing, app-to-app handoffs and intent:// fallbacks, and route real traffic over HTTPS.
Why does my custom scheme not open from Chrome or an in-app browser?
Modern browsers block navigation to a non-HTTP scheme that was not triggered by a direct user gesture, and many in-app browsers block it outright. On Android the supported way through is an intent:// URL that names the package and carries an S.browser_fallback_url parameter, so the browser opens the app when it is installed and the fallback URL when it is not.
How do I test a custom URI scheme?
On the iOS Simulator run xcrun simctl openurl booted followed by the URL in quotes. On Android run adb shell am start -W -a android.intent.action.VIEW -d with the URL and the package name. Add -c android.intent.category.BROWSABLE on Android so the test matches how a browser would launch it rather than succeeding against a filter no browser can reach.

Related terms

  • Universal LinkA Universal Link is a standard HTTPS URL that opens an iOS app directly when that app is installed and the domain has authorised it, and loads the equivalent web page when it is not.
  • Android App LinkAn Android App Link is an HTTPS URL that opens an Android app directly, without a chooser dialog, because the system has verified through a file on the domain that the app is authorised to handle it.
  • 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.
  • 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.
  • Testing a deep linkTesting a deep link means verifying three separate things — that the operating system routes the URL to the app, that the app receives it, and that the app navigates to the right screen — each of which can pass while the others fail.