Table: what Apple's associated-domains CDN and Google's Digital Asset Links verifier did with the files of 28 app domains on 30 October 2026. 200 with application/json: cached by Apple for 6 or 1 hours, verified by Google. Served as octet-stream or with no content type: accepted by Apple (7 of 28). Served at the root path only: accepted by Apple (2 of 28). A 301 to another host: followed by Apple (2 of 28), ERROR_CODE_REDIRECT from Google. A 404: SWCERR00101 from Apple, ERROR_CODE_FETCH_ERROR from Google.

A link to your site should open your app when the app is installed and fall back to the web page when it isn't. iOS calls this universal links, Android calls it App Links, and both work the same way at heart: you put a JSON file on your web server that names your app, the app names your domain, and a verifier owned by Apple or Google checks that the two agree. When the check fails nothing warns you. The link just opens the browser.

This guide covers both files, the exact hosting rules, how each OS fetches and caches them, the commands that tell you whether verification passed, and the failures we see most. To see how the rules behave in practice, on 30 October 2026 we fetched the files of 28 well-known app domains (Airbnb, Amazon, Flipkart, GitHub, LinkedIn, Spotify, Swiggy and others) three ways: straight from each site, from Apple's associated-domains CDN, and through Google's Digital Asset Links API, which is what Android's verifier relies on. Our script and raw results are summarised below.

The two files side by side

text
                       iOS: apple-app-site-association          Android: assetlinks.json
URL                    https://<host>/.well-known/               https://<host>/.well-known/assetlinks.json
                         apple-app-site-association (no .json)
format                 JSON object: applinks.details[]          JSON array of statements
names the app by       <TeamID>.<bundle id>                     package_name + SHA-256 of the signing cert
which URLs             in the file: components (path, query,    in the app's manifest: intent-filter data;
                         fragment, exclude)                       Android 15+: also dynamic rules in the file
redirects              "no redirects" (Apple docs)              "no 301 or 302 redirects" (Android docs)
content type           not checked in practice (see below)      application/json
fetched by             Apple's CDN, then the device asks the    the device's verifier at install; on
                         CDN (iOS 14+, macOS 11+)                 Android 15+ also on a schedule
refresh                CDN within 24 h; devices about weekly    Android 15+: up to 7 days; 14 and lower:
                                                                  only on install or update

Two differences matter more than the rest. Apple identifies your app by team and bundle ID, so the same file works for every build your team signs. Google identifies it by the signing certificate's fingerprint, so a debug build, an upload-key build and the Play-signed build are three different identities unless the file lists all of them. And iPhones never read your server directly: since iOS 14 they ask Apple's CDN, so what matters is what the CDN managed to fetch, not what your browser shows.

apple-app-site-association: a file that verifies

Each host needs its own file: example.com and www.example.com are two domains to Apple, and each must serve the file itself. Here is one for a shop app that sends product and order pages to the app but keeps account pages on the web:

json
{
  "applinks": {
    "defaults": { "caseSensitive": false },
    "details": [
      {
        "appIDs": ["ABCDE12345.dev.example.shop"],
        "components": [
          { "/": "/account/*", "exclude": true, "comment": "login and payment stay on the web" },
          { "#": "web", "exclude": true, "comment": "escape hatch: any URL ending in #web opens Safari" },
          { "/": "/p/*", "comment": "product pages" },
          { "/": "/orders/?*", "comment": "an order id of at least one character" },
          { "/": "/invite", "?": { "code": "??????" }, "comment": "six-character invite codes" }
        ]
      }
    ]
  },
  "webcredentials": { "apps": ["ABCDE12345.dev.example.shop"] }
}

How the components are read: iOS walks the list top to bottom and stops at the first match, so put exclusions before the broad rules they carve out. In a pattern, * matches any run of characters (including none), ? matches exactly one, and ?* means at least one. The "?" key matches query items by name, so ?code=?????? only matches six-character codes, and "#" matches the fragment. Matching is case-sensitive unless you set caseSensitive: false, globally in defaults as here or per component. A URL that matches no component opens in Safari.

Of the 28 domains we fetched, 15 use components, 12 still use the older paths array (Uber, Slack, Spotify, Flipkart, Zomato among them) and one, Pinterest, serves a signed PKCS#7 file. Medium even uses the iOS 9 layout where details is an object keyed by app ID. The CDN served every one of them, so old formats still load, but the paths format has no query or fragment matching; write components for anything new.

Size

