Skip to content

Documentation

Native Swift, Kotlin and Dart, without the SDK

There is no package for these languages and the product works. The two calls the SDK makes, written out in Swift, Kotlin and Dart, plus the deferred first-launch sequence you would build yourself.

If you are building in Swift, Kotlin, Java or Dart, this product works and there is nothing for you to install. Our package is JavaScript, so you cannot import it — and it is a client for the REST API described here, which is public, versioned, and covered by the same compatibility guarantee the package is.

This page exists because its absence was doing real damage. A native developer read our documentation, found Capacitor and React Native and nothing else, and concluded we did not support them.

What you are actually building

Two calls, and one sequence if you want deferred installs.

  • A link that opens an app somebody already has. One POST to /api/v1/sdk/resolve. This is the common case and it is genuinely one call.
  • Recording what happened afterwards. One POST to /api/v1/sdk/events.
  • A link tapped before the app existed. A first-launch call to /api/v1/sdk/match, which is a sequence rather than an endpoint — see below. This is the one thing the package does for you that a single call does not.

Authentication, and which key

A publishable key, in an Authorization: Bearer header. It begins qr_live_ or qr_test_, it is designed to be compiled into an app and shipped to the public, and it can do exactly three things: resolve your own links, report installs and record events.

Never ship a secret key — one beginning qr_sk_ — inside an app. These endpoints refuse it.

The environment is the key. A test key reaches test links and a live key reaches live ones; there is no parameter to get wrong and no way to write to the wrong one by mistake. See live and test environments.

The install token, which is yours to keep

Every call except the first carries an installToken: an opaque string you generate once, on the device, and store. It is how one install is told from another. It is not an advertising identifier, we do not issue it, and nothing about it is derived from the device.

Generate a UUID with the hyphens removed, store it, and reuse it for the life of the install. If you lose it, the install looks new and its attribution starts again — so store it where your app’s data lives, not in a cache.

Swift

Your app already receives the universal link — iOS hands it to continueUserActivity and nothing about that changes. What follows takes the URL you were given and asks what is behind it.

The association file iOS fetches from your link host is already being served, so the platform setup is the same as for any universal link: add the Associated Domains entitlement and your link host. The iOS page covers it, and it is not specific to the SDK.

DeepLinkClient.swift — resolving an opened linkswift
import Foundation

struct ResolvedLink: Decodable {
    let url: String?
    let alias: String?
    let data: [String: String]?
    let campaign: String?
    let channel: String?
    let feature: String?
}

struct ResolveResponse: Decodable {
    let link: ResolvedLink?
    let confidence: Double?
    let reason: String?
    let attributed: Bool?
    let installId: String?
}

final class DeepLinkClient {
    private let key: String
    private let base = URL(string: "https://app.quberoute.com")!

    /// Generated once, stored, reused for the life of the install.
    private var installToken: String {
        if let existing = UserDefaults.standard.string(forKey: "deeplink.installToken") {
            return existing
        }
        let fresh = UUID().uuidString.replacingOccurrences(of: "-", with: "")
        UserDefaults.standard.set(fresh, forKey: "deeplink.installToken")
        return fresh
    }

    init(key: String) { self.key = key }

    /// Call this with the URL your app was opened with.
    func resolve(url: URL) async throws -> ResolveResponse {
        try await post(
            path: "/api/v1/sdk/resolve",
            body: ["url": url.absoluteString, "installToken": installToken]
        )
    }

    private func post<T: Decodable>(path: String, body: [String: Any]) async throws -> T {
        var request = URLRequest(url: base.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)

        // Never let our call delay your app. Three seconds is what the package uses.
        request.timeoutInterval = 3

        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(T.self, from: data)
    }
}
Two fields go up and the link comes back. That is the whole of the common case.
AppDelegate.swift — where iOS already hands you the linkswift
func application(
    _ application: UIApplication,
    continue userActivity: NSUserActivity,
    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return false }

    Task {
        // A failure here must never stop the app opening. The link is already
        // in hand; what is being fetched is what is behind it.
        if let response = try? await client.resolve(url: url),
           let link = response.link {
            route(to: link)
        }
    }

    return true
}

Kotlin and Java

Your launch activity already receives the link in its Intent. The Digital Asset Links file Android verifies against your link host is already being served, so the platform setup is the ordinary App Links setup — the Android page covers it, including why the signing fingerprints are plural.

DeepLinkClient.kt — resolving an opened linkkotlin
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.TimeUnit

class DeepLinkClient(context: Context, private val key: String) {

    private val base = "https://app.quberoute.com"
    private val json = "application/json".toMediaType()

    // Three seconds, matching the package. Your app must not wait on us.
    private val http = OkHttpClient.Builder()
        .callTimeout(3, TimeUnit.SECONDS)
        .build()

    private val prefs = context.getSharedPreferences("deeplink", Context.MODE_PRIVATE)

    /** Generated once, stored, reused for the life of the install. */
    private val installToken: String
        get() = prefs.getString("installToken", null) ?: run {
            val fresh = UUID.randomUUID().toString().replace("-", "")
            prefs.edit().putString("installToken", fresh).apply()
            fresh
        }

    /** Call this with the URL your activity was launched with. */
    suspend fun resolve(url: String): JSONObject? = post(
        "/api/v1/sdk/resolve",
        JSONObject().put("url", url).put("installToken", installToken)
    )

