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 | |
|---|---|
| Source | About 940 non-blank lines of Swift in 7 files |
| Dependencies | None. Foundation, UIKit and os only. |
| Binary size | About 161 KB of code and data in a stripped Release app (arm64), measured by linking a minimal app with and without the SDK. |
| Requests | One 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. |
| Threads | One private serial queue. Nothing touches the network on the main thread; callbacks arrive on the main thread. |
| Privacy | No 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
Add the package
In Xcode, choose File > Add Package Dependencies… and enter the repository URL you received.
- 2
Pick a version rule
Choose Up to Next Major Version from
1.0.0. - 3
Link the library
Add the AppySDK library to your app target.
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.
| Value | Where to find it |
|---|---|
| Apple Team ID | Apple Developer account > Membership details, for example ABCDE12345. It must be the team that signs your app. |
| Bundle ID | Xcode > your target > General > Bundle Identifier, for example com.acme.shop. |
| Subdomain | 3 to 32 lowercase letters, digits and hyphens. It becomes your link domain, <subdomain>.appy.to, and cannot be changed later. |
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.
<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.
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)
}
}| Property | Default | Meaning |
|---|---|---|
publishableKey | required | Your 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:). |
openDeferredDeepLinks | false | When true, the SDK also opens a deferred link’s deeplinkURL. See Let the SDK open the link. |
deferredDeepLinkTimeout | 5 s | How long the first launch waits for Appy’s answer before onDeepLink receives .failed(.timeout). |
logLevel | .error | See Test and debug. |
launchLinkWaitInterval | 0.5 s | How long a launch waits for a link before opening the session without one. |
requestTimeout | 10 s | Timeout per HTTP attempt. |
apiBaseURL | https://api.appy.to | Change only for staging. |
Pass incoming links to the SDK
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.
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)
}func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
connectionOptions.userActivities.forEach { Appy.shared.handle(userActivity: $0) }
connectionOptions.urlContexts.filter { !Appy.shared.handle(url: $0.url) }.forEach { MyURLRouter.open($0.url) }
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
Appy.shared.handle(userActivity: userActivity)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
URLContexts.filter { !Appy.shared.handle(url: $0.url) }.forEach { MyURLRouter.open($0.url) }
}WindowGroup {
RootView()
.onOpenURL { url in
if !Appy.shared.handle(url: url) { router.open(url) }
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { Appy.shared.handle(userActivity: $0) }
}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
launchLinkWaitIntervalafterconfigure, 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
configureare kept, up to 10, and resolved right after it. Configure early.
Handle deep links
onDeepLink is called on the main thread with an AppyDeepLinkResult:
| Result | Meaning |
|---|---|
.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). |
.notFound | First 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.
AppyDeepLink | Meaning |
|---|---|
url | The Appy link, for example https://acme.appy.to/spring?item=42. |
deeplinkURL | The in-app destination set on the link, for example acme://items?item=42. |
slug | The link’s slug, for example spring. |
parameters | The link’s query parameters, including any utm_*. Keys starting with appy_ are reserved and never included. |
isDeferred | true when the link was tapped before the app was installed. |
clickedAt | When the link was tapped, if known. |
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
onDeepLinkbefore or afterconfigure. 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.notFoundor.failed. - Latest link.
latestDeepLinkholds 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,
.foundcarries a link parsed from the URL itself, withoutdeeplinkURLandclickedAt. Make sure your routing also works fromslugandparameters. - Testing your routing. Build links with the public initializer, for example
AppyDeepLink(slug: "spring", parameters: ["item": "42"]). Results areEquatable.
Deferred deep links
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 fromconfigure) and then delivers.failed(.timeout). A later answer does not callonDeepLinkagain; its link goes tolatestDeepLinkand its attribution still reachesonAttribution. - Retries. Until Appy has answered a first launch, each launch asks again.
- Tracking off. While
isTrackingEnabledisfalse, 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
.foundarrives. Later launches without a link do not callonDeepLink, 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.
Let the SDK open the link
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.
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 withisDeferred == truethere 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:
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.
Appy.shared.onAttribution = { attribution in
if attribution.isOrganic {
Analytics.setUserProperty("acquisition", value: "organic")
} else {
Analytics.setUserProperty("acquisition", value: attribution.campaign ?? attribution.linkSlug ?? "appy")
}
}| Property | Meaning |
|---|---|
isOrganic | true when no Appy link led to the install. |
linkSlug, linkTitle | The link’s slug and its title in the dashboard. |
source, medium, campaign | The utm_source, utm_medium and utm_campaign of the tapped link, when it had them. |
clickedAt | When 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
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")| Rule | Otherwise |
|---|---|
| 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.
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
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.
Privacy and consent
| Data | Sent with |
|---|---|
| Install id: a random UUID created on first launch and deleted with the app | Opens, events |
| The Appy link that opened the app, and the first-launch and tracking flags | Opens |
| User id, if one is set and tracking is enabled | Opens, events |
| Platform, iOS version, model identifier, locale, time zone, screen size | Opens |
| Bundle id, app version, build | Opens |
| Event name, properties, revenue, currency, timestamp | Events |
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.
Waiting for consent
if !Consent.hasAnswered {
Appy.shared.isTrackingEnabled = false
}
Appy.configure(configuration)
Consent.onAnswer = { granted in
Appy.shared.isTrackingEnabled = granted
}- Set
isTrackingEnabled = falsebeforeconfigure, 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, itsdeferredDeepLinkTimeoutandonDeepLinkstart at that moment, also when the user consents on a later launch. The attribution follows throughonAttribution. - 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.
Build share links
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:
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.toTest a deferred deep link
- 1
Delete the app
Remove it from the iPhone you test with.
- 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
Install your build
Run it from Xcode or install it from TestFlight.
- 4
Open the app
With
.debuglogging the console shows the first-launch open, andonDeepLinkreceives.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. 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
Appy | Description |
|---|---|
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:) -> Bool | Resolves an Appy link; false for other URLs. |
func handle(userActivity:) -> Bool | Resolves 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: Bool | Persisted 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.