Appy

SDK

Using Appy without the SDK

The Appy SDKs are thin clients over two HTTP endpoints. Anything they do, you can do yourself from React Native, Flutter, Unity, a web view or plain platform code.

Everything on this page uses the app’s publishable key (appy_pk_…), which is safe to ship in the app. The Client API, like the SDKs, is part of the Enterprise plan. Calls from your backend use a secret key (appy_sk_…), which never leaves your servers.

The whole protocol

WhenCallWhat you get
Every cold startPOST /v1/sdk/open with firstLaunchOn the first launch after install: the link the user tapped before installing, if any, and the install’s attribution.
The app is opened by a URLPOST /v1/sdk/open with urlThe link behind that URL, with its parameters.
Something worth measuring happensPOST /v1/sdk/eventsEvents and revenue credited to the link that brought the install.

Rules that keep the numbers right:

  1. Generate a random installId (a UUID) on first launch and keep it until the app is uninstalled.
  2. Send firstLaunch: true until one open request with firstLaunch: true has succeeded (2xx), then false forever. Retrying a first launch is safe; Appy answers it with the same link.
  3. On Android, on the first launch, read the Google Play Install Referrer and send it as installReferrer with installReferrerSource: "google_play". The referrer carries the link, so Appy knows exactly which link brought the install. Apps installed from Huawei AppGallery can send its referrer with installReferrerSource: "huawei_appgallery".
  4. Give every event a unique id and keep unsent events until the server answers 2xx. A resent event with the same id is counted once.
  5. If the user opts out, send "trackingEnabled": false. The server then stores nothing and only resolves URLs you pass explicitly.

Full request and response schemas are in the API reference under Client API.

Try it with curl

bash
curl https://api.appy.to/v1/sdk/open \
  -H "Authorization: Bearer $APPY_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "installId": "7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10",
    "firstLaunch": false,
    "url": "https://acme.appy.to/spring?item=42",
    "device": {"platform": "ios", "osVersion": "17.5"}
  }'
response
{
  "installId": "7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10",
  "firstLaunch": false,
  "deepLink": {
    "url": "https://acme.appy.to/spring?item=42",
    "deeplinkUrl": "acme://items?item=42",
    "slug": "spring",
    "parameters": {
      "item": "42"
    },
    "isDeferred": false,
    "clickedAt": null
  },
  "attribution": null
}

Route on slug and parameters, or on deeplinkUrl if your app already understands those URLs. deepLink is null when there is nothing to route. On the first launch, attribution says whether the install is attributed, with the link, the UTM source, medium and campaign and the tap time, or organic; on later launches it is null.

iOS in plain Swift

swift
import UIKit

enum Appy {
    static let publishableKey = "appy_pk_..."
    private static let endpoint = URL(string: "https://api.appy.to/v1/sdk/open")!
    private static let defaults = UserDefaults.standard

    static var installId: String {
        if let id = defaults.string(forKey: "appy.installId") { return id }
        let id = UUID().uuidString.lowercased()
        defaults.set(id, forKey: "appy.installId")
        return id
    }

    static func open(url: URL? = nil, completion: @escaping ([String: Any]?) -> Void) {
        let firstLaunch = !defaults.bool(forKey: "appy.firstOpenDone")
        var body: [String: Any] = [
            "installId": installId,
            "firstLaunch": firstLaunch,
            "device": ["platform": "ios", "osVersion": UIDevice.current.systemVersion]
        ]
        if let url { body["url"] = url.absoluteString }
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("Bearer \(publishableKey)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        URLSession.shared.dataTask(with: request) { data, response, _ in
            let ok = (response as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
            let json = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
            if ok && firstLaunch { defaults.set(true, forKey: "appy.firstOpenDone") }
            DispatchQueue.main.async { completion(ok ? json?["deepLink"] as? [String: Any] : nil) }
        }.resume()
    }
}

Call Appy.open { link in … } once at launch, and Appy.open(url: url) { link in … } from scene(_:continue:) or onContinueUserActivity for Universal Links on your link domain. You still need the Associated Domains entitlement applinks:<subdomain>.appy.to.

Android in plain Kotlin

Add the Play Install Referrer library (about 20 KB):

build.gradle.kts
implementation("com.android.installreferrer:installreferrer:2.2")
kotlin
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread

object Appy {
    private const val PUBLISHABLE_KEY = "appy_pk_..."

