Skip to content
Private betaTreebars is letting teams in from the waitlist.Join the waitlist

Deep links

A push, an in-app button and a notification row can all carry somewhere to go. Sending one is ours; making it land is yours.

Wherever a campaign takes a deep link, we deliver that string to the device exactly as you typed it and hand it to your app. We do not navigate — we cannot; only your app knows that /orders/4417 is a screen. So a deep link is two halves, and the half on this page is the one that has to exist first: a scheme the operating system knows belongs to you, and code that turns a URL into a screen.

Until both halves are in place a deep link is not broken, it is inert. The push arrives, the tap opens your app, and the app opens wherever it was last. Nothing anywhere reports an error, which is why this is worth doing before the first campaign rather than after somebody asks why the link did nothing.

Choosing a scheme

app://orders/latest is what the composer shows in its placeholder, and it is an example rather than a convention. Nothing owns app://, on your handset or anybody else's, and a name that generic is one every other app has also been shown. Pick something that is yours and put your own name in it — myapp://, northwind://.

Better still, where you can: an https:// universal link (iOS) or App Link (Android). Both platforms recommend them, they survive being pasted into a message or an email, and they degrade to your website on a device that does not have the app. They cost a file on your domain and a little more setup, and everything below about routing is the same either way.

Registering a scheme on iOS

1Declare it in Info.plist

One entry, and the reverse-DNS name is a label rather than anything the OS matches on — the scheme is the part that matters.

Info.plistxml
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.myapp.deeplink</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

2Hand the URL to your app

On React Native the plist alone gets you half of it. A link tapped when the app was not running arrives through Linking.getInitialURL(), because the launch options already carry it. A link tapped when the app was running arrives through the url event — and nothing posts that event unless you forward the call:

AppDelegate.swiftswift
func application(
  _ app: UIApplication,
  open url: URL,
  options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
  return RCTLinkingManager.application(app, open: url, options: options)
}

Miss it and deep linking looks like it works, because the case you test first — quit the app, tap the link — is the case that needs nothing. It is the second tap that goes nowhere.

This is the pre-scene lifecycle. If your app declares a UIApplicationSceneManifest, the OS calls scene(_:openURLContexts:) on your scene delegate instead and application(_:open:options:) is never reached. Implement whichever one matches your app — implementing the wrong one is silent.

Registering a scheme on Android

1Add an intent filter

A second filter on your launcher activity, beside the one that is already there. Leave the host out and the scheme matches on its own.

AndroidManifest.xmlxml
<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="myapp" />
</intent-filter>

2Keep the intent current

If your activity is singleTask or singleTop — React Native's template is — getIntent() goes on returning the intent that launched the process unless you replace it. Nothing in React Native's own chain does, so a second link is read as the first one:

MainActivity.ktkotlin
override fun onNewIntent(intent: Intent) {
  setIntent(intent)
  super.onNewIntent(intent)
}

Where a link reaches you

Four ways in, and only the last of them involves the operating system at all. Route them all to one function: the same URL should mean the same screen however it arrived, and a grammar that lives in four places drifts into four grammars.

Arriving from
A push notification tap
Where you read it
deep_link, in the message data your push library hands the tap handler.
Arriving from
An in-app message
Where you read it
readInAppHtmlAction(href) — anything that is not dismiss or a click report is yours to open. It answers null, never a link, for a URL that would run where it is opened, such as javascript:, so open only what it hands you.
Arriving from
A notification centre row
Where you read it
content.deep_link on the row.
Arriving from
The OS handing over a URL
Where you read it
Linking.getInitialURL() and the url event — a link tapped in a browser, a message or a note.
The first three never reach the operating system: the string travels inside a payload and your code reads it. So a scheme you have not registered still works from a push, an in-app button and the notification centre, and fails from everywhere else — which is exactly the way round to be misled. Always test with the last one.

Reading it from a push

deep_link is the key on both platforms: inside the data object on Android, and beside aps at the payload root on iOS, which most client libraries surface as data either way. Report the open first and route second, so an open is counted even if the routing throws.

ts
onNotificationOpenedApp(messaging, (opened) => {
  if (opened?.data) void treebars.trackNotificationOpened(opened.data);
  const link = opened?.data?.deep_link;
  if (typeof link === 'string') openDeepLink(link);
});
On iOS we deliver to Apple directly rather than forwarding through Firebase, so a Treebars push carries no gcm.* keys — and the Firebase messaging SDK ignores taps on any notification that lacks them. If your iOS app reads taps through Firebase, neither handler will fire for our pushes. Read them from UNUserNotificationCenter instead.

Two reserved URLs in an in-app message

A custom-HTML message is drawn in a sandbox with scripts off, so the markup cannot call the SDK. The only channel it has is a URL it tries to open — and the SDK reserves exactly two of them as instructions rather than destinations.

Write this
treebars://dismiss
And the SDK
Closes the message and reports it dismissed.
Write this
treebars://click/1
And the SDK
Reports a press of button 1 and leaves the message up. Optional — see below.
Write this
Any other URL
And the SDK
Reports the press, then hands the URL to your app untouched — myapp://cart, an https:// link, even mailto:.

So your own scheme needs no registration with us and no configuration: it is the default for everything that is not one of those two exact strings. And click/n is not how a click gets counted — every URL above is reported. It exists for the narrower case of a button that should record a press and not navigate anywhere.

treebars://dismiss is worth writing even when you think the message has another way out. Nothing of ours is drawn over custom markup — no close control, no chrome — so a fullscreen message without one cannot be closed at all on iOS, and only by the back button on Android. The composer warns you; this is what it means.

Only these two paths are claimed. The rest of the scheme is treated as an ordinary link, which means a typo — treebars://dismis — is not an error you will be told about. It renders, it is pressed, and nothing happens.

Testing it

Open the URL from outside the app. This is the only test that exercises the registration — a push or an in-app button would pass with no scheme registered at all.

bash
xcrun simctl openurl booted "myapp://orders/latest"
adb shell am start -a android.intent.action.VIEW -d "myapp://orders/latest"

Run each twice — once with the app quit and once with it in the background. They are different code paths on both platforms, and the second is the one that is usually missing.

When a tap does nothing

It works from a cold start and not otherwise
The warm path is not wired up: application(_:open:options:) on iOS, setIntent in onNewIntent on Android. Both are above.
It works from a push and not from the browser
The scheme is not registered, or it is registered under a different one than the campaign sends. A payload never asks the OS anything; a browser does.
Nothing at all on iOS, from a push only
Taps are being read through the Firebase messaging SDK, which ignores notifications with no gcm.* keys. Read them from UNUserNotificationCenter.