SDK
Android SDK
Add App Links, deferred deep links through Google Play and AppGallery, install attribution and in-app events to an Android app, from Kotlin or Java.
The SDK opens the right screen when someone taps an Appy link, keeps the link through a fresh install from Google Play, Huawei AppGallery or another store, tells you which link brought each install, records events and builds share links. Package to.appy.sdk, part of the Enterprise plan.
Requirements
- Android 5.0 (API 21) or newer, Java 8 compatible bytecode. Kotlin projects need Kotlin 1.9 or newer; Java projects work too.
- An app registered with Appy on the Enterprise plan, and its publishable key (
appy_pk_…).
| Footprint | |
|---|---|
| Source | 9 Kotlin files, about 1,150 non-blank lines |
| Dependencies | com.android.installreferrer:installreferrer:2.2 and the Kotlin standard library. No AndroidX, Play Services, HMS Core, OkHttp or JSON library. |
| Dex added to an app | About 75 KB after R8 with the whole public API kept, of which about 4 KB is the Install Referrer library. |
| Requests per cold start | One POST /v1/sdk/open; on a first launch it waits at most 3 s for the install referrer. |
| Not included | No ContentProvider auto-initialization, no WorkManager jobs, no reflection, no advertising ID, no UI. |
Install the SDK
The SDK is provided during Enterprise onboarding as a release AAR. Copy it to app/libs/ and add it with its one dependency, because a local AAR does not bring its dependencies along. No ProGuard or R8 rules are needed.
dependencies {
implementation(files("libs/appy-sdk-1.0.0.aar"))
implementation("com.android.installreferrer:installreferrer:2.2")
}The manifest merges android.permission.INTERNET and two <queries> entries that keep the Play Store’s install referrer service and AppGallery’s attribution provider visible on Android 11 and newer.
Register your app
Android verifies App Links by downloading https://<subdomain>.appy.to/.well-known/assetlinks.json. Appy publishes that file once your app is registered with its package name and the SHA-256 fingerprints of every certificate that signs builds users install.
| Build | Certificate | Where to find the SHA-256 |
|---|---|---|
| From Google Play with Play App Signing | Google’s app signing key | Play Console > your app > App integrity > App signing |
| Internal builds you sign | Your upload key | keytool -list -v -keystore upload-keystore.jks -alias upload |
| Debug builds | ~/.android/debug.keystore | ./gradlew :app:signingReport |
With Play App Signing, store builds are signed by Google’s key, not your upload key. Registering only the upload key is the most common reason links open the app in local builds but not in the Play build.
curl https://api.appy.to/v1/apps \
-H "Authorization: Bearer $APPY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Shop",
"subdomain": "acme",
"android": {
"packageName": "com.acme.shop",
"sha256CertFingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}
}'The 201 response contains linkDomain, for example acme.appy.to, and publishableKey. Add certificates later with PATCH /v1/apps/{appId} and the complete android object; the file changes within about a minute. It must answer 200 with application/json, without redirects.
Add intent filters
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<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" />
<data android:host="acme.appy.to" />
</intent-filter>
<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="acme" />
</intent-filter>
</activity>- The
autoVerifyfilter is the App Link: once verified, taps onhttps://acme.appy.to/...open the app instead of the browser. - The custom scheme covers in-app browsers that never hand links to Android. Appy’s page then opens
acme://...?appy_click_id=..., which the SDK recognizes. Custom-scheme URLs withoutappy_click_idare left to your code. - With
singleTaskorsingleTop, links that arrive while the app runs go toonNewIntent.
Initialize the SDK
class ShopApplication : Application() {
override fun onCreate() {
super.onCreate()
Appy.init(this, AppyConfig("appy_pk_...", listOf("acme.appy.to")).apply {
logLevel = if (BuildConfig.DEBUG) AppyLogLevel.DEBUG else AppyLogLevel.ERROR
})
}
}public class ShopApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
AppyConfig config = new AppyConfig("appy_pk_...", Collections.singletonList("acme.appy.to"));
config.setLogLevel(BuildConfig.DEBUG ? AppyLogLevel.DEBUG : AppyLogLevel.ERROR);
Appy.init(this, config);
}
}AppyConfig | Default | Meaning |
|---|---|---|
publishableKey | required | appy_pk_…; another prefix is logged as an error. |
linkDomains | empty | Hosts whose URLs are Appy links. The first one is used by buildLink. |
logLevel | ERROR | NONE, ERROR, WARNING, INFO, DEBUG; Logcat tag Appy. |
openDeferredDeepLinks | false | Let the SDK open a deferred link’s in-app deep link itself. |
deferredDeepLinkTimeoutMillis | 5000 | How long the first launch waits before the listener receives Failed(AppyError.TIMEOUT). |
launchLinkWaitMillis | 500 | How long the launch open waits for a link, so a cold start from a link sends one request. |
requestTimeoutMillis | 10000 | Connect and read timeout of each HTTP attempt. |
collectInstallReferrer | true | Read the install referrer of the store that installed the app on the first launch. |
apiBaseUrl | https://api.appy.to | Change only for a staging API. |
Handle incoming intents
Nothing is needed in onCreate: when an activity is created, the SDK inspects its intent. Forward onNewIntent, which lifecycle callbacks cannot see:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (!Appy.handleIntent(intent)) legacyRouter.handle(intent)
}handleIntent returns true for an Appy link and never processes the same intent twice; intents Android replays from Recents are ignored. Before init, the SDK keeps up to 10 URLs and resolves the Appy links among them right after it.
Receive deep links
One listener receives every deep link, deferred or not, as an AppyDeepLinkResult:
| Result | When |
|---|---|
Found(link) | An Appy link opened the app (link.isDeferred is false), or, on the first launch after install, Appy matched the install to the link tapped before installing (link.isDeferred is true). |
NotFound | The first launch has no link: the install did not come from an Appy link. |
Failed(error) | The first launch got no usable answer: TIMEOUT, NETWORK (Appy could not be reached after three attempts) or INVALID_RESPONSE (for example an invalid key, which the log shows). |
Appy.setDeepLinkListener { result ->
when (result) {
is AppyDeepLinkResult.Found -> {
val link = result.link
if (link.slug == "spring") router.openProduct(link.parameters["item"])
else link.deeplinkUri?.let(router::open) ?: router.openHome()
}
AppyDeepLinkResult.NotFound, is AppyDeepLinkResult.Failed -> router.openHome()
}
}Appy.setDeepLinkListener(result -> {
if (result instanceof AppyDeepLinkResult.Found) {
AppyDeepLink link = ((AppyDeepLinkResult.Found) result).getLink();
router.openProduct(link.getParameters().get("item"));
} else {
router.openHome();
}
});- The listener is always called on the main thread.
- On the first launch after install it is called exactly once, at the latest
deferredDeepLinkTimeoutMillisafterinit. A later answer is not delivered as a second result; it still updates the install attribution. - After that, it is called with
Foundfor every Appy link that opens the app. Later cold starts without a link do not call it. - A result that arrives while no listener is set waits and is delivered once, when a listener is set. Clear a listener that captures an activity in
onDestroywithAppy.setDeepLinkListener(null).
AppyDeepLink | Meaning |
|---|---|
url: Uri? | The Appy link that was opened or tapped. |
deeplinkUri: Uri? | The in-app deep link set on the link, such as acme://items?item=42. |
slug: String | The link’s slug in Appy. |
parameters: Map<String, String> | Query parameters of the tapped link, without the reserved appy_ ones. |
isDeferred: Boolean | true when the link was tapped before the app was installed. |
clickedAt: Long? | Tap time in epoch milliseconds, when known. |
If Appy cannot be reached, a link that opened the app is still delivered as Found, read from its own URL, without deeplinkUri or clickedAt.
Deferred deep links
- 1
Someone taps a link without the app
For example
https://acme.appy.to/spring?item=42. - 2
The store opens with the link attached
Appy records the tap and opens your Google Play or AppGallery listing, passing the link in the store’s install referrer.
- 3
The first launch reads the referrer
The SDK reads the referrer from the store that installed the app, waiting at most 3 s, and sends it with the launch open.
- 4
The listener receives the link
Appy matches the install to the link, and the listener receives
FoundwithisDeferred = trueand the same slug, parameters and deep link as a direct open.
- Installs from Google Play or AppGallery that started from the link carry it in the install referrer, so Appy knows exactly which link it was.
- For installs from other stores, sideloads, or when the user searched the store instead of following the link, Appy still matches the install to the link when it can. Strict attribution turns this off.
- Only the first launch is matched. If its request fails, the next cold start counts as the first launch again.
Open deferred deep links automatically
With openDeferredDeepLinks = true, when the first launch receives a deferred Found whose link has a deeplinkUri, the SDK calls your listener and then starts an ACTION_VIEW intent for that URI, restricted to your own package. Route deferred links either in your listener or through this intent, not both.
AppyConfig("appy_pk_...", listOf("acme.appy.to")).apply { openDeferredDeepLinks = true }config.setOpenDeferredDeepLinks(true);Keep the first launch a first launch
Android Auto Backup restores SharedPreferences on reinstall and on new phones, which makes a new install look old. Exclude the SDK’s file in backup_rules.xml (Android 11 and lower) and in both sections of data_extraction_rules.xml (Android 12 and newer):
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="to.appy.sdk.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="to.appy.sdk.xml" />
</device-transfer>
</data-extraction-rules>Strict attribution
Strict attribution is an app setting in the dashboard, off by default and not set in code: only count installs Appy can verify; everything else is organic. Appy can verify an install when the store’s install referrer carried the link, or when the link itself opened the app. With it on, other first launches receive NotFound and an organic attribution.
Links in push notifications and messages
A link in a push notification or an in-app message reaches your code as a URL, not as a link intent. Pass it to Appy.handleUri where your messaging library reports the tap:
fun onMessageLinkTapped(url: String) {
if (!Appy.handleUri(Uri.parse(url))) router.openUrl(url)
}void onMessageLinkTapped(String url) {
if (!Appy.handleUri(Uri.parse(url))) router.openUrl(url);
}An Appy link goes to your deep link listener as Found with isDeferred = false, and Appy counts it as an app open for that link. handleUri returns false for any other URL. For a notification, call it once for the tap, not again when the activity is recreated.
Install attribution
On the first launch Appy also tells the app which link brought the install, or that the install is organic.
Appy.setAttributionListener { attribution ->
analytics.setUserProperty("install_campaign", if (attribution.isOrganic) "organic" else attribution.campaign)
}
val installedFrom: String? = Appy.attribution?.linkSlugAppy.setAttributionListener(attribution ->
analytics.setUserProperty("install_campaign", attribution.isOrganic() ? "organic" : attribution.getCampaign()));
AppyAttribution attribution = Appy.getAttribution();AppyAttribution | Meaning |
|---|---|
isOrganic: Boolean | true when the install did not come from an Appy link; every other field is then null. |
linkSlug, linkTitle | The link that brought the install. |
source, medium, campaign | The utm_source, utm_medium and utm_campaign of the tapped link, when it had them. |
clickedAt: Long? | Tap time in epoch milliseconds, when known. |
The attribution listener is called once, on the main thread, also when the answer came too late for the deep link listener. Appy.attribution keeps the value across launches and is null until the first answer has arrived.
Rewards and anything of value
Route users on any Found link, but confirm on your server before granting referral rewards, discounts or account access. Call Appy.setUserId after sign-in, then look the install up with your secret key and grant the reward only when attribution.verified is true:
curl "https://api.appy.to/v1/apps/$APP_ID/installs?userId=u_123" \
-H "Authorization: Bearer $APPY_SECRET_KEY"Track events
Appy.track("signup")
Appy.track("purchase", mapOf("sku" to "A1", "qty" to 2), revenue = 19.98, currency = "USD")Map<String, Object> properties = new HashMap<>();
properties.put("sku", "A1");
properties.put("qty", 2);
Appy.track("signup");
Appy.track("purchase", properties, 19.98, "USD");- The name is trimmed and must be 1 to 64 printable characters, otherwise the event is dropped.
- Property values must be
String,NumberorBoolean; at most 50 properties with keys of 1 to 64 characters; strings are cut at 512 characters. revenueneeds a finite amount below 10^12 and a three-letter ISO 4217currency; otherwise both are dropped and the event is kept.deduplicationIdis trimmed and must be 1 to 128 printable ASCII characters without spaces; otherwise an error is logged and the event is kept with a generated id.
track returns immediately and never throws. Events are stored on disk (at most 1,000) and sent in batches of up to 100 events and 256 KB: 2 s after the last track, when 20 are waiting, when the app goes to the background, and on Appy.flush(), never before the launch open has finished. Failed batches are retried with growing delays.
Count a purchase once
Pass the store’s transaction id as deduplicationId, and Appy counts the purchase once, even when it is tracked again after a crash or a restore. With Google Play Billing, that is the order id of a purchase in the PURCHASED state.
Appy.track("purchase", mapOf("sku" to "A1"), revenue = 19.98, currency = "USD", deduplicationId = purchase.orderId)Map<String, Object> properties = new HashMap<>();
properties.put("sku", "A1");
Appy.track("purchase", properties, 19.98, "USD", purchase.getOrderId());If your backend also sends the purchase as a server event, pass the same order id as the event’s id. The id is unique per app across the app and your backend, so use one per purchase, not per product. While an event with the same deduplicationId is still waiting to be sent, a second one is dropped.
User id, privacy and consent
Appy.setUserId("u_123") stores an opaque id from your system and attaches it to later opens and events; Appy.setUserId(null) clears it at sign-out. Ids may have up to 128 characters. Do not use an email address or phone number.
The SDK sends the install id (a random UUID in app-private storage), the Appy link that opened the app, the raw install referrer and its store (first launch only), the user id if set, platform, OS version, model, manufacturer, locale, time zone and screen size, and the app’s package name, version and installing store, plus the events you track. It does not read the advertising ID, Android ID, hardware identifiers, location, contacts, installed apps or clipboard.
Appy.isTrackingEnabled = false is stored across restarts. While it is off, queued events are deleted and new ones dropped, and no launch open, install referrer read or deferred deep link lookup happens. Links that open the app still resolve, without the user id, and Appy stores nothing about that open.
Waiting for consent
Appy.isTrackingEnabled = consentStore.hasAnalyticsConsent()
Appy.init(this, appyConfig)
fun onAnalyticsConsentGiven() {
consentStore.setAnalyticsConsent(true)
Appy.isTrackingEnabled = true
}Appy.setTrackingEnabled(consentStore.hasAnalyticsConsent());
Appy.init(this, config);
void onAnalyticsConsentGiven() {
consentStore.setAnalyticsConsent(true);
Appy.setTrackingEnabled(true);
}- Set the flag before
Appy.init, becauseinitstarts the launch open right away. Nothing is sent before consent, and events tracked meanwhile are dropped. - When it turns
true, the SDK reads the install referrer and sends the launch open, and the listener receives the first-launch result withindeferredDeepLinkTimeoutMillis, counted from that moment. - The value is stored, so this also works when the user agrees only on a later launch.
Deleting a user’s data
The SDK has no delete call, because your backend owns the relationship with the user. Call DELETE /v1/apps/{appId}/installs?userId=<id> with a secret key; see Deleting installs. Also call Appy.setUserId(null) or set isTrackingEnabled = false in the app, otherwise its next launch is stored again as a new, organic install.
Build share links
val url: Uri? = Appy.buildLink("spring", mapOf("item" to "42"))The result is https://acme.appy.to/spring?item=42 on the first link domain, with parameters sorted and percent-encoded. It is null before init, without a link domain or for an invalid slug. Nothing is created on the server, so the slug must belong to an existing link.
Test and debug
adb logcat -s Appy
adb shell pm get-app-links com.acme.shop
adb shell pm verify-app-links --re-verify com.acme.shop
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://acme.appy.to/spring?item=42"'On Android 12 and newer, pm get-app-links should show acme.appy.to: verified. Set logLevel = AppyLogLevel.DEBUG to see each request, the install referrer, and each deep link and attribution the SDK delivers.
Test a deferred deep link
- 1
Uninstall the app
Remove it from the phone you test with.
- 2
Tap the link on the same phone
For example in a message to yourself. It opens the store listing or your website; you do not need to install from there.
- 3
Install your build
With
adb install, Run in Android Studio, or an internal testing track in Play. - 4
Open the app
The deep link listener receives
FoundwithisDeferred = true.
Every install gets a new random install id, so there is no test device to register and nothing to reset on the server. Each tap is a new click. A build installed with adb carries no install referrer, so with strict attribution on it gets NotFound; to test the referrer, install from the listing the link opens on an internal testing track.
| Symptom | Check |
|---|---|
| Links open the browser | pm get-app-links, the fingerprint of the certificate that signed the installed build, autoVerify, assetlinks.json served without redirect. |
| Listener never called | init in Application.onCreate, the domain in linkDomains, onNewIntent forwarded, isTrackingEnabled off on the first launch. |
Failed(INVALID_RESPONSE) | 401 or 403 in the log: the publishable key, or a plan without the SDK. |
NotFound on a test install | Only the first launch is matched; check the installer with dumpsys package and strict attribution in the dashboard. |
| Events missing | isTrackingEnabled is false, or the log shows rejected events. |
API reference
Appy member | Description |
|---|---|
init(context, config) | Call once from Application.onCreate. |
setDeepLinkListener(listener) | Sets or clears the main-thread AppyDeepLinkListener. |
setAttributionListener(listener) | Sets or clears the main-thread AppyAttributionListener. |
latestDeepLink: AppyDeepLink? | Last link delivered in this process. |
attribution: AppyAttribution? | The install attribution, kept across launches. |
handleIntent(intent): Boolean | Resolves the Appy link in an ACTION_VIEW intent. |
handleUri(uri): Boolean | Resolves an Appy link received another way, such as a push payload. |
track(name, properties, revenue, currency, deduplicationId) | Queues an event. Every argument after name is optional. |
setUserId(userId) | Sets or clears the user id. |
isTrackingEnabled | Stored consent switch, default true. |
buildLink(slug, parameters): Uri? | Builds a share link. |
flush() | Sends queued events now. |