Apple has limited the file to 128 KB uncompressed since iOS 9.3.1 in 2016, after oversized files caused apps to hang (Apple's App Search revision history, April 2016). The largest file in our sample was LinkedIn's at 95,995 bytes; Amazon's was 77,833 and the smallest, Myntra's, 277. If yours is growing past 50 KB, it is usually listing paths one by one: use wildcards, or substitutionVariables to name a list once and reuse it.

What Apple's CDN actually accepts: our check of 28 domains

Apple's CDN has a public endpoint, https://app-site-association.cdn-apple.com/a/v1/<host>, and it returns the copy devices get, with headers that say where it came from. All 28 domains had a copy there. What the sites themselves served was messier:

text
what the origin served (28 domains)       count  examples                          Apple CDN result
200, application/json at /.well-known/   17     airbnb.com, reddit, instagram     served, Apple-Origin-Format: json
200, octet-stream / no type              5      amazon, notion.so, flipkart,      served, re-labelled application/json
                                                figma, zomato (no header)
404 at /.well-known/, 200 at root        2      github.com, duolingo.com          served, Apple-From: .../apple-app-site-association
301 to another host                      2      spotify.com -> www.spotify.com,   served; Apple-From lists both URLs
                                                www.paytm.com -> paytm.com
403 / no answer to our own client        2      pinterest (403), tiktok (timeout) served: the CDN's fetch got through
of those, signed (PKCS#7) not JSON     1      pinterest                         served, Apple-Origin-Format: pkcs7+json

Four things follow from this, and they are worth knowing before you spend an afternoon on content-type headers:

1. Content type is not what breaks iOS. Seven of the 28 serve the file as application/octet-stream (Amazon, Notion, Flipkart, Figma, and GitHub and Duolingo at the root path) or with no Content-Type header at all (Zomato), and the CDN served all of them, relabelled as application/json. Serve application/json anyway, because Android's verifier requires it for assetlinks.json and it costs nothing.

2. The root path still works. GitHub and Duolingo return 404 at /.well-known/apple-app-site-association and 200 at /apple-app-site-association, and the CDN's Apple-From header shows it took the root copy. New files belong in /.well-known/, but an old root-only file is not your bug.

3. Apple's CDN followed a redirect to another host. spotify.com answers with a 301 to www.spotify.com, and www.paytm.com with a 301 to paytm.com. In both cases the CDN served the file and its Apple-From header lists both URLs. Apple's documentation still says "with no redirects", so don't build on this: it is undocumented, and Google's verifier does the opposite (next section).

4. Testing from your own laptop can mislead you both ways. Pinterest answered our client with a 403 and TikTok didn't answer at all, yet the CDN had both files. Bot rules and firewalls treat Apple's fetcher differently from curl, so check the CDN copy, not only your own request.

The CDN response headers tell you what happened. A good copy carries Apple-From and Apple-Origin-Format; a failed one carries Apple-Failure-Reason:

text
$ curl -sI https://app-site-association.cdn-apple.com/a/v1/www.notion.so
HTTP/1.1 200 OK
Content-Type: application/json
Apple-From: https://www.notion.so/.well-known/apple-app-site-association
Apple-Origin-Format: json
Cache-Control: public, max-age=17269

$ curl -sI https://app-site-association.cdn-apple.com/a/v1/example.com
HTTP/1.1 404 Not Found
Apple-Failure-Details: {"status":"404 Not Found"}
Apple-Failure-Reason: SWCERR00101 Bad HTTP Response: 404 Not Found
Apple-From: https://example.com/.well-known/apple-app-site-association

The CDN's own Cache-Control was max-age=21600 (6 hours, or what was left of it at that edge) for 21 of the 28 domains and 3,600 (1 hour) for the other 7, and 3,600 for a failed fetch. Apple's documentation says the CDN requests your file within 24 hours and that devices check for updates about once a week after the app is installed. In practice: a fixed file reaches new installs within a day, and existing installs within about a week.

assetlinks.json: a file that verifies

json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "dev.example.shop",
    "sha256_cert_fingerprints": [
      "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5",
      "B2:1A:...:upload key or debug key, if you sideload those builds"
    ]
  }
}]

The file is an array, one statement per app. sha256_cert_fingerprints takes several values, and that is where most Android failures come from. If you use Play App Signing, Google re-signs your app with its own key, so the fingerprint from keytool on your upload keystore does not match what users install. The right value, and a ready-made assetlinks snippet, are in Play Console under Release > Setup > App signing. List the app signing key, and also the upload key if you hand out upload-key builds to testers (for example through an install link or QR code).

The fingerprint counts in our sample show how common this is: Uber lists 42, Amazon 33, Slack 24 across its 6 statements, and Notion exactly 1. Big apps keep one fingerprint per key that ever signed a build people still have.

Unlike iOS, Android does not take the URL patterns from the file. They live in the app's intent filter, and verification only says yes or no per host. From Android 15 (with Google services) you can add dynamic rules to the file: a relation_extensions block with dynamic_app_link_components, using the same /, ?, # and exclude keys as Apple's components. Devices fetch it periodically and merge it with the manifest, but the rules can only narrow what the manifest allows, never widen it, and Android 14 and lower ignore them. So declare broad hosts in the manifest and refine paths in the file if most of your users are on 15+.

