Skip to main content

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 typeExampleWhen it fires
Custom schemeyourapp://products/42App is installed; opened from another app, a web page, or a notification.
App Linkhttps://link.paylisher.com/c/AbC123App 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

  1. The OS delivers the link to your launcher Activity as an Intent.
  2. On a cold start the SDK handles it automatically (it observes Activity creation); for warm starts you forward onNewIntent in one line.
  3. 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).
  4. The SDK automatically attaches attribution (campaign_key, deeplink_key, jid) to every event for that session, and emits its own deep-link analytics events.
  5. The SDK calls your lambda with the parsed PaylisherDeepLink so 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.

#StepWhat you do
1Configure the platformDeclare your custom scheme + App Link domain in AndroidManifest.xml.
2Set up the SDKAdd deepLinkConfig and call PaylisherAndroid.setup() in Application.onCreate.
3Receive linksRegister onDeepLink { … }, and forward warm-start links with one line in onNewIntent.
4Route to a screenTurn the parsed link into navigation — pick the tab, push the nested screen.
5TestFire 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>

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.com is the production link domain for all Paylisher short/App links. Until the asset-links file lists your app you can still test handling via adb (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.

PropertyTypeDefaultDescription
customSchemesList<String>[]Custom URL schemes you handle, e.g. listOf("yourapp").
universalLinkDomainsList<String>[]Domains handled as App Links, e.g. listOf("link.paylisher.com").
authRequiredDestinationsList<String>[]Destinations requiring an authenticated user before navigation (see Auth-gated destinations).
autoRegisterCampaignKeysBooleantrueWhen a link carries a campaign key, the SDK registers campaign_key + deeplink_key for the session automatically (see Automatic attribution).
captureDeepLinkEventsBooleantrueEmit the business event Deep Link Opened (plus Deep Link Completed / Deep Link Cancelled for auth-gated links) when a link is handled.
captureDeepLinkDiagnosticsBooleanfalseAlso 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.
autoHandleDeepLinksBooleantrueProcess links automatically on arrival (also enables cold-start auto-handling).
debugLoggingBooleanfalseVerbose deep-link logs (use in debug builds).
pendingDeepLinkTimeoutLong300_000How long an auth-gated link waits for login before expiring (milliseconds).

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 after setup). Because they are set before the launcher Activity is created, cold-start links are delivered to them automatically.

Your lambda receives a parsed object:

PropertyTypeDescription
urlStringThe original incoming URL.
schemeStringURL scheme (e.g. yourapp, https).
destinationStringThe destination/path extracted from the URL.
resolvedDestinationStringThe effective destination — derived from resolved campaign data when available, else destination.
pathSegmentsList<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.
parametersMap<String, String>All query parameters.
campaignKeyNameString?The campaign key extracted from the link, if any.
jidString?Journey ID for attribution.
campaignIdString?Campaign id from ?campaign_id= / ?campaign=, if present.
sourceString?Traffic source from ?source= / ?utm_source=, if present.
authParamRequiredBooleantrue when the link itself carries ?auth=required (see Auth-gated destinations).
campaignDataPaylisherResolvedDeepLinkPayload?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) or resolvedDestination (a ready-made single string). campaignData is handy when you want to show the campaign's title or read its metaData.

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.

ParameterAliasesEffect
keyNamekey, kCampaign key → resolved into campaignData.
jidJourney ID, attached to attribution.
sourceutm_sourceTraffic source, attached to attribution.
campaign_idcampaignCampaign id.
authauth=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?) { /* … */ }
}

Entry pointWhat 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:

  1. Parse the URL into a destination (a plain value — which tab, which nested screen).
  2. Store that destination in one place your UI observes.
  3. 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, resolvedDestination is enough. Real apps usually need a tab plus a nested stack (e.g. Products → detail → reviews), and a small destinationFor() 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:

PropertyMeaning
campaign_keyThe campaign key of the link that opened the session.
deeplink_keySame value, under the conventional deeplink_key name.
jidJourney ID, for cross-event attribution.
journey_sourceHow 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 (default true).

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().


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

MethodWhat 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).
MethodWhat 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

MethodWhat 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

SymptomCause / fix
Custom scheme does nothingScheme 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 directlyThe 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 ignoredMissing onNewIntent override, or the Activity isn't launchMode="singleTop" (so onNewIntent isn't called).
onDeepLink never fires on cold startRegister the lambda in Application.onCreate (before the Activity is created), and keep autoHandleDeepLinks = true.
Events don't carry campaign_keyThe 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 matchesDeferred not enabled, the app launched offline on first run, or the attribution window elapsed.