Appy

SDK

iOS SDK

Add deep links, deferred deep links, install attribution and in-app events to an iOS app with a Swift package that has no dependencies.

The SDK delivers every Appy link that opens your app, and on the first launch after install the link that was tapped before it, through one callback. It also tells you whether the install is attributed or organic, records events and builds share links. Not writing a native iOS app? See Using Appy without the SDK.

Requirements

  • iOS 13.0 or later.
  • Swift 5.9 or later (Xcode 15 or later). The package uses the Swift 5 language mode and works in Swift 5 and Swift 6 apps.
  • An app registered with Appy on the Enterprise plan, and its publishable key (appy_pk_…).
Footprint
SourceAbout 940 non-blank lines of Swift in 7 files
DependenciesNone. Foundation, UIKit and os only.
Binary sizeAbout 161 KB of code and data in a stripped Release app (arm64), measured by linking a minimal app with and without the SDK.
RequestsOne POST /v1/sdk/open per launch, plus one per Appy link opened later. Events go in batches of up to 100 events and 256 KB.
ThreadsOne private serial queue. Nothing touches the network on the main thread; callbacks arrive on the main thread.
PrivacyNo IDFA, no AdSupport or AppTrackingTransparency, no pasteboard access, no cookies. Ships a privacy manifest.

Install the package

The SDK is a Swift package. Its repository URL is provided during Enterprise onboarding.

  1. 1

    Add the package

    In Xcode, choose File > Add Package Dependencies… and enter the repository URL you received.

  2. 2

    Pick a version rule

    Choose Up to Next Major Version from 1.0.0.

  3. 3

    Link the library

    Add the AppySDK library to your app target.

Package.swift
dependencies: [
    .package(url: "<repository URL from onboarding>", from: "1.0.0")
],
targets: [
    .target(name: "YourApp", dependencies: [.product(name: "AppySDK", package: "<repository name>")])
]

Register your app

Appy needs to know which iOS app belongs to your links, so it can publish the Apple App Site Association (AASA) file for your link domain. Register the app once, under Apps & SDK in the dashboard or with POST /v1/apps and a secret key.

ValueWhere to find it
Apple Team IDApple Developer account > Membership details, for example ABCDE12345. It must be the team that signs your app.
Bundle IDXcode > your target > General > Bundle Identifier, for example com.acme.shop.
Subdomain3 to 32 lowercase letters, digits and hyphens. It becomes your link domain, <subdomain>.appy.to, and cannot be changed later.
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",
        "ios": { "teamId": "ABCDE12345", "bundleId": "com.acme.shop" }
      }'

The response contains the publishable key (appy_pk_…), which works only with the SDK endpoints and is safe to ship, and the link domain, for example acme.appy.to. If the app also ships on Android, register both platforms on one app. With the App Store Connect provider token (ios.providerToken), Appy also adds campaign parameters to App Store links.

Enable Associated Domains

In your target’s Signing & Capabilities, add Associated Domains with applinks: and your link domain.

entitlements
<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:acme.appy.to</string>
    <string>applinks:acme.appy.to?mode=developer</string>
</array>

Devices download the AASA file through Apple’s CDN, which can lag behind by up to a day. The ?mode=developer entry makes a development-signed build fetch the file straight from Appy on a device where Settings > Developer > Associated Domains Development is on. Other builds use the regular entry, so keep both.

Add a custom URL scheme

In-app browsers such as Instagram, Facebook and TikTok often ignore Universal Links. The Appy redirect page then tries the link’s deep link, such as acme://items/42, and opens the store if that fails. Register the scheme under your target’s Info > URL Types, for example with the URL scheme acme.

Appy appends appy_click_id to the custom-scheme URLs it opens, and the SDK treats any URL with that parameter as an Appy link. handle(url:) returns false for your own custom-scheme URLs, so they reach your router untouched.

Configure the SDK

Configure once, as early as possible: in application(_:didFinishLaunchingWithOptions:) or in your SwiftUI App initializer. A second configure call is ignored with an error log.

swift
import AppySDK

