Android SDK
Native Android deep linking, deterministic deferred deep linking, attribution, identity, events, and campaign link generation.
Jump to
- Android: minimum SDK 21.
- Dependency:
com.deeplinkly:deeplinkly-android:1.3.0. - Permission: the SDK declares only
INTERNET. - API key: create or copy one in App Settings.
Using Flutter?
Install flutter_deeplinkly instead. It wraps this SDK, so both integrations run the same native Android code.
dependencies {
implementation 'com.deeplinkly:deeplinkly-android:1.3.0'
}Use verified HTTPS App Links in production and retain a custom scheme for browser fallback and development.
<activity android:name=".MainActivity" android:launchMode="singleTop">
<!-- Production App Links -->
<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="links.yourapp.com" />
</intent-filter>
<!-- Browser fallback / development custom scheme -->
<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" android:host="deeplink" />
</intent-filter>
</activity>
<application ...>
<meta-data android:name="com.deeplinkly.sdk.api_key"
android:value="your_api_key_here" />
<!-- Optional. Comma-separate multiple hosts. -->
<meta-data android:name="com.deeplinkly.sdk.link_domains"
android:value="links.yourapp.com" />
</application>Replace links.yourapp.com, yourapp, and the API key with your project values. launchMode="singleTop" makes an already-running activity receive the link through onNewIntent.
App Links are the production path
Without the verified HTTPS filter, every link detours through a browser. In-app browsers that block intent:// fallbacks may never reach your installed app. autoVerify applies only to HTTP and HTTPS links.
Which links the SDK claims
Redirected links carry a click_id, which the SDK handles regardless of scheme. Direct App Links contain only the host and first path segment, so the SDK uses link_domains to decide which HTTPS hosts it may resolve.
- Custom-scheme links without a
click_idare ignored. Routes such asyourapp://settings/notificationsremain yours. - HTTP(S) links are resolved by code. With
link_domainsset, only the listed hosts are resolved; without it, every HTTPS link your app handles is eligible.
Set link_domains for mixed-purpose domains
If your app also claims a marketing site, set this allowlist or a URL such as https://www.yourapp.com/pricing could be resolved as the Deeplinkly code pricing.
Android verifies your domain against its hosted Digital Asset Links file when the app is installed.
Deeplinkly serves https://<your-link-domain>/.well-known/assetlinks.jsonafter App Settings contains both your package name andSHA-256 signing certificate fingerprint. If either value is missing, the endpoint returns 404 and verification fails silently.
keytool -list -v -keystore <your-keystore> -alias <your-alias> | grep SHA256
curl https://links.yourapp.com/.well-known/assetlinks.json
adb shell pm get-app-links <your.package.name>The final command should report verified. When Play App Signing is enabled, use the fingerprint from Play Console → Setup → App signing, not the upload keystore: Google re-signs the APK distributed to users.
class App : Application() {
override fun onCreate() {
super.onCreate()
Deeplinkly.init(this)
}
}Initialization is deliberately explicit: the SDK does not merge anandroidx.startup initializer or a content provider into your manifest. init is idempotent, so repeated calls are harmless.
Inspecting SDK state
Deeplinkly.isEnabled // true after init when the API key was found
Deeplinkly.version // the native SDK version, for example "1.3.0"isEnabled is false before init and remains false when the manifest API key is missing or unreadable. In that state, reporting and deep-link handling are no-ops, but getDeeplinklyId() remains available because it is generated locally.
Attach one listener for resolved links and forward every warm link from activities that can receive one.
Deeplinkly.setDeepLinkListener { link ->
// Always called on the main thread.
Log.d("app", "click=${link.clickId} params=${link.params}")
router.open(link.params)
}override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Deeplinkly.onNewIntent(this, intent)
}onNewIntent is required for warm links
Android's ActivityLifecycleCallbacks has no onNewIntent hook, so the SDK cannot automate this override. Cold-start intents are captured automatically and must not be forwarded again.
Links are never lost
If a link resolves before the listener is attached, it is stored in a persistent queue that survives process death. Offline resolutions retry on the next launch. If your listener throws, the link stays queued for another attempt.
Payload
class DeeplinklyDeepLink {
val clickId: String? // null when the backend does not recognise the click
val params: Map<String, Any?> // the link's own parameters
val source: String // deep_link | deep_link_fallback | install_referrer
val raw: Map<String, Any?> // the payload exactly as resolved
}params is populated from the backend response or, when the backend cannot be reached, directly from the URL. Your router can therefore use the same read path in both cases.
The original destination survives a Play Store install without a permission or user gesture.
Deferred linking works through the Google Play Install Referrer. When a visitor taps a Deeplinkly link, installs the app from Play, and opens it for the first time, the listener receives the resolved link withsource = "install_referrer".
Test through Google Play
Install Referrer data is unavailable to sideloaded builds. Use a Play internal testing track for a real deferred-link test.
Deeplinkly.getInstallAttribution() // first-touch attribution, write-once
Deeplinkly.getDeeplinklyId() // stable install id
Deeplinkly.setUserId("user_123") // your id, reported as custom_user_idThe fields a conversion is matched on once it reaches Meta's Conversions API or Google's enhanced conversions.
Deeplinkly.setUserData(
userId = "user_123",
email = "ada@example.com",
phoneNumber = "+441234567890",
firstName = "Ada",
lastName = "Lovelace",
city = "London",
country = "GB",
) // false if any field was malformed, in which case nothing was storedEvery field is optional and each call merges, so you can supply an email at sign-up and an address at checkout. A malformed field rejects the whole call — nothing is stored — so you never have to guess which of the values took.
Supply only what your own privacy policy and consent flow allow; the SDK cannot know what you told your users. These fields survive a REDUCED downgrade, because attribution levels gate what the SDK observes about a device and an email someone typed into your app is not an observation. At NONE nothing is sent.
dateOfBirth:YYYY-MM-DD.gender:"m"or"f"— the only two values Meta'sgeaccepts. Anything else is refused rather than coerced.country: ISO-3166-1 alpha-2, for example"US".- Every field has a maximum length, enforced before anything is stored.
Your own identifiers
customData carries identifiers Deeplinkly does not name — typically product-analytics ids such as a Mixpanel distinct id or a CleverTap id. Attach a new identifier anytime; no app release required.
Deeplinkly.setUserData(
userId = "user_123",
customData = mapOf(
"mixpanel_distinct_id" to "d-8837",
"clevertap_id" to "ct-4412",
),
)Up to 10 entries, keys up to 64 characters and values up to 256. Anything larger rejects the whole call, exactly as one bad typed field does.
Erasing it
Deeplinkly.clearUserData() // erases everything setUserData and setUserId recorded
Deeplinkly.setUserId(null) // clears only the idThis is not merely “stop sending”: the next enrichment reports each previously-set field as empty, which the service reads as null this column. The erasure is re-sent until it is delivered, so calling it on a device that is offline still takes effect once it is not.
Deeplinkly.logEvent(
"purchase",
mapOf("order_id" to "ord_42", "amount" to 49.99, "currency" to "USD"),
) { accepted -> /* optional */ }- Event name: maximum 64 characters.
- At most 25 custom parameters. Reserved
_dl_*keys do not count and cannot be supplied by the app. - Parameter key: maximum 64 characters.
- String value: maximum 256 characters.
ListandMapvalues become compact JSON; the 256-character limit applies to the encoded value.- Numbers and booleans retain their types end to end;
49.99is not converted to a string.
Deeplinkly.logPurchase(
value = 49.99,
currency = "USD",
orderId = "ord_42",
quantity = 1,
productId = "sku_9",
) { accepted -> /* optional */ }Not a separate pipeline: it sends the event named purchase with value and currency set, and everything true of logEvent — the retry queue, the parameter limits, the device block — is true of this too.
logEvent is untyped, so left to themselves one app sends revenue and another sends "USD 49.99". value and currency are what Meta's Conversions API and Google's enhanced conversions both key off, so this typed wrapper feeds both without a conversion forwarder having to guess.
Rejected, sending nothing, if the value is negative or not finite (a refund is a different event, not a negative purchase), the currency is not three letters, the quantity is negative, or parameters contains any of the keys this method sets.
Pass orderId
It is what Google deduplicates conversions on, and how you reconcile a forwarded conversion against your own records.
Deeplinkly.generateLink(
content = DeeplinklyContent(
canonicalIdentifier = "product/sku_42",
title = "Pro Plan",
metadata = mapOf("plan" to "pro"),
),
options = DeeplinklyLinkOptions(
channel = "email",
feature = "upgrade_campaign",
),
) { result ->
if (result.success) share(result.url!!)
}Deeplinkly.setTrackingEnabled(false) // off entirely
Deeplinkly.setAttributionLevel(AttributionLevel.REDUCED) // middle ground| Level | What is sent |
|---|---|
| FULL | Everything. This is the default. |
| REDUCED | Drops screen geometry, model, CPU, local IP, WebView user agent, advertising ID, and Android ID. Keeps coarse campaign-reporting context. |
| MINIMAL | Only the install id, app build, and the link being reported on. |
| NONE | No enrichment. Links still resolve and deliver. |
Each level is a strict subset of the level above it. Deep link delivery works at every level, including NONE. Resolving a link never sends device description signals at any level.
To apply a restriction before any app code runs, set the manifest default:
<meta-data android:name="com.deeplinkly.sdk.attribution_level"
android:value="reduced" />setTrackingEnabled(false) always wins and behaves as NONE. The SDK does not do probabilistic fingerprint matching; matches are deterministic, using the click id or install referrer.
Hashing identifiers on the device
Off by default. With it on, the email, phone number and names given to setUserData are SHA-256 hashed before they are sent, so plaintext never reaches Deeplinkly.
Deeplinkly.setPIIHashingEnabled(true) // SHA-256 on device before sending
Deeplinkly.isPIIHashingEnabled() // off unless you turned it onOnly those four are hashed. Gender, country and date of birth are not: their value ranges are small enough that a digest is reversed by enumerating them, so hashing them would be protection in appearance only.
It costs attribution quality
A digest is computed once, under one normalisation, and advertising destinations disagree about phone formatting — so a conversion forwarded to a destination whose rules differ will not match, and the service can no longer re-derive per destination because the value it would need is gone. Turn it on when a compliance requirement says plaintext must not reach a processor, not by default.
Hashing happens at send time rather than in the store, so the switch is reversible.
Advertising ID is opt-in
The SDK compiles against Google's Advertising ID library but does not bundle it, because that library would merge the AD_ID permission into every host app. Add it yourself only if your policy and consent flow allow advertising-id collection:
dependencies {
implementation 'com.google.android.gms:play-services-ads-identifier:18.2.0'
}Without this optional dependency, deep linking and deterministic attribution continue to work. ACCESS_NETWORK_STATE is also not declared; if your app already has it, the SDK reports connection type, otherwise that field is omitted.
Deeplinkly.onForeground()
Deeplinkly.shutdown()onForeground() optionally reports an app-open and asks the persistent link queue to process immediately. The SDK already observes activity transitions, and duplicate foreground calls are rate-limited, so this is mainly useful to a framework bridge that owns a more precise foreground signal.
shutdown() detaches the deep-link listener, stops queue processing, and cancels the SDK's background scope. It is intended for final host/framework teardown, not ordinary activity destruction. The SDK cannot be initialized again in the same process after shutdown, so application integrations should normally leave the process-wide SDK running.
Deeplinkly.setDebugMode(true) // verbose logcat under the "Deeplinkly" tag# Simulate a deep link
adb shell am start -a android.intent.action.VIEW \
-d "https://links.yourapp.com/abc123"
# Simulate an install referrer
adb shell am broadcast -a com.android.vending.INSTALL_REFERRER \
--es "referrer" "click_id=test123"- Test cold start with the process killed and warm start with the app already running or backgrounded.
- If App Links do not open the app, verify the hosted file, content, signing fingerprint, and
pm get-app-linksresult. - If warm links disappear, confirm every receiving activity forwards
onNewIntentand usessingleTop. - If an event is rejected, check its name, parameter count, reserved keys, key length, and encoded value length.
Source repository
The native SDK, sample app, tests, and canonical README are open source.
View android_deeplinkly on GitHub →