Appy

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
Source9 Kotlin files, about 1,150 non-blank lines
Dependenciescom.android.installreferrer:installreferrer:2.2 and the Kotlin standard library. No AndroidX, Play Services, HMS Core, OkHttp or JSON library.
Dex added to an appAbout 75 KB after R8 with the whole public API kept, of which about 4 KB is the Install Referrer library.
Requests per cold startOne POST /v1/sdk/open; on a first launch it waits at most 3 s for the install referrer.
Not includedNo 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.

build.gradle.kts
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.

BuildCertificateWhere to find the SHA-256
From Google Play with Play App SigningGoogle’s app signing keyPlay Console > your app > App integrity > App signing
Internal builds you signYour upload keykeytool -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.

bash
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

AndroidManifest.xml
<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 autoVerify filter is the App Link: once verified, taps on https://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 without appy_click_id are left to your code.
  • With singleTask or singleTop, links that arrive while the app runs go to onNewIntent.

Initialize the SDK

kotlin
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
        })
    }
}
AppyConfigDefaultMeaning
publishableKeyrequiredappy_pk_…; another prefix is logged as an error.
linkDomainsemptyHosts whose URLs are Appy links. The first one is used by buildLink.
logLevelERRORNONE, ERROR, WARNING, INFO, DEBUG; Logcat tag Appy.
openDeferredDeepLinksfalseLet the SDK open a deferred link’s in-app deep link itself.
deferredDeepLinkTimeoutMillis5000How long the first launch waits before the listener receives Failed(AppyError.TIMEOUT).
launchLinkWaitMillis500How long the launch open waits for a link, so a cold start from a link sends one request.
requestTimeoutMillis10000Connect and read timeout of each HTTP attempt.
collectInstallReferrertrueRead the install referrer of the store that installed the app on the first launch.
apiBaseUrlhttps://api.appy.toChange 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:

kotlin
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.

One listener receives every deep link, deferred or not, as an AppyDeepLinkResult:

ResultWhen
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).
NotFoundThe 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).
kotlin
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()
    }
}
  • The listener is always called on the main thread.
  • On the first launch after install it is called exactly once, at the latest deferredDeepLinkTimeoutMillis after init. A later answer is not delivered as a second result; it still updates the install attribution.
  • After that, it is called with Found for 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 onDestroy with Appy.setDeepLinkListener(null).
AppyDeepLinkMeaning
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: StringThe link’s slug in Appy.
parameters: Map<String, String>Query parameters of the tapped link, without the reserved appy_ ones.
isDeferred: Booleantrue 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.

  1. 1

    Someone taps a link without the app

    For example https://acme.appy.to/spring?item=42.

  2. 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. 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. 4

    The listener receives the link

    Appy matches the install to the link, and the listener receives Found with isDeferred = true and 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.

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.

kotlin
AppyConfig("appy_pk_...", listOf("acme.appy.to")).apply { openDeferredDeepLinks = 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):

res/xml/data_extraction_rules.xml
<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:

kotlin
fun onMessageLinkTapped(url: String) {
    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.

kotlin
Appy.setAttributionListener { attribution ->
    analytics.setUserProperty("install_campaign", if (attribution.isOrganic) "organic" else attribution.campaign)
}

val installedFrom: String? = Appy.attribution?.linkSlug
AppyAttributionMeaning
isOrganic: Booleantrue when the install did not come from an Appy link; every other field is then null.
linkSlug, linkTitleThe link that brought the install.
source, medium, campaignThe 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:

bash
curl "https://api.appy.to/v1/apps/$APP_ID/installs?userId=u_123" \
  -H "Authorization: Bearer $APPY_SECRET_KEY"

Track events

kotlin
Appy.track("signup")
Appy.track("purchase", mapOf("sku" to "A1", "qty" to 2), revenue = 19.98, currency = "USD")
  • The name is trimmed and must be 1 to 64 printable characters, otherwise the event is dropped.
  • Property values must be String, Number or Boolean; at most 50 properties with keys of 1 to 64 characters; strings are cut at 512 characters.
  • revenue needs a finite amount below 10^12 and a three-letter ISO 4217 currency; otherwise both are dropped and the event is kept.
  • deduplicationId is 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.

kotlin
Appy.track("purchase", mapOf("sku" to "A1"), revenue = 19.98, currency = "USD", deduplicationId = purchase.orderId)

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.

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.

kotlin
Appy.isTrackingEnabled = consentStore.hasAnalyticsConsent()
Appy.init(this, appyConfig)

fun onAnalyticsConsentGiven() {
    consentStore.setAnalyticsConsent(true)
    Appy.isTrackingEnabled = true
}
  • Set the flag before Appy.init, because init starts 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 within deferredDeepLinkTimeoutMillis, 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.

kotlin
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

bash
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.

  1. 1

    Uninstall the app

    Remove it from the phone you test with.

  2. 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. 3

    Install your build

    With adb install, Run in Android Studio, or an internal testing track in Play.

  4. 4

    Open the app

    The deep link listener receives Found with isDeferred = 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.

SymptomCheck
Links open the browserpm get-app-links, the fingerprint of the certificate that signed the installed build, autoVerify, assetlinks.json served without redirect.
Listener never calledinit 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 installOnly the first launch is matched; check the installer with dumpsys package and strict attribution in the dashboard.
Events missingisTrackingEnabled is false, or the log shows rejected events.

API reference

Appy memberDescription
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): BooleanResolves the Appy link in an ACTION_VIEW intent.
handleUri(uri): BooleanResolves 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.
isTrackingEnabledStored consent switch, default true.
buildLink(slug, parameters): Uri?Builds a share link.
flush()Sends queued events now.