var configuration = AppyConfiguration(publishableKey: "appy_pk_live_...", linkDomains: ["acme.appy.to"])
#if DEBUG
configuration.logLevel = .debug
#endif
Appy.configure(configuration)
Appy.shared.onDeepLink = { result in
    if case .found(let link) = result {
        DeepLinkRouter.shared.open(link)
    }
}
PropertyDefaultMeaning
publishableKeyrequiredYour appy_pk_… key. Only its first 12 characters are ever logged.
linkDomains[]Hosts of your Appy links, matched case-insensitively. The first one is used by link(slug:parameters:).
openDeferredDeepLinksfalseWhen true, the SDK also opens a deferred link’s deeplinkURL. See Let the SDK open the link.
deferredDeepLinkTimeout5 sHow long the first launch waits for Appy’s answer before onDeepLink receives .failed(.timeout).
logLevel.errorSee Test and debug.
launchLinkWaitInterval0.5 sHow long a launch waits for a link before opening the session without one.
requestTimeout10 sTimeout per HTTP attempt.
apiBaseURLhttps://api.appy.toChange only for staging.

Both handle methods return true for Appy links: the host is one of your linkDomains, or the URL carries appy_click_id. For any other URL they return false and send nothing, so route those yourself.

swift
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    Appy.shared.handle(url: url) || MyURLRouter.open(url)
}

func application(_ application: UIApplication, continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    Appy.shared.handle(userActivity: userActivity)
}

A scene-based app receives the cold-start link only in the connection options. SwiftUI can deliver a Universal Link through either onOpenURL or onContinueUserActivity, so attach both.

  • Each launch sends exactly one open request. If an Appy link arrives within launchLinkWaitInterval after configure, the request carries it.
  • On the first launch after install, the request without a URL is the one that asks Appy for a deferred link.
  • Links that arrive later each send their own request.
  • Links handled before configure are kept, up to 10, and resolved right after it. Configure early.

onDeepLink is called on the main thread with an AppyDeepLinkResult:

ResultMeaning
.found(link)An Appy link opened the app, or, on the first launch, Appy found the link that was tapped before the install (link.isDeferred == true).
.notFoundFirst launch only. Appy answered, and no Appy link led to this install.
.failed(error)First launch only. .timeout when Appy did not answer within deferredDeepLinkTimeout, .network when Appy could not be reached through all retries, .invalidResponse when Appy rejected the request (for example an unknown key) or the answer was unreadable.

It is called exactly once on the first launch after install, with one of the three results, and with .found every time an Appy link opens the app. It is not called on other launches.

AppyDeepLinkMeaning
urlThe Appy link, for example https://acme.appy.to/spring?item=42.
deeplinkURLThe in-app destination set on the link, for example acme://items?item=42.
slugThe link’s slug, for example spring.
parametersThe link’s query parameters, including any utm_*. Keys starting with appy_ are reserved and never included.
isDeferredtrue when the link was tapped before the app was installed.
clickedAtWhen the link was tapped, if known.
swift
Appy.configure(AppyConfiguration(publishableKey: "appy_pk_live_...", linkDomains: ["acme.appy.to"]))
Appy.shared.onDeepLink = { result in
    switch result {
    case .found(let link):
        DeepLinkRouter.shared.open(link)
    case .notFound, .failed:
        Onboarding.start()
    }
}
  • Callback timing. Set onDeepLink before or after configure. A result that arrives while no callback is set is delivered once, as soon as you set one. A link is never replaced by a later .notFound or .failed.
  • Latest link. latestDeepLink holds the most recent link, including a deferred link that arrived after the timeout.
  • Working offline. When Appy cannot be reached for a link that opened the app, .found carries a link parsed from the URL itself, without deeplinkURL and clickedAt. Make sure your routing also works from slug and parameters.
  • Testing your routing. Build links with the public initializer, for example AppyDeepLink(slug: "spring", parameters: ["item": "42"]). Results are Equatable.

When someone taps an Appy link without your app installed, Appy remembers the tap and sends them to the App Store. On the first launch after install, the SDK asks Appy for that link and Appy matches the install to the link. The answer arrives through onDeepLink: .found with isDeferred == true, or .notFound.

  • Timeout. The first launch waits at most deferredDeepLinkTimeout (5 seconds, counted from configure) and then delivers .failed(.timeout). A later answer does not call onDeepLink again; its link goes to latestDeepLink and its attribution still reaches onAttribution.
  • Retries. Until Appy has answered a first launch, each launch asks again.
  • Tracking off. While isTrackingEnabled is false, the SDK does not ask for a deferred link. See Waiting for consent.
  • What to show meanwhile. Most apps show their usual first screen and navigate when .found arrives. Later launches without a link do not call onDeepLink, so do not wait for it there.

Deferred links on iOS work best when the app is installed and opened soon after the tap. When Appy cannot connect an install to a tap, the first launch gets .notFound and the install counts as organic.

