Deep links
Open your own iOS or Android app from a short link, with the store as fallback.
A deep link is an ordinary short link on your custom domain that opens your own app on the right screen. If the app isn't installed yet, it goes to the App Store or Google Play first, and the app still opens on that screen after installing:
go.acme.com/order/42/_/openFinds the link it came from: a deferred deep link
Computers, and phones in browsers that ignore app links, get the link's destination like any short link.
Pro, custom domains
Deep links need the Pro plan and a custom domain such as go.acme.com; the workspace's own
subdomain can't carry them. Every link on that domain opens the app where it's installed, so
keep links that shouldn't on another domain.
Before you start
- The Pro plan and a custom domain, connected and verified on the Domains page.
- Your app's IDs: the Apple Team ID and bundle ID, the Android package name and signing-key fingerprints. Step 1 says where to find each.
- An app update you can ship. The domain goes into the app itself (step 2), and only versions built with it open links.
- Your App Store and Google Play pages, for people who don't have the app yet.
- Two small pieces of app code: one for a link that opens the app, one for the first launch after an install. Copy them from your app's code.
Set it up
Connect the app to your domain
In the dashboard go to Domains, then Deep links → Set up on your domain. Enter your iOS Team ID and Bundle ID, your Android package name and SHA-256 certificate fingerprints, or both. tinbo then serves the two files the phones check:
https://go.acme.com/.well-known/apple-app-site-associationhttps://go.acme.com/.well-known/assetlinks.json
They claim every path on the domain for your app except the root (go.acme.com/ keeps its root redirect) and the paths tinbo uses itself (those starting with /_ and /.).
Add the domain to your app, and ship an update
In Xcode, open Signing & Capabilities, add Associated Domains, and add:
applinks:go.acme.comOnly the new version opens links
This is part of the app itself, so it works only in a version built with it. Release an update first: until people install it, a tap opens the browser and goes the "no app" way (the store page then just offers Open or Update). The phone also reads tinbo's files mainly when the app is installed or updated, so if you change the settings in step 1 later, the change reaches people with their next app update.
Create the links
Create a link on the domain. Set its destination to the page it stands for, such as https://acme.com/product/42. Your app decides which screen a destination means. Then add device targeting for people without the app:
- iOS → your App Store page,
https://apps.apple.com/app/… - Android → your Play Store page,
https://play.google.com/store/apps/details?id=com.acme.app
Use your own app's store pages. For Android this matters: tinbo hands the link to the app only through the Play Store page whose id= is your package name. On a deep-link domain the link form says when a store page is missing, and Add store links adds both rules (the Google Play one filled in from your package name; paste your App Store URL into the other).
Ask tinbo where to go
When a link opens the app, the phone skips tinbo: your app gets the short link, not the destination. Send the short link to /_/open on your domain. tinbo replies with the destination and counts the click:
curl -X POST https://go.acme.com/_/open \
-H 'content-type: application/json' \
-d '{"link":"https://go.acme.com/promo","platform":"ios","deviceType":"mobile"}'{
"clickId": "req_9fQ2…",
"link": {
"id": "lnk_…",
"domain": "go.acme.com",
"slug": "promo",
"url": "https://acme.com/product/42"
}
}Open the screen for link.url. Two cases to handle:
linkisnull: nothing to open. The slug doesn't exist, or the link is expired, disabled or password-protected. Open the short link in an in-app browser (SFSafariViewController, Custom Tabs); it shows what the web would.- A destination your app has no screen for: every link on the domain opens the app, including ones that point elsewhere. Open
link.urlin an in-app browser.
Your app's code, further down, does this in SwiftUI, Kotlin and React Native, together with the first-launch check.
Deferred deep links
"Deferred" means the app wasn't installed when the link was tapped: the person installs it first, and the app should still open on the link's screen. The store sits in the middle, so the link has to survive the install. Your app checks once, on its first launch. tinbo can't tell a first launch from any other, so the app keeps track itself, for example with a flag it saves the first time it runs.
Android: exact
go.acme.com/order/42, no app yet/_/opentinbo puts the short link into the Play Store URL as the install referrer (referrer=link%3D…). After the install, Google hands that text to your app through the Install Referrer API. The app reads link from it and sends it to /_/open. Nothing is guessed. A referrer you put on the store URL yourself is kept, and then tinbo adds nothing.
iPhone: two workarounds
The App Store passes nothing from a web page to the app it installs; Apple offers no way to do it. So tinbo uses two workarounds, in this order:
go.acme.com/order/42, no app yet/_/open{} to /_/open- The clipboard. An iPhone without your app gets a small page before the App Store: "Looks like you don't have the app yet", one button, Get the app from the App Store*, and a note at the bottom: tinbo copies the link so the app can open the right screen once it's installed. On first launch the app reads the clipboard. iOS then asks the person whether your app may paste from Safari (the Allow Paste prompt). If they allow it, the app has the exact link. If they say no, or copied something else meanwhile, there's no link; that's fine.
- The IP match. With no link, the app sends
{}. tinbo looks for the most recent store visit from the same IP address on your domain in the last hour and answers with that link, once. It's a best guess: people behind one IP (an office, a café, a mobile carrier, a VPN) can get each other's link, and it misses anyone who took longer than an hour. Apple's App Store rules restrict identifying devices from signals like the IP address; if you'd rather not rely on this, don't send{}, and the app simply opens its home screen.
The "Get the app" page is tinbo's own: no scripts or content from you besides the link and your store URL. It also shows to iPhone users who have your app when their browser ignored the app link (Instagram, TikTok and other in-app browsers). For them "you don't have the app" is wrong, but the button still works: the App Store offers Open, though the app then starts on its home screen (it isn't a first launch, so it doesn't look at the clipboard).
The first-launch check for both platforms is in your app's code, next.
Your app's code
Your app does two things, and the code below does both: it asks /_/open where a link goes when one opens the app, and on the first launch after an install it looks for the link the person came from (the Play Store referrer on Android, the clipboard on iPhone). router.open(url) stands for your own navigation: it turns a destination into a screen, and opens the web in an in-app browser when no screen fits.
import SwiftUI
let shortDomain = "go.acme.com"
struct Opened: Decodable {
struct Link: Decodable { let url: String }
let link: Link?
}
/// Where a short link goes, from tinbo. `link` nil asks "which link brought
/// me here?": the IP match, for a first launch with nothing to paste.
@MainActor func destination(for link: URL?) async -> String? {
var req = URLRequest(url: URL(string: "https://\(shortDomain)/_/open")!)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
// platform and deviceType: the device in your analytics
var body = [
"platform": "ios",
"deviceType": UIDevice.current.userInterfaceIdiom == .pad ? "tablet" : "mobile",
]
if let link { body["link"] = link.absoluteString }
req.httpBody = try? JSONEncoder().encode(body)
guard let (data, _) = try? await URLSession.shared.data(for: req) else { return nil }
return (try? JSONDecoder().decode(Opened.self, from: data))?.link?.url
}
@main
struct AcmeApp: App {
@AppStorage("checkedInstallLink") private var checkedInstallLink = false
@StateObject private var router = Router() // your navigation
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(router)
// 1. A short link opened the app
.onOpenURL { url in
checkedInstallLink = true // opened by a link: nothing to look for
Task { if let dest = await destination(for: url) { router.open(dest) } }
}
// 2. The first launch after installing from a link
.task {
// a launch by a link delivers onOpenURL right after the first frame
try? await Task.sleep(for: .milliseconds(300))
guard !checkedInstallLink else { return }
checkedInstallLink = true
// hasURLs doesn't ask; reading .url shows the Allow Paste prompt
let pasted = UIPasteboard.general.hasURLs ? UIPasteboard.general.url : nil
let link = pasted?.host == shortDomain ? pasted : nil
if let dest = await destination(for: link) { router.open(dest) }
}
}
}
}How clicks are counted
- App installed: each tap is one click, counted when the app calls
/_/openwith the link. - App not installed: the click is counted when the person goes to the store. The app's first-launch call doesn't count it again, with the link or with
{}, as long as it comes from the same IP within the hour. - Clicks from the app show in analytics like any other and count toward the plan's tracked clicks. Their device comes from the
platformanddeviceTypeyour app sends (see the endpoint).
The /_/open endpoint
POST https://<your domain>/_/open needs no API key: your app knows only the domain.
| Body | Answer |
|---|---|
{ "link": "<the short link>" } | That link's destination (with the rest of the path, for a prefix link) |
{} | The store visit from this IP within the hour, used once; otherwise link: null |
Send these with either, so analytics shows the right device. An app's HTTP client often doesn't say which phone it runs on (React Native and OkHttp on Android say only okhttp):
| Field | Values | Without it |
|---|---|---|
platform | "ios" or "android" | iOS when the user agent says so, otherwise Android |
deviceType | "mobile" or "tablet" | A tablet when the user agent says so, otherwise a phone |
Geo targeting and A/B tests apply. Device targeting doesn't: once the app is open, the phone is already where it should be.
| Status | When |
|---|---|
400 | link isn't a URL on this domain, or platform / deviceType has another value |
404 | The domain has no deep links set up |
503 | tinbo can't look the link up; retry |