Link Doctor
Find out why a link does not open your app, and what to change. A public web tool, a CLI that runs in CI, and one endpoint behind both.
A link that does not open the app fails silently. There is no error, nothing in your logs, and the two platforms fail in different ways for different reasons. Link Doctor looks at your domain from the outside, exactly as Apple and Google look at it, and reports what is wrong with the fix for each thing it finds.
It needs no account, no SDK and no key, and it works on any domain whether or not Vouch hosts it.
The web tool
vouch.dev/link-doctor takes a domain and, optionally, the identities of your apps:
| Field | Why it helps |
|---|---|
| Domain | Required. The host your links are served from, for example links.example.com. |
| Bundle identifier and Team ID | Without them the iOS file is fetched and parsed but not matched against your app, so an app ID mistake cannot be caught. |
| Package name | The same for Android: the file is read, but nothing is compared. |
| SHA-256 fingerprint | Lets the check compare what the file declares against the certificate you say signs your release. Any formatting is accepted. |
| Paths | Each is tested against the path patterns the two files declare, per platform. |
The report leads with one sentence, then the findings, each with its fix, then exactly what was fetched for each platform: status, content type, whether a redirect happened, and what the file declares.
The CLI
vouch-doctor is a single Go binary that runs the same checks locally. It talks to no service of
ours unless you point it at one, so it works on a laptop with no network access to Vouch and on a
build machine with no credentials.
go install github.com/vouch-dev/vouch/services/vouchd/cmd/vouch-doctor@latestvouch-doctor check --host links.example.com \
--bundle-id app.roam.ios --team-id ABCDE12345 \
--package app.roam --fingerprint A1:B2:C3:...Flags
| Flag | What it does |
|---|---|
--host | The domain to check. Required. |
--bundle-id | iOS bundle identifier. |
--team-id | Apple Team ID, the ten-character prefix on your app ID. |
--package | Android package name. |
--fingerprint | SHA-256 signing certificate fingerprint, in any common formatting. |
--path | A path to test against the declared patterns. Repeat for more than one. |
--ipa, --apk, --aab | Read the identities out of a build instead of trusting the flags above. |
--json | Print the whole report as JSON instead of text. |
--warnings-as-errors | Treat warning-severity findings as failures. |
--api | Run the check through a Vouch API instead of locally, for example a self-hosted one. |
Reading identities out of a build
Given a build, vouch-doctor reads what the binary actually claims rather than what you told it:
--ipatakes the team ID, the bundle identifier and theapplinks:domains from the entitlements in the embedded provisioning profile.--apktakes the package name and theautoVerifyintent filters from the binary manifest, and the SHA-256 fingerprint from the signing certificate inMETA-INF.--aabtakes the same from the protobuf manifest.
A fingerprint read from a build is the single most useful thing the tool does. If Play App Signing
is on, Google re-signs your release with its own key, so an assetlinks.json listing only your
upload key's fingerprint verifies in testing and fails for every real user. The fingerprint that
belongs in the file is the app signing certificate one from Play Console, not the upload one.
Exit codes
| Code | Meaning |
|---|---|
0 | Nothing of error severity was found. |
1 | At least one error-severity finding, or any finding with --warnings-as-errors. |
2 | The tool itself could not run: bad flags, an unreadable build, no network. |
A 2 is not a verdict on your domain. It means nothing was checked.
In CI
Run it after the build, against the artefact you are about to ship, and a release that would break links fails before it reaches anyone. In a GitHub Actions workflow:
- name: Check deep links
run: |
go install github.com/vouch-dev/vouch/services/vouchd/cmd/vouch-doctor@latest
vouch-doctor check --host links.example.com \
--aab build/app-release.aab \
--path /posts/123 --path /invite/abc \
--warnings-as-errorsUse --json when something else needs to read the result. Finding codes are stable, so a script can
branch on them.
The API
Both surfaces call one endpoint. It is public and needs no authentication, and it is rate limited by address:
curl -X POST https://api.vouch.dev/v1/doctor \
-H "Content-Type: application/json" \
-d '{"host":"links.example.com",
"bundle_id":"app.roam.ios","team_id":"ABCDE12345",
"android_package":"app.roam","paths":["/posts/123"]}'The report carries summary, ok, findings, and an apple and google block holding what was
actually fetched: status, content_type, redirected, declared, fingerprints and patterns.
Every finding carries code, severity, platform, title, detail, fix and often docs.
What each check means
| Code | Meaning | Usual fix |
|---|---|---|
unreachable | The file could not be fetched at all: DNS, TLS or a timeout. | Nothing verifies until the host answers. Check DNS and that the certificate covers this exact host. |
not_https | The file is served over plain HTTP. | Both platforms fetch over HTTPS only. Serve it over TLS. |
redirected | The request was redirected. | Neither platform follows a redirect for these files. Serve the file at the well-known path on this host, apex included. |
not_found | The well-known path returns 404. | Publish /.well-known/apple-app-site-association and /.well-known/assetlinks.json. Vouch generates and serves both for a verified domain. |
unexpected_status | Something other than 200 or 404, for example 403 or 500. | A 403 usually means an auth rule or a WAF is in front of the path. Exclude /.well-known/ from it. |
wrong_content_type | The file is served as something other than JSON. | Serve it as application/json. The Apple file has no extension, so many servers guess wrong. |
not_json | The body is not valid JSON. | Validate the file. A trailing comma is the usual culprit. |
byte_order_mark | A byte-order mark sits in front of the opening brace. | Save the file as UTF-8 without a BOM. It is invisible in an editor and fatal to the parser. |
too_large | The file exceeds Apple's size limit. | Apple refuses a file over 128 KB. Narrow the path patterns rather than listing every path. |
tls_invalid | The certificate did not validate. | Both platforms refuse an invalid chain. Check the intermediate certificates on this host specifically. |
app_id_missing | The file does not list your app. | Add the app ID to appIDs in the Apple file, or the package to assetlinks.json. |
app_id_not_team_prefixed | An app ID is missing its Team ID prefix. | An iOS app ID is TEAMID.bundle.identifier, not the bundle identifier alone. |
package_missing | assetlinks.json does not list your package. | Add a statement for the package under target.package_name. |
fingerprint_missing | No SHA-256 fingerprint is declared. | Add the app signing certificate's fingerprint from Play Console. |
fingerprint_mismatch | The declared fingerprint is not the one you gave. | Almost always an upload key published where the Play App Signing key belongs. Take the fingerprint from Play Console under App signing. |
relation_missing | The statement does not grant delegate_permission/common.handle_all_urls. | Add that relation; without it the statement grants nothing. |
apple_cdn_missing | Apple's mirror has no copy of your file. | iOS reads Apple's CDN, not your server. It can take a day after the file first appears. |
apple_cdn_stale | Apple's mirror holds an older copy than your server. | The old file is the one deciding on devices. Ship a new app version or wait for Apple to re-fetch. |
path_not_covered | A path you gave matches no declared pattern. | Add a pattern that covers it, or accept that the link will open the browser. |
apex_missing | www serves the file and the apex does not. | Serve it on every host you put in links, apex included. Redirecting the apex does not count. |
www_only | The same thing the other way round. | Same fix: both hosts, no redirect. |
android_paths_unknown | Every path is ticked for Android because the host is granted. | assetlinks.json has no path patterns: which paths open the app is decided by the intent filters inside the build. Pass the build to the command-line tool to have those read. |
no_ios_identity / no_android_identity | No identity was given for that platform, so those checks were skipped. | Pass the bundle identifier and Team ID, or the package name, to have the file matched against your app. |
Severity, and why iOS is judged differently
An error means links are broken now. A warning means they will break, or are fragile.
That distinction is why a fault in how your server delivers the Apple file drops to a warning when Apple's mirror is holding a good copy: iOS devices read the mirror, not your server, so those links open today. It still needs fixing, because the mirror re-fetches from your server and that fetch is what will hit the fault. Android has no mirror, so the same fault there is an error.
The three questions people ask when they leave Firebase
Why does the link open the browser instead of the app? Almost always the association file: a redirect, a 404 on the apex, the wrong content type, or a path pattern that does not cover the URL. The report names which.
Which fingerprint goes in assetlinks.json? The SHA-256 of the app signing certificate, the
one Play Console shows under App signing, not your upload key. --aab reads the right one straight
out of the build.
Why does it work on Android but not iOS? The two platforms read different files, with different path rules, and iOS reads through Apple's CDN rather than your server. A fix that is live on your server can still be missing from the copy the phone sees.
See also Domains and association files for the hourly check that runs against every registered domain, and Migrating from Firebase Dynamic Links.