With openDeferredDeepLinks = true, the SDK calls onDeepLink and then opens the deferred link’s deeplinkURL with UIApplication.open. Your app receives it like any other URL, so an app that already routes its own URL scheme needs no extra code.

swift
var configuration = AppyConfiguration(publishableKey: "appy_pk_live_...", linkDomains: ["acme.appy.to"])
configuration.openDeferredDeepLinks = true
Appy.configure(configuration)
  • The SDK only opens URLs with a scheme your app registers under URL Types, so it never sends the user to a web page.
  • It opens only a deferred link delivered within the timeout. Links that open an installed app are never reopened.
  • If you also navigate on .found, skip links with isDeferred == true there so the screen does not open twice.

Strict attribution

Strict attribution is an app setting in the dashboard, under Apps & SDK > your app > Strict attribution, and is off by default. It needs no change in the app. With it on, Appy counts only installs it can verify, and everything else is organic. The App Store passes nothing from the tap to a new install, so a first launch from the home screen then reports .notFound. Links that open the installed app keep arriving as .found.

Links in push notifications and messages

A link in a push notification or an in-app message reaches your code as a URL, not through the system’s link handling. Pass it to handle(url:) where your messaging code reports the tap:

swift
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
    if let value = response.notification.request.content.userInfo["link"] as? String,
       let url = URL(string: value), !Appy.shared.handle(url: url) {
        MyURLRouter.open(url)
    }
    completionHandler()
}

An Appy link is delivered to onDeepLink as .found with isDeferred == false and counted as an app open for that link, the same as a link that opens the app. Put the Appy link itself in the payload, not its deeplinkURL. handle(url:) returns false for any other URL.

Install attribution

onAttribution tells you where the install came from. It is called once, on the main thread, when Appy answers the first launch, also when that answer came after the deep link timeout.

swift
Appy.shared.onAttribution = { attribution in
    if attribution.isOrganic {
        Analytics.setUserProperty("acquisition", value: "organic")
    } else {
        Analytics.setUserProperty("acquisition", value: attribution.campaign ?? attribution.linkSlug ?? "appy")
    }
}
PropertyMeaning
isOrganictrue when no Appy link led to the install.
linkSlug, linkTitleThe link’s slug and its title in the dashboard.
source, medium, campaignThe utm_source, utm_medium and utm_campaign of the tapped link, when it had them.
clickedAtWhen the link was tapped.

Appy.shared.attribution keeps the value across launches and is nil until Appy has answered the first launch. Attribution on the device is for analytics and personalization. Before you pay a referral reward, look the install up from your backend and require attribution.verified; see Verified installs.

Track events

swift
Appy.shared.track("signup")
Appy.shared.track("add_to_cart", properties: ["sku": "A1", "qty": 2, "gift": false])
Appy.shared.track("purchase", properties: ["sku": "A1"], revenue: Decimal(string: "19.98"), currency: "USD")
RuleOtherwise
The name is 1 to 64 characters after trimming.The event is dropped (error log).
Property keys are 1 to 64 characters; values are strings, finite numbers or booleans.The property is dropped.
At most 50 properties; strings are at most 512 characters.Extra properties are dropped; long strings are truncated.
revenue is finite and below 10^12 in absolute value, with a three-letter currency.revenue and currency are dropped and the event is kept.
deduplicationId is 1 to 128 printable ASCII characters without spaces, after trimming.An error is logged and the event is kept with a generated id.

Create amounts with Decimal(string:): a literal such as 19.98 passes through Double. Negative revenue (refunds) is allowed. Events are queued on disk (at most 1,000) and sent 2 seconds after the latest track, when 20 are queued, when the app goes to the background, and on flush(), never before the launch open has finished. Failed batches are retried with growing delays.

Count a purchase once

The same purchase can be tracked twice: again after a crash or a restore, or once from the app and once from your backend. Pass the store’s transaction id as deduplicationId, and Appy counts it once.

swift
Appy.shared.track(
    "purchase",
    properties: ["sku": "A1"],
    revenue: Decimal(string: "19.98"),
    currency: "USD",
    deduplicationId: String(transaction.id)
)

The id is the event deduplication key for your app, shared by SDK and server events, and the first event with an id wins. Use an id that belongs to a single purchase: transaction.id, which is new for every renewal, not originalID or the product id. If your backend also reports the purchase, for example from App Store Server Notifications, pass the same transaction id as the server event’s id. A track call whose id is still waiting in the queue is dropped, because the queued event is the same purchase.

Identify users

swift
Appy.shared.setUserId("u_123")
Appy.shared.setUserId(nil)

