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:

PersonTaps go.acme.com/order/42
App installed
PhoneOpens your app, not the browser
Your appSends the link to /_/open
tinboAnswers with the destination and counts the click
Order 42 in your app
No app yet
tinboDevice targeting: App Store or Google Play
PersonInstalls the app and opens it
Your app

Finds the link it came from: a deferred deep link

Order 42 in your app
What a tap on a deep link does

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-association
  • https://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.com

Only 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 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:

  • link is null: 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.url in 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" 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

PersonTaps go.acme.com/order/42, no app yet
tinboSends them to Google Play, the short link in the install referrer
StoreInstalls the app
Your appFirst launch: reads the referrer, sends the link to /_/open
Order 42 in your app
Android: the link rides along through Google Play

tinbo 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:

PersonTaps go.acme.com/order/42, no app yet
tinboShows "Get the app": its button copies the short link
StoreThe App Store installs the app
Your appFirst launch: a URL on the clipboard? iOS asks "Allow Paste?"
Allowed
Your appSends the pasted link to /_/open
Order 42 in your app
Refused, or nothing copied
Your appSends {} to /_/open
tinboLooks for a store visit from the same IP in the last hour
Order 42 if one matched, otherwise the home screen
iPhone: the clipboard first, the IP match when there's nothing to paste
  1. 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.
  2. 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 /_/open with 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 platform and deviceType your app sends (see the endpoint).

The /_/open endpoint

POST https://<your domain>/_/open needs no API key: your app knows only the domain.

BodyAnswer
{ "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):

FieldValuesWithout 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.

StatusWhen
400link isn't a URL on this domain, or platform / deviceType has another value
404The domain has no deep links set up
503tinbo can't look the link up; retry

Test it

On this page