Setting Up Deep Links in Capacitor — iOS and Android, From Scratch

Deep links. Tap a URL, app opens. How hard can it be?
Not that hard — but it has its own special flavor of silent failures, especially when juggling two platforms that handle the same concept in completely different ways.
This is the complete setup guide. Both platforms. The parts the docs gloss over. And the mistakes that are easy to make.
First, Understand What's Actually Happening
Before touching any config, get this mental model right:
iOS uses Universal Links — verified through an
apple-app-site-association(AASA) file you host on your domain.Android uses App Links — verified through an
assetlinks.jsonfile, also on your domain.
Both platforms fetch these files at install time, verify that the app signature matches, and only then agree to intercept matching URLs and route them into your app instead of the browser.
That word — install time — will matter a lot when you're debugging.
Step 1: Host the Well-Known Files
Both files live at /.well-known/ on the same domain your links use — and this is where many developers trip up.
Your links might point to your main domain (https://yourdomain.com/some-path) or a subdomain (https://app.yourdomain.com/some-path). It doesn't matter which — but the well-known files must be hosted on that exact domain. If your links use yourdomain.com but your files are served from app.yourdomain.com, verification fails silently and the browser opens instead of your app.
Pick one domain, use it consistently everywhere.
The files have non-negotiable requirements regardless of where they live:
Served over HTTPS
No redirects on the
.well-knownpathContent-Type: application/jsonNo authentication
iOS: apple-app-site-association
⚠️ The file must be named exactly
apple-app-site-association— no.jsonextension. iOS ignores it entirely if you name itapple-app-site-association.json.
iOS 13+ uses the components format. If you're supporting older setups, both formats work — but prefer the modern one for new projects:
Modern (iOS 13+):
{
"applinks": {
"details": [
{
"appID": "TEAMID.com.your.bundleid",
"components": [
{
"/": "/*",
"comment": "Matches all paths"
}
]
}
]
}
}
Legacy (pre-iOS 13, still supported):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.your.bundleid",
"paths": ["*"]
}
]
}
}
TEAMID comes from your Apple Developer portal. com.your.bundleid is your app's bundle identifier.
Android: assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.your.appid",
"sha256_cert_fingerprints": [
"AA:BB:CC:..."
]
}
}
]
Step 2: Get the Right Android SHA256 Fingerprint
This is the most common place everything breaks.
From a connected device:
adb shell pm get-app-links com.your.appid
Look for the Signatures field.
From your keystore:
keytool -list -v -keystore release.keystore -alias your_alias | grep SHA256
⚠️ If you use Google Play App Signing — and you probably do — stop here. Google re-signs your APK with their own certificate. Your local keystore fingerprint will not match what's installed from the Play Store. The correct fingerprint lives in Play Console → Protected with Play → Play Store protection → Protect app signing key → Manage Play app signing.
If the UI has changed, refer to the official Play App Signing documentation.
This mistake alone accounts for approximately 80% of "Android App Links not working" Stack Overflow posts.
Step 3: iOS: Associated Domains
In Xcode: Signing & Capabilities → Associated Domains, add:
applinks:yourdomain.com
If you want to cover both your main domain and all subdomains with a single entitlement, use a wildcard:
applinks:*.yourdomain.com
Note that the wildcard covers subdomains only, it does not match the root domain (yourdomain.com) itself.
In App.entitlements:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourdomain.com</string>
</array>
Step 4: Android: Intent Filter
In android/app/src/main/AndroidManifest.xml, inside your <activity> block:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="yourdomain.com" />
</intent-filter>
android:autoVerify="true" triggers the assetlinks.json fetch at install time.
If you need to support multiple subdomains, add additional <data> tags inside the same intent filter:
<data android:scheme="https" android:host="app.yourdomain.com" />
<data android:scheme="https" android:host="links.yourdomain.com" />
Step 5: Handle the URL in Capacitor
Create a dedicated service and wrap navigation in NgZone. Capacitor listeners run outside Angular's change detection zone, skipping this causes a subtle bug where the route changes but the UI doesn't update until the user taps the screen.
There are also two distinct scenarios to handle: the app is already running (warm start), and the app was completely closed when the link was tapped (cold start). appUrlOpen covers warm starts, but on cold starts the event can fire before the listener registers. Use App.getLaunchUrl() to catch those.
import { App } from '@capacitor/app';
import { Router } from '@angular/router';
import { Injectable, NgZone } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DeepLinkService {
constructor(private router: Router, private zone: NgZone) {}
init() {
// Handle links when the app is already open or backgrounded
App.addListener('appUrlOpen', (event) => {
this.handleUrl(event.url);
});
// Handle links that triggered a cold start
App.getLaunchUrl().then((launchUrl) => {
if (launchUrl?.url) {
this.handleUrl(launchUrl.url);
}
});
}
private handleUrl(url: string) {
this.zone.run(() => {
const parsedUrl = new URL(url);
const routePath = parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
if (routePath) {
this.router.navigateByUrl(routePath);
}
});
}
}
Call DeepLinkService.init() during app initialization so neither scenario is missed.
Debugging
Android
Check verification status:
adb shell pm get-app-links com.your.appid
You want verified next to your domain. If you see none or failed — wrong fingerprint, or assetlinks.json isn't reachable.
Force a re-verification:
adb shell pm set-app-links --package com.your.appid 0 all
adb shell pm verify-app-links --re-verify com.your.appid
adb shell pm get-app-links com.your.appid
Test a link directly from terminal:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://yourdomain.com/some-path"
iOS
Check AASA is reachable:
curl -I https://yourdomain.com/.well-known/apple-app-site-association
Should return 200 OK with Content-Type: application/json.
Check Apple's CDN directly:
https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
Apple doesn't fetch your AASA directly, they go through their own CDN at install time. If you updated the file recently, the CDN can take up to 24 hours to refresh.
Common Mistakes
Testing by typing the URL in the browser. Deep links only trigger when you tap a link from another app — mail, messages, WhatsApp. Typing directly in the browser address bar bypasses interception entirely.
Domain mismatch between links and well-known files. Each platform verifies the domain from the URL in the link — not wherever you think the files should be. Main domain and subdomain are not interchangeable. Pick one, host files there, use that domain in every link.
Play App Signing fingerprint mismatch. Already covered. Worth repeating.
Redirects on the well-known path. If yourdomain.com redirects to www.yourdomain.com, verification fails. Serve .well-known directly, no redirect.
Missing Content-Type: application/json. Serving as text/plain causes silent verification failure on both platforms.
Wrong AASA filename. The file must be named apple-app-site-association — no .json extension. iOS ignores it otherwise.
Works in Xcode, breaks on TestFlight. Xcode direct installs skip AASA verification. TestFlight and App Store installs use Apple's CDN. If it works locally but not on TestFlight, your AASA file is unreachable or wrong from Apple's perspective, not yours.
Quick Reference
| iOS | Android | |
|---|---|---|
| Verification file | apple-app-site-association (no .json) |
assetlinks.json |
| Hosted at | /.well-known/ on link domain |
/.well-known/ on link domain |
| App config | Associated Domains entitlement | intent-filter in AndroidManifest |
| Verified at | Install time (Apple CDN) | Install time |
| Debug tool | curl + Apple CDN check |
adb shell pm get-app-links |
| Classic gotcha | CDN caches up to 24h | Play App Signing changes the fingerprint |
Get the well-known files right, match your domains exactly, and use adb to verify instead of guessing. The rest is plumbing.




