A deep link is four platform integrations wearing one URL
Verified, registered and delivered differently; parsing is the easy part.
TL;DR
- One shared URL parser, four platform integrations, and a share link that opened the app's home screen instead of the shared note on iOS for eleven days — because the Universal Links association file was served with the wrong content type.
- Parsing the URL is the shared part and it is 40 lines. Verification, registration and delivery are per platform and they are where every failure was.
- The deliveries differ in kind: Android hands you an
Intentthat may arrive before your navigation graph exists, iOS hands you anNSUserActivityon a cold start and a different callback on a warm one, and the web hands you a route that was already rendered. - What reading on buys: the four delivery shapes, the verification each platform performs, and the one check that would have caught the content-type mistake in CI.
The case this post is about
A share link that should open a note:
https://notes.example.com/n/7f2a1c9e
The shared parser, which is the easy and portable part:
// commonMain/kotlin/deeplink/DeepLink.kt
sealed interface DeepLink {
data class Note(val id: String) : DeepLink
data class Folder(val id: String) : DeepLink
data object Settings : DeepLink
}
fun parseDeepLink(url: String): DeepLink? {
val u = Url(url)
if (u.host != "notes.example.com") return null
return when (u.segments.firstOrNull()) {
"n" -> u.segments.getOrNull(1)?.let { DeepLink.Note(it) }
"f" -> u.segments.getOrNull(1)?.let { DeepLink.Folder(it) }
"settings" -> DeepLink.Settings
else -> null
}
}
And four platform entry points, each a handful of lines that hand the URL to that function.
The symptom
Three reports over eleven days, all from iOS, all the same shape:
"I tapped a link my colleague sent and it just opened the app's main screen."
Which is not a parse failure — a failed parse returns null and we show a "link not recognised"
screen. The app was opening normally, as though the link had never been a link.
And it had not been. iOS was not treating the URL as a Universal Link at all, so Safari opened the web page, and tapping through from there launched the app cold with no link attached:
$ # the association file, which iOS fetches to decide whether to route the domain
$ curl -sI https://notes.example.com/.well-known/apple-app-site-association
HTTP/2 200
content-type: application/octet-stream ← must be application/json
A content type. Served correctly for Android's equivalent file, wrong for iOS's, and the consequence is total: iOS silently declines to associate the domain, and there is no error anywhere on the device.
The second symptom, found while fixing the first, was on Android and intermittent:
java.lang.IllegalStateException: no NavHostController available
at com.example.app.MainActivity.handleIntent(MainActivity.kt:48)
A cold start from a link, where onNewIntent ran before the Compose navigation graph existed.

Why the obvious fix failed
The first fix was to add a custom scheme as a fallback, which does work and is worse:
<data android:scheme="notesapp" android:host="n" />
notesapp://n/7f2a1c9e
A custom scheme has no verification — any app can register notesapp://, and a link in that form
sent to someone without the app installed does nothing at all rather than opening a web page. We
kept it for one internal tool and it is not a fallback for a share link.
The second fix was to test the association file by eye, which is how it had been wrong for eleven days:
$ curl https://notes.example.com/.well-known/apple-app-site-association
{"applinks":{"details":[{"appIDs":["TEAMID.com.example.notes"],"components":[…]}]}}
The content is correct. Reading the body tells you nothing about the header, and the header was the fault.
The third fix was Apple's own validator, which reported success:
$ # the AASA validator, via the developer tools
notes.example.com — association file found and valid
Found and valid. The validator fetches the file and parses it; it does not enforce the content type that iOS itself requires. Two tools agreed the file was fine and the device disagreed, which is the state that cost the eleven days.
The actual mechanism
Four integrations, and the only shared part is parsing.
Android App Links are verified by the system against a JSON file
(/.well-known/assetlinks.json) containing your signing certificate's fingerprint. Verification
happens at install and periodically; the status is inspectable on the device. An unverified domain
falls back to the chooser dialog rather than failing.
iOS Universal Links are verified against apple-app-site-association, fetched over HTTPS at
install and cached by Apple's CDN — so an association change is not immediate, and a wrong file
means the OS never offers your app. There is no on-device status to inspect without a profile,
and no error surface at all.
A custom scheme is not verified by anything. It is a registration, first-come on Android and last-registered-wins behaviour that varies on iOS.
And a web route is not a deep link at all. The URL is the application state; there is no hand-off, no verification, and the "integration" is that your router already handles it.
Then the deliveries differ in shape, which is the second half nobody plans for:
android, cold start an Intent on the Activity, possibly before your UI exists
android, warm onNewIntent, at any point in the lifecycle
ios, cold start an NSUserActivity in the launch options
ios, warm continue userActivity: — a different callback entirely
web the initial route, already rendered
desktop an OS open-url event, or argv on first launch
Six delivery points for four platforms, each arriving at a different moment relative to your navigation being ready. Which explains the Android crash: the link arrived and the code tried to navigate immediately, and "immediately" is before the graph on a cold start.

