# 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.json` file, 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-known` path
    
*   `Content-Type: application/json`
    
*   No authentication
    

#### iOS: `apple-app-site-association`

> ⚠️ The file must be named exactly `apple-app-site-association` — no `.json` extension. iOS ignores it entirely if you name it `apple-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+):**

```json
{
  "applinks": {
    "details": [
      {
        "appID": "TEAMID.com.your.bundleid",
        "components": [
          {
            "/": "/*",
            "comment": "Matches all paths"
          }
        ]
      }
    ]
  }
}
```

**Legacy (pre-iOS 13, still supported):**

```json
{
  "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`

```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:**

```bash
adb shell pm get-app-links com.your.appid
```

Look for the `Signatures` field.

**From your keystore:**

```bash
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](https://support.google.com/googleplay/android-developer/answer/9842756).
> 
> 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:

```plaintext
applinks:yourdomain.com
```

If you want to cover both your main domain and all subdomains with a single entitlement, use a wildcard:

```plaintext
applinks:*.yourdomain.com
```

Note that the wildcard covers subdomains only, it does not match the root domain (`yourdomain.com`) itself.

In `App.entitlements`:

```xml
<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:

```xml
<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:

```xml
<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.

```typescript
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:**

```bash
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:**

```bash
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:**

```bash
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:**

```bash
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:**

```plaintext
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.