    private suspend fun post(path: String, body: JSONObject): JSONObject? =
        withContext(Dispatchers.IO) {
            val request = Request.Builder()
                .url(base + path)
                .addHeader("Authorization", "Bearer " + key)
                .post(body.toString().toRequestBody(json))
                .build()

            runCatching {
                http.newCall(request).execute().use { response ->
                    response.body?.string()?.let { JSONObject(it) }
                }
            }.getOrNull()
        }
}
MainActivity.kt — where Android already hands you the linkkotlin
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    intent?.data?.let { uri ->
        lifecycleScope.launch {
            // runCatching inside the client means this cannot throw here. A
            // failed lookup must never stop the app opening.
            val response = client.resolve(uri.toString())
            response?.optJSONObject("link")?.let { routeTo(it) }
        }
    }
}

Dart and Flutter

Receive the incoming URL with app_links or uni_links — both are maintained Flutter packages, and neither is ours. Then the call is the same one.

deep_link_client.dartdart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';

class DeepLinkClient {
  DeepLinkClient(this.key);

  final String key;
  static const _base = 'https://app.quberoute.com';

  Future<String> _installToken() async {
    final prefs = await SharedPreferences.getInstance();
    final existing = prefs.getString('installToken');
    if (existing != null) return existing;

    final fresh = const Uuid().v4().replaceAll('-', '');
    await prefs.setString('installToken', fresh);
    return fresh;
  }

  Future<Map<String, dynamic>?> resolve(String url) => _post(
        '/api/v1/sdk/resolve',
        {'url': url, 'installToken': await _installToken()},
      );

  Future<Map<String, dynamic>?> _post(String path, Map<String, dynamic> body) async {
    try {
      final response = await http
          .post(
            Uri.parse('$_base$path'),
            headers: {
              'content-type': 'application/json',
              'authorization': 'Bearer $key',
            },
            body: jsonEncode(body),
          )
          // Your app must never wait on us.
          .timeout(const Duration(seconds: 3));

      return jsonDecode(response.body) as Map<String, dynamic>;
    } catch (_) {
      // A lookup that fails is a link that routes to your default screen.
      return null;
    }
  }
}

The deferred case, which is the one worth understanding

Somebody taps a link, does not have the app, goes to the store, installs, and opens it. The store tells your app nothing about the link that sent them — that is the platform’s behaviour, not a limitation of ours — so the install has to be matched against recent clicks instead.

This is the sequence the package does for you. On the first launch only, with no URL in hand:

  1. POST to /api/v1/sdk/match with your installToken. On Android, include the install referrer from the Play Install Referrer library as referrer — it makes the match exact rather than probabilistic.
  2. If a link comes back, it carries a confidence below 1 and a reason. Route on it exactly as you would a link that was opened directly.
  3. If none comes back, reason says why. Treat it as an ordinary launch.
  4. Ask once. Record that you have asked, and never ask again for this install. A second call cannot improve the answer, and the endpoint answers already_attributed.
Swift — first launch onlyswift
func matchOnFirstLaunch() async {
    let launched = UserDefaults.standard.bool(forKey: "deeplink.launchedBefore")
    guard !launched else { return }
    UserDefaults.standard.set(true, forKey: "deeplink.launchedBefore")

    // No URL: this asks whether a recent click belongs to this install.
    if let response: ResolveResponse = try? await client.match(),
       let link = response.link {
        route(to: link, deferred: true, confidence: response.confidence ?? 0)
    }
}
Guarded on first launch. Asking twice cannot improve the answer.

Be honest with yourself about the confidence. A deferred match is a probabilistic join between a click and an install, and how well it actually works gives the real rates rather than a marketing figure. The confidence on the response is not decoration: if you are paying affiliates on these, read that page before you build the payment on top of it.

Recording events

One POST, with your installToken and an array of events. Values are in minor units499 means 4.99 of whatever currency you name — and a value always requires a currency.

POST /api/v1/sdk/eventshttp
POST https://app.quberoute.com/api/v1/sdk/events
Authorization: Bearer qr_live_YOUR_KEY
Content-Type: application/json

{
  "installToken": "9f3a...",
  "events": [
    {
      "eventId": "a-key-you-generate",
      "name": "purchase",
      "valueMinor": 499,
      "currency": "GBP",
      "properties": { "sku": "spring-box" },
      "clientTimestamp": "2026-09-01T12:00:00.000Z"
    }
  ]
}
eventId is yours to generate, and it is what makes a retry safe: send the same batch twice and the second is refused rather than double-counted.

Retry on a 5xx or a 429, and give up on a 4xx. That distinction is the one thing worth copying from the package: a 503 means “keep them and try again”, and treating it as permanent throws away a queue built on a train.

What you give up by not having the package

Stated plainly, because it is a real list and not a short one.

  • The deferred sequence. Described above; you would build it.
  • The offline queue. The package holds up to a hundred events when the network is gone and sends them later. You would hold your own.
  • The never-crash guarantee. The package cannot throw into your app and cannot block startup. Your client is yours to make safe — the samples above catch and time out for that reason.
  • Clipboard matching, which is off by default and needs a handshake.

What you do not give up: the link object, the parameters on it, the environment separation, deferred attribution itself, webhooks, or anything on the dashboard. Those are all server-side and identical however you call.

Would a package for your language help?

There is no Swift or Kotlin package to import, and this page is an accurate account of what exists rather than a substitute for one. If having one would decide it for you, write to [email protected] and say which language. What people ask for is what gets built, and nobody has asked — because until this page, nobody knew this was possible.

Ask the documentation

It answers from these pages only, and links what it used. If the answer is not here it says so rather than guessing — then email [email protected].

← All documentation