Deep Links (Android)
Paylisher turns every link you create in the dashboard into a measurable, routable entry point into your app. The SDK receives the incoming intent, resolves its campaign data from the backend, stamps attribution onto your analytics events, and hands you a parsed object so you can navigate the user to the right screen — with almost no code on your side.
The SDK handles three kinds of links:
| Link type | Example | When it fires |
|---|---|---|
| Custom scheme | yourapp://products/42 | App is installed; opened from another app, a web page, or a notification. |
| App Link | https://link.paylisher.com/c/AbC123 | App is installed; opened from Chrome/email/social — verified, no chooser dialog. |
| Deferred deep link | (resolved on first launch) | App was not installed when the link was tapped; the destination is delivered on the first launch after install. |
This guide targets Paylisher Android (LITE) 1.1.4. For the iOS counterpart, see the iOS Deep Link Integration guide.
How it works
- The OS delivers the link to your launcher
Activityas anIntent. - On a cold start the SDK handles it automatically (it observes
Activitycreation); for warm starts you forwardonNewIntentin one line. - The SDK parses the URL, and — if it carries a campaign key — calls the Paylisher backend to resolve the campaign (title, type, target URLs, metadata, journey id).
- The SDK automatically attaches attribution (
campaign_key,deeplink_key,jid) to every event for that session, and emits its own deep-link analytics events. - The SDK calls your lambda with the parsed
PaylisherDeepLinkso you can navigate.
You write the lambda and the one-line onNewIntent. Everything else is automatic.
Integration steps
Follow these in order. Steps 1–3 are the core integration; step 4 makes navigation real; step 5 verifies it.
| # | Step | What you do |
|---|---|---|
| 1 | Configure the platform | Declare your custom scheme + App Link domain in AndroidManifest.xml. |
| 2 | Set up the SDK | Add deepLinkConfig and call PaylisherAndroid.setup() in Application.onCreate. |
| 3 | Receive links | Register onDeepLink { … }, and forward warm-start links with one line in onNewIntent. |
| 4 | Route to a screen | Turn the parsed link into navigation — pick the tab, push the nested screen. |
| 5 | Test | Fire links with adb and confirm the right screen opens. |
Optional, add when you need them: auth-gated screens, deferred deep links. Attribution is automatic — nothing to write.
Prerequisites
Deep links require AndroidManifest.xml configuration. These are platform requirements (not SDK calls) and cannot be done from code.
1. Register intent filters
Add the filters to your launcher (or deep-link target) Activity. Use launchMode="singleTop" so warm-start links arrive in onNewIntent.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Custom scheme: yourapp://... -->
<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="yourapp" />
</intent-filter>
<!-- App Link: https://link.paylisher.com/... (verified, opens with no chooser) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="link.paylisher.com" />
</intent-filter>
</activity>
2. App Link verification (assetlinks.json)
For App Links to open without a chooser, Android verifies a Digital Asset Links file at https://link.paylisher.com/.well-known/assetlinks.json that authorizes your app's signing certificate. Send your package name and your signing certificate's SHA-256 fingerprint to Paylisher so it can be added:
# Get the SHA-256 fingerprint of your signing key
keytool -list -v -keystore <your.keystore> -alias <your-alias>
link.paylisher.comis the production link domain for all Paylisher short/App links. Until the asset-links file lists your app you can still test handling viaadb(see Testing); verification only affects the chooser-free auto-open behaviour.
Quick start
The recommended integration is two touch points. Attribution, campaign resolution, journey tracking, manager setup and cold-start handling are all automatic.
// 1) Application.onCreate — configure, set up, and register a lambda (no interface to implement).
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
val config = PaylisherAndroidConfig(apiKey = "phc_YOUR_PROJECT_KEY").apply {
deepLinkConfig = PaylisherDeepLinkConfig(
customSchemes = listOf("yourapp"),
universalLinkDomains = listOf("link.paylisher.com"),
authRequiredDestinations = listOf("wallet", "account"), // optional
)
}
PaylisherAndroid.setup(this, config)
PaylisherAndroid.onDeepLink { deepLink, requiresAuth ->
// Simplest case. For a tab + nested screens, see "Routing the user to the right screen".
if (!requiresAuth) navigate(deepLink.resolvedDestination)
}
}
}
// 2) Forward warm-start links from your Activity (cold start is automatic).
class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
PaylisherAndroid.handleDeepLink(intent)
}
}
That's the whole integration. The sections below explain each piece and the alternatives (the interface, deferred links, auth gating).
Configuration — PaylisherDeepLinkConfig
Set this on the config's deepLinkConfig before PaylisherAndroid.setup(). Setup then initializes the deep-link manager automatically.
| Property | Type | Default | Description |
|---|---|---|---|
customSchemes | List<String> | [] | Custom URL schemes you handle, e.g. listOf("yourapp"). |
universalLinkDomains | List<String> | [] | Domains handled as App Links, e.g. listOf("link.paylisher.com"). |
authRequiredDestinations | List<String> | [] | Destinations requiring an authenticated user before navigation (see Auth-gated destinations). |
autoRegisterCampaignKeys | Boolean | true | When a link carries a campaign key, the SDK registers campaign_key + deeplink_key for the session automatically (see Automatic attribution). |
captureDeepLinkEvents | Boolean | true | Emit the business event Deep Link Opened (plus Deep Link Completed / Deep Link Cancelled for auth-gated links) when a link is handled. |
captureDeepLinkDiagnostics | Boolean | false | Also emit verbose diagnostic / funnel events (deeplink_received, deeplink_resolved, deeplink_resolve_failed, deeplink_navigation) for debugging the pipeline (e.g. inspecting why a campaign key didn't resolve). Off by default to keep your event stream clean. |
autoHandleDeepLinks | Boolean | true | Process links automatically on arrival (also enables cold-start auto-handling). |
debugLogging | Boolean | false | Verbose deep-link logs (use in debug builds). |
pendingDeepLinkTimeout | Long | 300_000 | How long an auth-gated link waits for login before expiring (milliseconds). |
Receiving deep links
Lambda API (recommended)
Register lambdas on PaylisherAndroid. No interface, no setHandler.
// Called for every received deep link.
PaylisherAndroid.onDeepLink { deepLink, requiresAuth ->
if (!requiresAuth) navigate(deepLink.resolvedDestination)
}
// Optional: called when an auth-gated destination needs a logged-in user.
PaylisherAndroid.onDeepLinkRequiresAuth { deepLink, complete ->
if (isUserLoggedIn) complete(true) else showLogin { success -> complete(success) }
}
// Optional: called when a link can't be parsed/handled.
PaylisherAndroid.onDeepLinkFailed { url, error ->
Log.w("DeepLink", "failed: $url", error)
}
Register the lambdas in
Application.onCreate(right aftersetup). Because they are set before the launcherActivityis created, cold-start links are delivered to them automatically.
The PaylisherDeepLink object
Your lambda receives a parsed object:
| Property | Type | Description |
|---|---|---|
url | String | The original incoming URL. |
scheme | String | URL scheme (e.g. yourapp, https). |
destination | String | The destination/path extracted from the URL. |
resolvedDestination | String | The effective destination — derived from resolved campaign data when available, else destination. |
pathSegments | List<String> | Normalized route segments: [host] + path for custom schemes, path for App Links. So yourapp://products/42/reviews and https://link.paylisher.com/products/42/reviews both give ["products","42","reviews"]. Use this to route nested screens — no URL re-parsing, identical on Android & iOS. |
parameters | Map<String, String> | All query parameters. |
campaignKeyName | String? | The campaign key extracted from the link, if any. |
jid | String? | Journey ID for attribution. |
campaignId | String? | Campaign id from ?campaign_id= / ?campaign=, if present. |
source | String? | Traffic source from ?source= / ?utm_source=, if present. |
authParamRequired | Boolean | true when the link itself carries ?auth=required (see Auth-gated destinations). |
campaignData | PaylisherResolvedDeepLinkPayload? | Full campaign object resolved from the backend (title, type, androidUrl, metaData, …), or null. |
For navigation, prefer
pathSegments(already normalized, cross-platform — you don't re-parse the URL) orresolvedDestination(a ready-made single string).campaignDatais handy when you want to show the campaign'stitleor read itsmetaData.
What the link can carry
When you build a link in the dashboard — or a campaign points one at your app — these query parameters are parsed automatically into the object above. You don't read the query string yourself; you just react to the parsed fields.
| Parameter | Aliases | Effect |
|---|---|---|
keyName | key, k | Campaign key → resolved into campaignData. |
jid | — | Journey ID, attached to attribution. |
source | utm_source | Traffic source, attached to attribution. |
campaign_id | campaign | Campaign id. |
auth | — | auth=required gates this one link behind login (see Auth-gated destinations). |
Example a campaign would send: yourapp://products/42?keyName=SUMMER25&source=push
Interface alternative
If you prefer an interface, implement PaylisherDeepLinkHandler and register it with PaylisherDeepLinkManager.getInstance().setHandler(this):
class MainActivity : ComponentActivity(), PaylisherDeepLinkHandler {
override fun paylisherDidReceiveDeepLink(deepLink: PaylisherDeepLink, requiresAuth: Boolean) { /* … */ }
override fun paylisherDeepLinkRequiresAuth(deepLink: PaylisherDeepLink, completion: (Boolean) -> Unit) { /* … */ }
override fun paylisherDeepLinkDidFail(url: String, error: Exception?) { /* … */ }
}
Forwarding links to the SDK
| Entry point | What you do |
|---|---|
| Cold start (app launched by the link) | Nothing — the SDK handles the launch intent automatically via an ActivityLifecycleCallbacks integration (requires autoHandleDeepLinks = true, the default). |
| Warm start (app already running) | Forward onNewIntent once: PaylisherAndroid.handleDeepLink(intent). Android has no lifecycle callback for onNewIntent, so this one line is required. |
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // keep the Activity's current intent in sync
PaylisherAndroid.handleDeepLink(intent)
}
Routing the user to the right screen
The SDK parses the link and calls your lambda — turning it into a screen is your app's job. The callback gives you a PaylisherDeepLink; you decide which tab/screen it maps to and navigate there.
The reliable pattern is three small steps:
- Parse the URL into a destination (a plain value — which tab, which nested screen).
- Store that destination in one place your UI observes.
- Render it: your UI reacts to the stored destination.
Doing it this way means the same code handles cold start, warm start, and deferred links — the callback only updates state, and the UI applies whatever destination is current as soon as it's on screen.
1. Parse the URL into a destination
Map the deep link's normalized pathSegments to your screens. Keep this a pure function (no UI), so you can reuse it everywhere:
// yourapp://home -> Home
// yourapp://products -> Products list
// yourapp://products/42 -> Products -> detail "42"
// yourapp://products/42/reviews -> Products -> detail "42" -> reviews
// yourapp://wallet -> Wallet
sealed class Destination {
object Home : Destination()
data class Products(val productId: String? = null, val showReviews: Boolean = false) : Destination()
object Wallet : Destination()
object Profile : Destination()
}
fun destinationFor(dl: PaylisherDeepLink): Destination {
// pathSegments is already normalized by the SDK — no URL re-parsing, and identical on Android & iOS.
// ["products","42","reviews"] for both yourapp://products/42/reviews and the App Link form.
val segs = dl.pathSegments
return when (segs.firstOrNull()?.lowercase()) {
null, "home" -> Destination.Home
"products" -> {
val id = segs.getOrNull(1) ?: dl.parameters["id"]
Destination.Products(id, showReviews = segs.getOrNull(2)?.lowercase() == "reviews")
}
"wallet" -> Destination.Wallet
"profile" -> Destination.Profile
else -> Destination.Home // unknown link → safe default
}
}
2. Store the destination where UI and SDK both reach it
Keep the navigation state in a singleton (or a shared ViewModel). The SDK lambda writes to it; the UI observes it. Register the lambda in Application.onCreate so cold-start links are delivered before the UI exists.
object AppRouter {
val destination = MutableStateFlow<Destination>(Destination.Home)
fun go(dl: PaylisherDeepLink) { destination.value = destinationFor(dl) }
}
// Application.onCreate, right after PaylisherAndroid.setup(...)
PaylisherAndroid.onDeepLink { deepLink, requiresAuth ->
if (!requiresAuth) AppRouter.go(deepLink) // auth-gated links: see below
}
3. Render it in your UI
Your navigation reacts to destination. With Compose, collect the state and select the tab / push the nested screens:
@Composable
fun RootScreen() {
val dest by AppRouter.destination.collectAsState()
when (val d = dest) {
is Destination.Home -> HomeTab()
is Destination.Products -> ProductsTab(productId = d.productId, showReviews = d.showReviews)
is Destination.Wallet -> WalletTab()
is Destination.Profile -> ProfileTab()
}
}
Why parse the URL yourself instead of just using
resolvedDestination? For a single flat screen,resolvedDestinationis enough. Real apps usually need a tab plus a nested stack (e.g. Products → detail → reviews), and a smalldestinationFor()gives you exactly that.
Cold start (app was closed)
Because the lambda is registered in Application.onCreate and only writes state, a cold-start link sets AppRouter.destination before your first screen is composed. The UI reads the current destination when it appears — so the user lands directly on the target screen, no extra code.
If your app starts on a login screen, don't navigate while logged out: keep the destination stored and apply it right after a successful login.
fun onLoginSuccess() {
// the stored destination is applied as the main UI appears; nothing else to do.
showMainUi()
}
This is the same flow the auth-gate uses (next section) — store the target, complete after login.
Automatic attribution
This is the part you don't have to write. When a deep link carrying a campaign key arrives, the SDK attaches attribution to every analytics event for that session, so you can build the user's journey/path from the deep link onward:
| Property | Meaning |
|---|---|
campaign_key | The campaign key of the link that opened the session. |
deeplink_key | Same value, under the conventional deeplink_key name. |
jid | Journey ID, for cross-event attribution. |
journey_source | How the journey started (e.g. deeplink). |
Session-scoped by design. These properties are attached only to events in the session the link opened. If the app is killed and later opened organically (no deep link), they are not carried over — so organic sessions stay clean. Controlled by
autoRegisterCampaignKeys(defaulttrue).
No Paylisher.register(...) calls are needed on your side; disable it with autoRegisterCampaignKeys = false to manage it manually.
Auth-gated destinations
Some screens should only open for a logged-in user. A link is treated as auth-gated when either of these is true:
requiresAuth = (destination is in authRequiredDestinations) OR (link carries ?auth=required)
Option 1 — by destination (config). List the always-protected destinations once:
deepLinkConfig.authRequiredDestinations = listOf("wallet", "account")
Every link to wallet/account is then gated, regardless of how it was built.
Option 2 — per link (?auth=required). A single link can opt in without any config change — useful when a campaign wants one specific link (e.g. an "apply" or "checkout" link) to require login:
yourapp://campaigns/summer/apply?auth=required
In both cases the SDK invokes your auth lambda instead of navigating immediately:
PaylisherAndroid.onDeepLinkRequiresAuth { deepLink, complete ->
if (isUserLoggedIn) {
complete(true) // already authenticated → proceed
} else {
presentLogin { success -> complete(success) } // after login, the SDK resumes to the destination
}
}
A typical pattern: if the user is already logged in (and the app wasn't killed), call complete(true) for a direct hand-off; if not (e.g. a cold start from a killed state), present login and call complete(true) once they succeed. After complete(true), the SDK finishes the pending link and calls your onDeepLink lambda with requiresAuth == false.
You can also drive this manually with PaylisherDeepLinkManager.getInstance().completePendingDeepLink() / cancelPendingDeepLink().
Deferred deep links
A deferred deep link is one tapped before the app was installed: the user clicks a Paylisher link, lands on Google Play, installs, and on first launch the SDK recovers the original destination via install attribution.
Enable it on the config and check after setup():
val config = PaylisherAndroidConfig(apiKey = "phc_YOUR_PROJECT_KEY").apply {
deepLinkConfig = PaylisherDeepLinkConfig(customSchemes = listOf("yourapp"))
deferredDeepLinkConfig = PaylisherDeferredDeepLinkConfig.forProduction().withEnabled(true)
}
PaylisherAndroid.setup(this, config)
if (PaylisherDeferredDeepLinkManager.isSetup()) {
PaylisherDeferredDeepLinkManager.getInstance().check(
onSuccess = { deepLink -> navigate(deepLink.resolvedDestination) },
onNoMatch = { /* organic install — nothing to do */ },
onError = { error -> Log.e("DeepLink", "deferred check failed", error) },
)
}
The deferred-attribution backend is hosted and managed by Paylisher — you don't configure an endpoint. Attribution matching is keyed on a privacy-preserving device fingerprint; including the Advertising ID (with user consent) improves match accuracy.
Testing
With the app installed on a device/emulator, trigger links via adb:
# Custom scheme
adb shell am start -W -a android.intent.action.VIEW -d "yourapp://products/42" com.yourcompany.yourapp
# Campaign key (resolves campaign data from the backend)
adb shell am start -W -a android.intent.action.VIEW -d "yourapp://products?keyName=YOUR_CAMPAIGN_KEY" com.yourcompany.yourapp
# App Link
adb shell am start -W -a android.intent.action.VIEW -d "https://link.paylisher.com/c/YOUR_CAMPAIGN_KEY" com.yourcompany.yourapp
# Check App Link verification status
adb shell pm get-app-links com.yourcompany.yourapp
Set deepLinkConfig.debugLogging = true to see deep-link logs in Logcat. To verify automatic attribution, open a campaign link, then fire any event and confirm it carries campaign_key / deeplink_key / jid.
API reference
The lambda API and warm-start helper are static methods on PaylisherAndroid. The manager methods are on PaylisherDeepLinkManager.getInstance().
Setup & handlers
| Method | What it does |
|---|---|
PaylisherAndroid.onDeepLink { deepLink, requiresAuth -> } | Registers a lambda for received deep links. No interface needed. |
PaylisherAndroid.onDeepLinkRequiresAuth { deepLink, complete -> } | Registers a lambda for auth-gated destinations. Call complete(true/false). |
PaylisherAndroid.onDeepLinkFailed { url, error -> } | Registers a lambda for parse/handle failures. |
PaylisherDeepLinkManager.getInstance().setHandler(handler) | Interface-based alternative (PaylisherDeepLinkHandler). |
Forwarding links
| Method | What it does |
|---|---|
PaylisherAndroid.handleDeepLink(intent: Intent?) | Handles a deep link from an Intent — call in onNewIntent. |
PaylisherDeepLinkManager.getInstance().handleIntent(intent) | Lower-level intent handler (what handleDeepLink delegates to). |
PaylisherDeepLinkManager.getInstance().handleUrl(url: String) | Handles a deep link from a raw URL string. |
Pending (auth) & deferred
| Method | What it does |
|---|---|
PaylisherDeepLinkManager.getInstance().completePendingDeepLink() | Completes a pending auth-gated link after successful login. |
PaylisherDeepLinkManager.getInstance().cancelPendingDeepLink() | Cancels a pending link (emits a cancellation event). |
PaylisherDeferredDeepLinkManager.getInstance().check(onSuccess, onNoMatch, onError) | Checks for a deferred (install-attribution) deep link on first launch. |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Custom scheme does nothing | Scheme not declared in the manifest intent-filter, or customSchemes doesn't include it. Activity must be exported="true" with the BROWSABLE category. |
| App Link shows a chooser instead of opening directly | The assetlinks.json for link.paylisher.com doesn't yet authorize your package + SHA-256. Send them to Paylisher; verify with adb shell pm get-app-links <package>. |
| Warm-start link ignored | Missing onNewIntent override, or the Activity isn't launchMode="singleTop" (so onNewIntent isn't called). |
onDeepLink never fires on cold start | Register the lambda in Application.onCreate (before the Activity is created), and keep autoHandleDeepLinks = true. |
Events don't carry campaign_key | The link had no campaign key, or autoRegisterCampaignKeys is false. Attribution is also session-scoped — it won't appear in organic (no-deep-link) sessions. |
| Deferred link never matches | Deferred not enabled, the app launched offline on first run, or the attribution window elapsed. |