Redirects: where Google is strict

Google's verifier refuses any redirect on the file, and its API tells you so in plain words. www.paytm.com, which the Apple CDN happily followed above, is the only domain in our 28 that Google failed:

json
$ curl -s 'https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.paytm.com&relation=delegate_permission/common.handle_all_urls'
{
  "maxAge": "599.999999960s",
  "debugString": "... Error: unavailable: Redirect encountered while fetching statements from
    https://www.paytm.com./.well-known/assetlinks.json ...: redirects are disallowed for security
    reasons (NOT_FOLLOWED_MAX_FORWARDS) ...",
  "errorCode": ["ERROR_CODE_REDIRECT"]
}

Its assetlinks.json redirects to paytm.com, so an app that lists www.paytm.com in its intent filter cannot verify that host. The other 27 returned statements, including medium.com, whose server sent our client a 403 but answered Google's fetcher. Errors are cached for 10 minutes (maxAge 600 s), so after a fix you can retest almost at once.

What happens when verification fails changed with Android 12. Since then an https link to a host that is not verified for any app opens in the default browser; before, users got a chooser. And on Android 11 and lower the app became the default handler only if every host in the manifest verified, so one broken subdomain turned off all of them.

Hosting both files: the rules in one place

nginx
# both files: plain 200, JSON, no redirect, no auth, even if the rest of the site redirects
location = /.well-known/apple-app-site-association {
    default_type application/json;
}
location = /.well-known/assetlinks.json {
    default_type application/json;
}
# if you force http -> https or apex -> www, make sure these two paths are excluded
# on every host listed in the app (example.com AND www.example.com each need a 200)

The rules: HTTPS with a certificate that a stock phone trusts; a 200 on the exact host, with no 301, 302 or 308 (that includes http-to-https, apex-to-www and trailing-slash redirects); no login, cookie wall, geo block or bot challenge on these two paths; and valid JSON. If your site lives behind a CDN, add a cache rule so a stale copy isn't served for days after you change the file.

Wiring the app side in an Expo or React Native project

In an Expo app both halves go in app.json; prebuild writes the iOS entitlement and the Android intent filter for you:

