Vouch
Links

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:

FieldWhy it helps
DomainRequired. The host your links are served from, for example links.example.com.
Bundle identifier and Team IDWithout them the iOS file is fetched and parsed but not matched against your app, so an app ID mistake cannot be caught.
Package nameThe same for Android: the file is read, but nothing is compared.
SHA-256 fingerprintLets the check compare what the file declares against the certificate you say signs your release. Any formatting is accepted.
PathsEach 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@latest
vouch-doctor check --host links.example.com \
  --bundle-id app.roam.ios --team-id ABCDE12345 \
  --package app.roam --fingerprint A1:B2:C3:...

Flags

FlagWhat it does
--hostThe domain to check. Required.
--bundle-idiOS bundle identifier.
--team-idApple Team ID, the ten-character prefix on your app ID.
--packageAndroid package name.
--fingerprintSHA-256 signing certificate fingerprint, in any common formatting.
--pathA path to test against the declared patterns. Repeat for more than one.
--ipa, --apk, --aabRead the identities out of a build instead of trusting the flags above.
--jsonPrint the whole report as JSON instead of text.
--warnings-as-errorsTreat warning-severity findings as failures.
--apiRun 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:

  • --ipa takes the team ID, the bundle identifier and the applinks: domains from the entitlements in the embedded provisioning profile.
  • --apk takes the package name and the autoVerify intent filters from the binary manifest, and the SHA-256 fingerprint from the signing certificate in META-INF.
  • --aab takes 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

CodeMeaning
0Nothing of error severity was found.
1At least one error-severity finding, or any finding with --warnings-as-errors.
2The 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-errors

Use --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

CodeMeaningUsual fix
unreachableThe 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_httpsThe file is served over plain HTTP.Both platforms fetch over HTTPS only. Serve it over TLS.
redirectedThe 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_foundThe 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_statusSomething 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_typeThe 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_jsonThe body is not valid JSON.Validate the file. A trailing comma is the usual culprit.
byte_order_markA 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_largeThe file exceeds Apple's size limit.Apple refuses a file over 128 KB. Narrow the path patterns rather than listing every path.
tls_invalidThe certificate did not validate.Both platforms refuse an invalid chain. Check the intermediate certificates on this host specifically.
app_id_missingThe 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_prefixedAn app ID is missing its Team ID prefix.An iOS app ID is TEAMID.bundle.identifier, not the bundle identifier alone.
package_missingassetlinks.json does not list your package.Add a statement for the package under target.package_name.
fingerprint_missingNo SHA-256 fingerprint is declared.Add the app signing certificate's fingerprint from Play Console.
fingerprint_mismatchThe 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_missingThe statement does not grant delegate_permission/common.handle_all_urls.Add that relation; without it the statement grants nothing.
apple_cdn_missingApple'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_staleApple'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_coveredA path you gave matches no declared pattern.Add a pattern that covers it, or accept that the link will open the browser.
apex_missingwww 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_onlyThe same thing the other way round.Same fix: both hosts, no redirect.
android_paths_unknownEvery 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_identityNo 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.

On this page