    fun open(context: Context, url: String?, onLink: (JSONObject?) -> Unit) {
        val prefs = context.getSharedPreferences("appy", Context.MODE_PRIVATE)
        val installId = prefs.getString("installId", null)
            ?: UUID.randomUUID().toString().also { prefs.edit().putString("installId", it).apply() }
        val firstLaunch = !prefs.getBoolean("firstOpenDone", false)
        if (!firstLaunch || url != null) {
            send(prefs, installId, firstLaunch, url, null, onLink)
            return
        }
        val sent = AtomicBoolean(false)
        val client = InstallReferrerClient.newBuilder(context).build()
        client.startConnection(object : InstallReferrerStateListener {
            override fun onInstallReferrerSetupFinished(code: Int) {
                val referrer = if (code == InstallReferrerClient.InstallReferrerResponse.OK) {
                    runCatching { client.installReferrer.installReferrer }.getOrNull()
                } else null
                client.endConnection()
                if (sent.compareAndSet(false, true)) send(prefs, installId, true, null, referrer, onLink)
            }

            override fun onInstallReferrerServiceDisconnected() {
                if (sent.compareAndSet(false, true)) send(prefs, installId, true, null, null, onLink)
            }
        })
    }

    private fun send(prefs: SharedPreferences, installId: String, firstLaunch: Boolean, url: String?,
                     referrer: String?, onLink: (JSONObject?) -> Unit) = thread {
        val body = JSONObject()
            .put("installId", installId)
            .put("firstLaunch", firstLaunch)
            .put("device", JSONObject().put("platform", "android").put("osVersion", Build.VERSION.RELEASE))
        url?.let { body.put("url", it) }
        referrer?.let { body.put("installReferrer", it).put("installReferrerSource", "google_play") }
        val connection = URL("https://api.appy.to/v1/sdk/open").openConnection() as HttpURLConnection
        val link = runCatching {
            connection.requestMethod = "POST"
            connection.doOutput = true
            connection.setRequestProperty("Authorization", "Bearer $PUBLISHABLE_KEY")
            connection.setRequestProperty("Content-Type", "application/json")
            connection.outputStream.use { it.write(body.toString().toByteArray()) }
            if (connection.responseCode !in 200..299) return@runCatching null
            if (firstLaunch) prefs.edit().putBoolean("firstOpenDone", true).apply()
            JSONObject(connection.inputStream.bufferedReader().readText()).optJSONObject("deepLink")
        }.getOrNull()
        connection.disconnect()
        Handler(Looper.getMainLooper()).post { onLink(link) }
    }
}

Call Appy.open(context, null) { link -> … } in your launcher activity and Appy.open(context, intent.data?.toString()) { link -> … } when an App Link on your link domain opens the app. Exclude the appy preferences from Auto Backup, or a restored phone skips its first launch.

React Native, Flutter and Unity

The recipe is the same in every runtime:

  • Storage for installId, the first-open flag and pending events: AsyncStorage, shared_preferences, PlayerPrefs or similar.
  • Incoming links from the framework’s deep link API (Linking in React Native, an app links plugin in Flutter, Application.deepLinkActivated in Unity), sent as url.
  • The Play Install Referrer on Android through a plugin that exposes it, or a small platform channel that calls com.android.installreferrer:installreferrer like the Kotlin example.
  • Two HTTP calls, as in the curl examples.

A React Native first launch, for example:

javascript
const res = await fetch('https://api.appy.to/v1/sdk/open', {
  method: 'POST',
  headers: {Authorization: `Bearer ${PUBLISHABLE_KEY}`, 'Content-Type': 'application/json'},
  body: JSON.stringify({
    installId,
    firstLaunch,
    url: (await Linking.getInitialURL()) ?? undefined,
    installReferrer: Platform.OS === 'android' ? referrer : undefined,
    device: {platform: Platform.OS, osVersion: String(Platform.Version)},
  }),
});
if (res.ok && firstLaunch) await AsyncStorage.setItem('appy.firstOpenDone', '1');
const {deepLink} = res.ok ? await res.json() : {deepLink: null};

Web views and hybrid apps

When most of the app runs in a web view, make the calls from the native shell or from the page’s JavaScript with fetch. Keep installId and the first-open flag in storage that lasts as long as the app, not in session storage. Pass links that open the app from the native side to POST /v1/sdk/open as url, and read the install referrer natively on Android.

Events

bash
curl https://api.appy.to/v1/sdk/events \
  -H "Authorization: Bearer $APPY_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "installId": "7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10",
    "events": [
      {"id": "2000000123456789", "name": "purchase",
       "timestamp": "2026-09-25T10:06:00Z", "revenue": 19.98, "currency": "USD",
       "properties": {"sku": "A1"}}
    ]
  }'
202 response
{
  "accepted": 1,
  "duplicates": 0,
  "rejected": []
}

The answer (202) says how many events were stored, how many were duplicates of earlier ones, and which were rejected with a reason. Rejected events will never succeed; drop them.

An event id is 1 to 128 printable ASCII characters without spaces. It is the deduplication key for the app, shared with server events: use a UUID for ordinary events, and the App Store transaction id or Google Play order id for a purchase that your backend may report too.

From your server

Some events only your backend knows: a renewal charged by the store, a refund, an order that passed fraud checks. Send them with a secret key and either the installId or your own userId, the same one the app sent. Before you pay a referral bonus, look up how the user arrived and require attribution.verified. Both calls are described in the REST API guide.