The fix
Parse in shared code, hold the result until navigation is ready, and verify the platform registration in CI rather than by eye.
// commonMain/kotlin/deeplink/PendingDeepLink.kt
//
// A one-slot holder, because the link can arrive before anything can act on it. The original
// code navigated on receipt, which works warm and crashes cold — and "cold" is the case a
// shared link is almost always in.
class PendingDeepLink {
private val pending = MutableStateFlow<DeepLink?>(null)
/** Called from whichever platform entry point received it, at any time. */
fun offer(url: String) {
val link = parseDeepLink(url)
if (link == null) {
// An unparseable link is a REPORTABLE event, not a silent drop: it usually means
// a link format shipped that this app version does not know, which is a
// compatibility fact worth having.
analytics.log("deeplink.unparsed", "url" to url)
pending.value = DeepLink.Unrecognised
return
}
pending.value = link
}
/**
* Consumed ONCE, by the navigator, when it is ready. A StateFlow rather than a Channel
* deliberately: a cold start can deliver the link before any collector exists, and a
* Channel's buffered-then-conflated semantics made that ordering a coin flip in testing.
*/
@Composable
fun ConsumeWhenReady(navigate: (DeepLink) -> Unit) {
val link by pending.collectAsState()
LaunchedEffect(link) {
val l = link ?: return@LaunchedEffect
navigate(l)
pending.value = null // consumed: a rotation must not re-navigate
}
}
}
// androidMain — both entry points, feeding the same holder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let { pendingDeepLink.offer(it.toString()) } // cold
setContent { AppRoot() } // graph exists after this
}
override fun onNewIntent(intent: Intent) { // warm
super.onNewIntent(intent)
intent.data?.let { pendingDeepLink.offer(it.toString()) }
}
}
And the CI check, which is the part that addresses the eleven days rather than the crash:
#!/usr/bin/env bash
# tools/verify-deeplink-registration.sh
#
# Checks the association files as the OPERATING SYSTEMS do, not as a human reading the body
# does. The content type is what broke us: the file's contents were correct, Apple's own
# validator reported success, and iOS declined the domain because of a header.
set -euo pipefail
HOST="${1:-notes.example.com}"
fail=0
# ── iOS ────────────────────────────────────────────────────────────────────────────────
AASA="https://$HOST/.well-known/apple-app-site-association"
ct=$(curl -sI "$AASA" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-type"{print $2}')
code=$(curl -s -o /dev/null -w '%{http_code}' "$AASA")
[ "$code" = "200" ] || { echo "FAIL ios: $AASA returned $code"; fail=1; }
# The requirement iOS enforces and no validator checks. Charset suffixes are accepted, so
# the match is on the type rather than the whole header.
case "${ct%%;*}" in
application/json) ;;
*) echo "FAIL ios: content-type is '${ct:-<none>}', must be application/json."
echo " iOS silently declines the association — no device error, no log line."
fail=1 ;;
esac
# A REDIRECT is also fatal and also invisible: iOS does not follow one for this file.
redirects=$(curl -s -o /dev/null -w '%{num_redirects}' -L "$AASA")
[ "$redirects" = "0" ] || { echo "FAIL ios: $redirects redirect(s); iOS does not follow them"; fail=1; }
# The app ID must be present, and must be the RELEASE one — a debug bundle id here is a
# file that validates and associates the wrong app.
grep -q "$EXPECTED_APP_ID" <(curl -s "$AASA") \
|| { echo "FAIL ios: $EXPECTED_APP_ID absent from the association file"; fail=1; }
# ── Android ───────────────────────────────────────────────────────────────────────────
AL="https://$HOST/.well-known/assetlinks.json"
curl -s "$AL" | grep -q "$RELEASE_SHA256" \
|| { echo "FAIL android: release signing fingerprint absent from assetlinks.json"; fail=1; }
exit $fail
And the output it produced, run against production at the time:
$ ./tools/verify-deeplink-registration.sh notes.example.com
FAIL ios: content-type is 'application/octet-stream', must be application/json.
iOS silently declines the association — no device error, no log line.
exit 1
One line, the exact fault, from a check that takes 400 ms.
How we proved it
That the content type alone caused it — changed in isolation, with the body byte-identical:
content-type iOS associates the domain? link opens the app?
application/octet-stream no no
application/json yes yes
application/json; charset=utf-8 yes yes
Same file, same bytes, one header. And the association is CDN-cached, so the fix took around 24 hours to reach devices that had already fetched the bad file — which is its own reason to check in CI rather than after a report.
That two tools said it was fine. Worth recording, because "the validator passes" was the reason nobody looked at the header:
Apple's AASA validator association file found and valid
curl, reading the body valid JSON, correct app ID
curl -I, reading the header application/octet-stream ← the fault
That the Android crash was an ordering problem and not a navigation bug. The measurement separates cold from warm, which the stack trace does not:
launch path crashed navigated correctly
warm, onNewIntent 0 / 50 50 / 50
cold, from a link 47 / 50 3 / 50
cold, with PendingDeepLink 0 / 50 50 / 50
Forty-seven of fifty on a cold start, three surviving by scheduling luck — which is why it had reached production looking intermittent.
That a Channel was the wrong holder. We tried it first and the ordering made it unreliable:
link delivered before any collector exists
Channel(RENDEZVOUS) dropped 0 / 50 navigated
Channel(CONFLATED) delivered sometimes 31 / 50
StateFlow delivered always 50 / 50
A cold start always delivers the link before a collector exists, so the holder must retain rather than buffer.
That consuming the link matters. The fixture is a rotation after a deep-link navigation, which
without the pending.value = null re-navigates and loses whatever the user did next:
$ ./gradlew :app:connectedAndroidTest --tests '*RotationDoesNotRenavigate*'
without consume: navigated to Note(7f2a1c9e) twice; user's scroll position lost
with consume: navigated once
What I cannot tell you is how many users hit the iOS failure. Three reported it; the web page opened and worked, so most people who tapped a link got a usable experience and no reason to complain. That is the uncomfortable part of this class — the fallback was good enough to hide the defect, and the only honest statement is that the rate is unknown and was not zero for eleven days.
And a gap the check does not close. The script verifies the files are served correctly. It cannot verify that iOS has fetched the new one, because that is CDN-cached on Apple's side with no inspectable status. After an association change there is a window — we measured roughly a day — in which a device that cached the old file still declines, and nothing on the device or the server shows it.

What this generalizes to
A URL that works everywhere looks like one feature and is four registrations with four verification mechanisms, three of which can fail without producing an error anywhere. The shared part — parsing — is the part that is easy to test and the part that was never broken. For the rest, the useful move is to check the platform's own requirement mechanically rather than trusting a validator or a reading of the file, because a validator checks what it was written to check and the OS checks something slightly different. And whatever receives the link, assume it arrives before anything can act on it.
Related
- There is no shared permission API — another capability where four platforms' lifecycles disagree more than their APIs do.
- One notification API, four delivery systems — the other inbound integration, with the same verified-registration shape.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".