Call it after sign-in, and with nil on sign-out. The id is trimmed, persisted and sent with later opens and events. An id longer than 128 characters is rejected and clears the stored id. Use an opaque internal id, not an email address.

DataSent with
Install id: a random UUID created on first launch and deleted with the appOpens, events
The Appy link that opened the app, and the first-launch and tracking flagsOpens
User id, if one is set and tracking is enabledOpens, events
Platform, iOS version, model identifier, locale, time zone, screen sizeOpens
Bundle id, app version, buildOpens
Event name, properties, revenue, currency, timestampEvents

The SDK does not show an App Tracking Transparency prompt, does not read the IDFA and sends nothing to ad networks. The package ships PrivacyInfo.xcprivacy; you remain responsible for your App Store privacy details. What Appy stores lists retention.

isTrackingEnabled defaults to true and is persisted. When it is false, the event queue is deleted and new events are ignored, the launch open is skipped with the deferred deep link and the install attribution, and links your app passes to handle(url:) are still resolved without the user id, with nothing stored on the server.

swift
if !Consent.hasAnswered {
    Appy.shared.isTrackingEnabled = false
}
Appy.configure(configuration)

Consent.onAnswer = { granted in
    Appy.shared.isTrackingEnabled = granted
}
  • Set isTrackingEnabled = false before configure, and only while the user has not answered, because the value is persisted. Before consent the SDK sends no launch open, no events and no user id.
  • When it turns true, the first-launch lookup, its deferredDeepLinkTimeout and onDeepLink start at that moment, also when the user consents on a later launch. The attribution follows through onAttribution.
  • If an Appy link opened the app earlier in the same launch, the first-launch lookup waits for the next 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 to delete that user’s installs and events; see Deleting installs. Deleting does not stop the app from sending more: also call setUserId(nil) or set isTrackingEnabled = false in the app, otherwise its next launch is stored again as a new, organic install.

swift
let url = Appy.shared.link(slug: "spring", parameters: ["item": "42", "ref": referralCode])

This returns https://acme.appy.to/spring?item=42&ref=… on the first link domain, with parameters sorted and percent-encoded. It is nil before configure, without a link domain, or for a slug that does not match ^[a-z0-9-]{1,64}$. The method only builds the URL, so the slug must belong to a link in your account.

Test and debug

Logs go to the unified log with subsystem to.appy.sdk, prefixed with [Appy]. Levels from quiet to verbose: .none, .error, .warning, .info, .debug. Check the association file and what Apple’s CDN serves to devices:

bash
curl -i https://acme.appy.to/.well-known/apple-app-site-association
curl -i https://app-site-association.cdn-apple.com/a/v1/acme.appy.to
  1. 1

    Delete the app

    Remove it from the iPhone you test with.

  2. 2

    Tap the link on the same phone

    Open an Appy link from Notes or Messages. It opens the App Store or your website; you do not need to install from there.

  3. 3

    Install your build

    Run it from Xcode or install it from TestFlight.

  4. 4

    Open the app

    With .debug logging the console shows the first-launch open, and onDeepLink 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. To test again, repeat the steps: each tap is a new click.

  • A link typed or pasted into Safari’s address bar opens the website. Universal Links react to taps.
  • Choosing “Open in Safari” turns Universal Links off for that domain on that device. Long-press a link and choose your app to turn them back on.
  • The Team ID registered with Appy must be the one that signs the build.
  • With strict attribution on, a local test install from Xcode reports .notFound.

API reference

AppyDescription
static func configure(_:)Starts the SDK. Call once, early.
var onDeepLink: ((AppyDeepLinkResult) -> Void)?Once on the first launch and for each Appy link that opens the app.
var onAttribution: ((AppyAttribution) -> Void)?Once, when Appy answers the first launch.
var attribution: AppyAttribution?The install’s attribution, kept across launches.
var latestDeepLink: AppyDeepLink?The most recent link.
func handle(url:) -> BoolResolves an Appy link; false for other URLs.
func handle(userActivity:) -> BoolResolves the webpageURL of a web-browsing activity.
func track(_:properties:revenue:currency:deduplicationId:)Queues an event.
func setUserId(_:)Sets or clears the user id.
var isTrackingEnabled: BoolPersisted consent switch, default true.
func link(slug:parameters:) -> URL?Builds a share link.
func flush()Sends queued events now.

All public methods are safe to call from any thread. Each request carries Authorization: Bearer <publishable key>; network errors, 429 and 5xx responses are retried up to three attempts in total.