json
{
  "expo": {
    "ios": {
      "bundleIdentifier": "dev.example.shop",
      "associatedDomains": ["applinks:shop.example.dev", "webcredentials:shop.example.dev"]
    },
    "android": {
      "package": "dev.example.shop",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "shop.example.dev", "pathPrefix": "/p" },
            { "scheme": "https", "host": "shop.example.dev", "pathPrefix": "/orders" },
            { "scheme": "https", "host": "shop.example.dev", "path": "/invite" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

In a bare React Native app, add the Associated Domains capability in Xcode (it writes com.apple.developer.associated-domains into the .entitlements file, and the App ID needs the Associated Domains capability enabled in the developer portal before the profile includes it), and add the intent filter with android:autoVerify="true" to the activity in AndroidManifest.xml. Either way, the link arrives in JavaScript through Linking.getInitialURL() and the url event, or through your router's linking config.

Checking verification, step by step

1. The server and both verifiers

bash
D=shop.example.dev
# 1. origin: must be 200, no Location header, JSON that parses
curl -sS -o /tmp/aasa -D - "https://$D/.well-known/apple-app-site-association" | grep -iE '^HTTP|^location|^content-type'
python3 -m json.tool /tmp/aasa >/dev/null && wc -c /tmp/aasa      # keep it well under 128 KB
curl -sS -o /tmp/al -D - "https://$D/.well-known/assetlinks.json" | grep -iE '^HTTP|^location|^content-type'

# 2. what iPhones actually get: Apple's CDN copy (Apple-Failure-Reason appears when it failed)
curl -sS -D - "https://app-site-association.cdn-apple.com/a/v1/$D" | grep -iE '^HTTP|^apple-|^cache-control'

# 3. what Android's verifier sees: Google's statement list (errorCode appears when it failed)
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://$D&relation=delegate_permission/common.handle_all_urls" \
  | python3 -c 'import json,sys; j=json.load(sys.stdin); print(len(j.get("statements",[])), j.get("errorCode"))'

Step 2 and step 3 are what devices use. If both are clean and links still open the browser, the problem is in the app: the entitlement, the intent filter or the signing key.

2. Android device or emulator

bash
# the fingerprint Play re-signs with: Play Console > Release > Setup > App signing (copy the SHA-256)
# the fingerprint of a local or aipps.dev-signed APK:
keytool -printcert -jarfile app-release.apk | grep SHA256
apksigner verify --print-certs app-release.apk | grep SHA-256      # same value without colons

# on a device or emulator with the build installed (Android 12+):
adb shell pm set-app-links --package dev.example.shop 0 all          # reset the state
adb shell pm verify-app-links --re-verify dev.example.shop           # ask the verifier again
sleep 20
adb shell pm get-app-links dev.example.shop
#   dev.example.shop:
#     Domain verification state:
#       shop.example.dev: verified        <- anything else means the link opens the browser
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE \
  -d "https://shop.example.dev/p/123"                                # should open the app, no chooser

States you may see instead of verified: none (not checked yet; wait a few minutes and re-run), legacy_failure (an older verifier rejected it), approved or denied (forced by a shell command), and any number of 1024 or higher, which is the device verifier's own error code: check the network and step 3 above. After install, Android's docs say to wait at least 20 seconds before reading the state.

3. iOS Simulator or Mac

bash
# Mac: test your components against a URL without a device (needs root)
sudo swcutil verify -d shop.example.dev -j ./apple-app-site-association -u https://shop.example.dev/p/123

# iOS Simulator: open a link the way Notes or Messages would
xcrun simctl openurl booted "https://shop.example.dev/p/123"

# skip the CDN while the file is still changing (development-signed builds only):
#   entitlement  applinks:shop.example.dev?mode=developer
#   device       Settings > Developer > Associated Domains Development: on

swcutil verify runs Apple's own matcher against a local copy of your file, so you can test components before you deploy them. Remember that a universal link has to be tapped, from another app or another domain: typing the URL into Safari, or following a link within the same domain in Safari, opens the web page by design.

When it still opens the browser

text
symptom                                        cause                                          fix
Android: get-app-links says 1024 or higher     verifier could not fetch or match the file     run step 3 above; read errorCode
Android: ERROR_CODE_REDIRECT                   http->https, apex->www or trailing-slash       serve a 200 on that exact host
                                               redirect on /.well-known/                      (www.paytm.com in our check)
Android: verified on your phone, not on        assetlinks has the upload key, Play re-signs   add the App signing key SHA-256
  installs from Play                           with the app signing key                       from Play Console (keep both)
Android: one host fails, all links break       Android 11 and lower verify all hosts or none  give every host in the intent
  on Android 11                                in the manifest                                filter its own file, or split them
Android: file fixed, phones still open web     Android 14 and lower re-verify only on         reinstall to test; ship an update
                                               install or update; 15+ up to 7 days            for users on 14 and lower
iOS: link opens Safari on a fresh install      CDN has no copy yet or a failed one            check Apple-Failure-Reason; the CDN
                                                                                              refetches within 24 hours
iOS: works when tapped in Notes, not when      typed into Safari's address bar, or a          expected: a link must be tapped,
  pasted in Safari                             same-domain link inside Safari                 and from another domain
iOS: worked, then stopped for one user         they chose "Open in Safari" from the banner;   long-press the link > Open in
                                               iOS remembers that per domain                  <app> to switch it back
both: in-app browser of a chat or social app   the host app opens the URL in its own          a smart banner or "Open in app"
  shows the web page                           web view instead of handing it to the OS       button on the web page
iOS: appIDs mismatch                           team ID prefix differs from the signing team   use <TeamID>.<bundle id> from the
                                               (an old App ID prefix or a second team)        Membership page, not the bundle id

Most of these are invisible until a real install from the store, which is why they are worth checking on every release that touches signing, domains or redirects. Signing keys are the usual Android culprit; our guide to code signing for React Native teams: certificates, profiles and upload keys explains which key signs what. For testing deep links on real devices before a store release, install a test build from a QR code gets an APK or ad hoc build onto a phone in a minute; add that build's fingerprint to assetlinks.json first.

Build your iOS and Android apps on aipps.dev: the iOS build is signed with your team's App ID and Associated Domains entitlement, the Android build with the key you choose, and an install link or QR code puts it on a test device so you can run the checks above before release.

Open the console

Sources: Apple, Supporting associated domains (hosting, "no redirects", CDN within 24 hours, weekly device checks) and the com.apple.developer.associated-domains entitlement (alternate modes developer, managed, developer+managed), read 30 October 2026; Apple, applinks and components reference; Apple App Search programming guide revision history (128 KB limit, April 2016); Android Developers, Configure website associations (hosting rules, Play App Signing fingerprint, dynamic rules for Android 15+) and Verify Android App Links (adb commands, states, 20 seconds, 7-day propagation on Android 15+), read 30 October 2026; Android 12 behaviour changes (web intent resolution); Expo docs, iOS universal links and Android App Links (associatedDomains, intentFilters). Our check: on 30 October 2026 we fetched /.well-known/apple-app-site-association, /apple-app-site-association and /.well-known/assetlinks.json for 28 domains without following redirects, the same domains from app-site-association.cdn-apple.com, and Google's statements:list API, and recorded status, headers, format and counts.