Start here: short installation guide · Full documentation
The Appy iOS SDK connects your app to Appy smart links:
- Deep links. Universal Links on your Appy link domain, and custom-scheme links opened by the Appy redirect page, reach your app as an
AppyDeepLinkwith the slug, the in-app destination and the link's parameters. - Deferred deep links. A link tapped before the app was installed is delivered on the first launch after install, through the same callback.
- Install attribution. The first launch tells you which link brought the install, with its campaign parameters, or that the install was organic.
- Events. In-app events, optionally with revenue, credited to the link that brought the install.
- Share links.
https://<your-domain>/<slug>?…URLs for links in your Appy account.
Building with Flutter or React Native? Use the Flutter plugin or react-native-appy, which wrap this SDK. For anything else, the SDK is a thin client over two HTTP endpoints: use the API directly.
- Lightweight by design
- Requirements and installation
- 1. Register your app with Appy
- 2. Enable Associated Domains
- 3. Add a custom URL scheme
- 4. Configure the SDK
- 5. Pass incoming links to the SDK
- 6. Handle deep links
- 7. Deferred deep links
- 8. Install attribution
- 9. Track events
- 10. Identify users
- 11. Privacy and opt-out
- 12. Build share links
- 13. Debugging
- API reference
- For maintainers: publishing
| Source | 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. The prelinked AppySDK.o of a Release build is 549 KB, most of it symbol data that does not ship. |
| 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, never before the launch open has finished, one request at a time. |
| Storage | Five keys in the to.appy.sdk user defaults suite, and one JSON file with at most 1,000 queued events |
| 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. |
- iOS 13.0 or later.
- Swift 5.9 or later (Xcode 15 or later). Built and tested with Xcode 26.6 and Swift 6.3.
- The package uses the Swift 5 language mode and works in both Swift 5 and Swift 6 apps.
- macOS 10.15 is supported only so the tests run on a Mac.
- An app registered with Appy on the Enterprise plan, and its publishable key (
appy_pk_…).
The SDK is a public Swift package. To add it in Xcode:
- Choose File > Add Package Dependencies… and enter
https://github.com/appy-to/appy-ios-sdk.git. - Pick Up to Next Major Version from
1.0.0. - Add the AppySDK library to your app target.
In a Package.swift:
dependencies: [
.package(url: "https://github.com/appy-to/appy-ios-sdk.git", from: "1.0.0")
],
targets: [
.target(name: "YourApp", dependencies: [.product(name: "AppySDK", package: "appy-ios-sdk")])
]For CocoaPods, add to your app target and run pod install:
pod 'AppySDK', :git => 'https://github.com/appy-to/appy-ios-sdk.git', :tag => '1.0.0'Appy needs to know which iOS app belongs to your links. It uses that to publish the Apple App Site Association (AASA) file for your link domain and to resolve links for your app. Register the app once, either under Apps & SDK in the Appy dashboard or by calling POST /v1/apps with a secret API key from your Appy account. Registering apps and using the SDK endpoints are part of the Enterprise plan; keys on other plans receive 403 plan_required. With the app's App Store Connect provider token (ios.providerToken), Appy also adds campaign parameters to App Store links so App Store Connect reports downloads per Appy link.
| Value | Where to find it |
|---|---|
| Apple Team ID | Apple Developer account > Membership details, a 10-character identifier such as ABCDE12345. It must be the team that signs your app. |
| Bundle ID | Xcode > your app target > General > Bundle Identifier, for example com.acme.shop. |
| Subdomain | Your choice of 3-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 full schema is in the API reference at https://api.appy.to/docs. If the same app also ships on Android, register both platforms on one app; see the Android SDK guide. The response contains the two values the SDK needs:
- Publishable key, starting with
appy_pk_. It works only with the SDK endpoints and is safe to ship in the app. Never put a secret key (appy_sk_…) in an app. - Link domain,
<subdomain>.appy.to, for exampleacme.appy.to. Appy serves your AASA file athttps://acme.appy.to/.well-known/apple-app-site-association, listingABCDE12345.com.acme.shop.
In Xcode, open your target's Signing & Capabilities, add Associated Domains, and enter applinks:acme.appy.to with your own link domain:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:acme.appy.to</string>
</array>Devices download the AASA file through Apple's CDN, which can lag behind by up to a day. While developing, add a second entry, applinks:acme.appy.to?mode=developer. A development-signed build then fetches 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.
In-app browsers (Instagram, Facebook, TikTok, many mail apps) often ignore Universal Links. The Appy redirect page then tries the link's deep link, such as acme://items/42, before falling back to the store. Register the scheme under your target's Info > URL Types, for example with identifier com.acme.shop and URL scheme acme.
Appy appends appy_click_id to the custom-scheme URLs it opens. 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 once, as early as possible: in application(_:didFinishLaunchingWithOptions:) or in your SwiftUI App initializer.
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. A key with any other prefix is logged as an error. Only the first 12 characters are ever logged. |
linkDomains |
[] |
Hosts of your Appy links, matched case-insensitively. The first one is used by link(slug:parameters:). Values like https://acme.appy.to/ and applinks:acme.appy.to are normalized to the host. |
emailLinkDomains |
[] |
Click-tracking domains of your email tool that open the app. See Links in marketing email. |
openDeferredDeepLinks |
false |
When true, the SDK also opens a deferred link's deeplinkURL for you. 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). Zero, negative and non-finite values use 5 s. |
apiBaseURL |
https://api.appy.to |
Change only for staging. |
logLevel |
.error |
See Debugging. |
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. |
A second configure call is ignored with an error log.
Both handle methods return true for Appy links, meaning 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.
UIApplicationDelegate (apps without scenes):
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)
}UISceneDelegate. A cold start delivers the link only in the connection options. The other two methods are called while the scene is running.
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) }
}SwiftUI. Depending on the modifiers attached, SwiftUI delivers a Universal Link through onOpenURL or onContinueUserActivity. Attach both:
@main
struct AcmeApp: App {
@StateObject private var router: DeepLinkRouter
init() {
let router = DeepLinkRouter()
_router = StateObject(wrappedValue: router)
Appy.configure(AppyConfiguration(publishableKey: "appy_pk_live_…", linkDomains: ["acme.appy.to"]))
Appy.shared.onDeepLink = { router.handle($0) }
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(router)
.onOpenURL { url in
if !Appy.shared.handle(url: url) { router.open(url) }
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { Appy.shared.handle(userActivity: $0) }
}
}
}Push notifications and in-app messages. Pass the link's URL to handle(url:) when the user taps the notification or the message; below, the payload carries it under a link key. An Appy link reaches onDeepLink as .found and counts as an app open for that link. Put the Appy link itself in the payload, not its deeplinkURL.
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
if let link = response.notification.request.content.userInfo["link"] as? String, let url = URL(string: link) {
Appy.shared.handle(url: url)
}
completionHandler()
}Email tools wrap every link in a click-tracking URL on their own domain. When that domain serves an apple-app-site-association file for your app, a tap opens the app with the tracking URL, not with your link. List the domain in emailLinkDomains, add applinks: for it to Associated Domains, and keep passing URLs to handle:
var configuration = AppyConfiguration(publishableKey: "appy_pk_live_…", linkDomains: ["acme.appy.to"])
configuration.emailLinkDomains = ["links.acme.com"]
Appy.configure(configuration)- The SDK requests the tracking URL once and reads its
Locationheader without following the redirect, the way SendGrid documents it, so your email tool counts the click. It keeps going only while the redirects stay on your email link domains, for at most 5 requests. - An Appy link behind it is resolved like any other:
.foundwith its slug, one open request, and on a cold start it is the launch link. - Any other destination reaches
onDeepLinkas.foundwith thaturl, an emptyslugand the URL's query parameters. Route it yourself. - When the email tool cannot be reached or does not redirect,
.foundcarries the tracking URL itself and an emptyslug. - Klaviyo's tracking domains need Klaviyo's own SDK; follow Klaviyo's guide for those.
The AASA generator has presets for SendGrid, Braze and Klaviyo tracking domains, and the validator traces a link from a test email.
How a launch is resolved:
- Each launch sends exactly one open request.
- If an Appy link arrives within
launchLinkWaitIntervalafterconfigure, the request carries it. Otherwise the request goes out without a URL when the interval ends. - An email link that arrives in that interval holds the request until the SDK knows where it points.
- 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. Until then the SDK cannot know your domains, so it returnstruefor anyhttporhttpsURL and any URL withappy_click_id. Configure early.
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. No usable answer: .timeout when Appy did not answer within deferredDeepLinkTimeout; .network when Appy could not be reached, or was busy or unavailable (HTTP 429 or 5xx) through all retries; .invalidResponse when Appy rejected the request (another 4xx, such as an unknown key) or answered with something unreadable. The log has the details. |
When it is called:
- on the first launch after install, exactly once, with one of the three results;
- every time an Appy link opens the app, with
.found; - on no other launch.
An AppyDeepLink has:
| Property | 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 |
SwiftUI, with the router from the previous section:
@MainActor
final class DeepLinkRouter: ObservableObject {
@Published var path: [Route] = []
func handle(_ result: AppyDeepLinkResult) {
guard case .found(let link) = result else { return }
if let itemId = link.parameters["item"] {
path = [.item(id: itemId)]
} else {
path = [.campaign(slug: link.slug)]
}
}
func open(_ url: URL) {
path = Route(url: url).map { [$0] } ?? []
}
}UIKit:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
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()
}
}
return true
}Parse deeplinkURL and navigate inside your app, or route on slug and parameters. Delivery rules:
- Callback timing. Set
onDeepLinkbefore or afterconfigure. A result that arrives while no callback is set is delivered once, as soon as you set one. When several arrive, the most recent link wins, and 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. - Offline fallback. When Appy cannot be reached for a link that opened the app,
.foundcarries a link parsed from the URL itself:urlis the URL,slugis its first path component on a link domain (empty otherwise),parametersare its query parameters, anddeeplinkURLandclickedAtarenil. 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, soresult == .notFoundworks in tests.
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 when no Appy link led to the install.
- Timeout. The first launch waits at most
deferredDeepLinkTimeout(5 seconds, counted fromconfigure) and then delivers.failed(.timeout). An answer that comes later does not callonDeepLinka second time. Its link goes tolatestDeepLink, and its attribution still reachesonAttribution. - Retries. Until Appy has answered a first launch, each launch asks again, and
onDeepLinkanswers again on that launch. - Tracking off. While
isTrackingEnabledisfalse, the SDK does not ask for a deferred link andonDeepLinkis not called for it. Turning tracking on starts the request and its timeout; see Waiting for consent. - What to show meanwhile. Most apps show their usual first screen and navigate when
.foundarrives. If you would rather hold a splash screen on your app's first run,onDeepLinkanswers withindeferredDeepLinkTimeout. Later launches without a link do not call it, 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, through application(_:open:options:), scene(_:openURLContexts:) or onOpenURL, 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. Other URLs are skipped with a warning in the log.
- It opens the link even if
onDeepLinkis not set, and only for a deferred link delivered within the timeout. - Links that open an installed app are never reopened; the system already delivered them.
- If you also navigate on
.foundinonDeepLink, skip links withisDeferred == truethere so the screen does not open twice.
Strict attribution is an app setting in the Appy 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 with Strict attribution on, a first launch from the home screen reports .notFound and the install counts as organic. Links that open the installed app keep arriving as .found.
onAttribution tells you where the install came from. It is called once, on the main thread, when Appy answers the first launch:
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 |
network |
Set when an ad network's own data attributed the install: name, campaignId, adGroupId, adId and the other fields the network reports. linkSlug is then nil. |
- Set
onAttributionbefore or afterconfigure. If it is set later during that launch, it receives the attribution once when you set it. Appy.shared.attributionkeeps the value across launches. It isniluntil Appy has answered the first launch.- The deep link timeout does not apply here: an answer that arrives after
.failed(.timeout)is still delivered.
On the first launch the SDK reads the Apple Ads attribution token from AdServices (iOS 14.3 and newer) and sends it with the launch. Appy asks Apple's attribution API on the server, which needs no App Tracking Transparency prompt and no IDFA, and stores the campaign, ad group, keyword and ad on the install. Apple's answer can take a few seconds, so it is not part of onAttribution; it appears on the install in the dashboard, in GET /v1/apps/{appId}/installs/{installId} as attribution.network, and in install postbacks. The SDK loads AdServices at run time, so there is nothing to link. Turn the lookup off with the Apple Ads setting of your app in the dashboard.
Attribution on the device is for analytics and personalization. Before you pay a referral reward, check the install from your backend with GET /v1/apps/{appId}/installs?userId=<user ID> (see Identify users), and pay when its attribution.verified is true, which means Appy confirmed the link itself: it opened the app, or the store passed it to the install.
Appy.shared.track("signup")
Appy.shared.track("add_to_cart", properties: ["sku": "A1", "qty": 2, "gift": false])
Appy.shared.track("purchase", properties: ["order_id": "ord_981"], revenue: Decimal(string: "19.98"), currency: "USD")| Rule | Otherwise |
|---|---|
| Name is 1-64 characters after trimming | The event is dropped (error log). |
| Property keys are 1-64 characters; values are strings, finite numbers or booleans | The property is dropped (debug log). |
| At most 50 properties | Properties after the first 50 in key order are dropped (debug log). |
| Strings are at most 512 characters | The string is truncated. |
revenue is finite and below 10^12 in absolute value, with a three-letter currency, which is uppercased for you |
revenue and currency are dropped (warning log). |
currency comes with revenue |
A currency without revenue is ignored. |
deduplicationId is 1-128 printable ASCII characters without spaces after trimming |
A generated ID is used and the event is kept (error log). |
Lengths count Unicode scalars. Negative revenue (refunds) is allowed. Create amounts with Decimal(string: "19.98"): a literal such as revenue: 19.98 passes through Double and can arrive as 19.980000000000001.
Each event gets an ID (your deduplicationId or a random one), a UTC timestamp and the current user ID. The queue lives in Application Support/AppySDK/events.json and survives restarts. The file is excluded from backups and holds at most 1,000 events, dropping the oldest first. The queue is sent:
- 2 seconds after the latest
trackcall; - immediately when 20 events are queued;
- when the app goes to the background, inside a background task;
- when you call
flush().
How sending works:
- Nothing is sent before the launch open has finished.
- Each batch holds at most 100 events and 256 KB.
- Accepted batches leave the queue.
- Events the server rejects one by one in a 202 response are logged as warnings and not resent, because they would never succeed.
- A 4xx other than 429 drops the whole batch with an error log.
- After network errors, 429 or 5xx, the events stay queued and are retried after 4, 8, 16… seconds (at most 5 minutes), or after the server's
Retry-After. - The server de-duplicates by event ID, so a resent batch counts once.
A purchase can reach your code more than once: StoreKit delivers a transaction again when the app quit before finish(), a restore replays it, and your backend may report it too. Pass the store's transaction ID as deduplicationId, and Appy counts the purchase once.
let result = try await product.purchase()
if case .success(.verified(let transaction)) = result {
Appy.shared.track(
"purchase",
properties: ["product_id": product.id],
revenue: product.price,
currency: product.priceFormatStyle.currencyCode,
deduplicationId: String(transaction.id)
)
await transaction.finish()
}- The ID is sent as the event's ID. Appy keeps one event per ID and app, so use an ID that belongs to a single purchase:
transaction.id, which is new for every renewal, notoriginalIDor the product ID. A later event with an ID Appy already has is not counted. - If your backend also reports purchases, for example from App Store Server Notifications through
POST /v1/apps/{appId}/events, pass the same transaction ID as the eventidthere. Appy then counts the purchase once, whichever side reports it first. - A
trackcall whose ID is still waiting in the queue is dropped with an info log; the queued event is the same purchase. - An ID that is empty, longer than 128 characters, or contains spaces or characters outside printable ASCII is logged as an error, and the event is sent with a generated ID.
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 with an error log and clears the stored ID. Use an opaque internal ID, not an email address.
What the SDK sends:
| Data | With |
|---|---|
Install ID: a random UUID created on first launch, stored in the app's to.appy.sdk suite 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, iOS version, model identifier such as iPhone15,2, locale |
Opens |
| Bundle ID, app version, build | Opens |
| Event ID, name, properties, revenue, currency, timestamp | Events |
As with any HTTPS request, the server also sees the request's IP address. Appy turns it into a daily-rotating keyed hash for deferred deep linking and does not store the address; see how attribution works and what is stored.
The SDK does not show an App Tracking Transparency prompt, does not read the IDFA and sends nothing to ad networks. Apple does not allow deriving data from a device to identify it uniquely, so review deferred deep linking with your privacy team. If you want only installs Appy can verify, turn on Strict attribution in the dashboard.
Appy.shared.isTrackingEnabled = falseThe flag 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, and with it the deferred deep link and the install attribution;
- links your app passes to
handle(url:)are still resolved, withtrackingEnabled: falseand without the user ID. The server stores nothing for them.
If you ask for consent first, see Waiting for consent.
The package ships PrivacyInfo.xcprivacy, which declares:
- no tracking;
- the collected data types Device ID (the install ID), User ID, Product Interaction, Purchase History, Advertising Data (Apple Ads attribution) and Other Data Types (device model, OS version and locale), linked to the user, not used for tracking, for app functionality and analytics;
- the
UserDefaultsAPI with reasonCA92.1.
You remain responsible for your App Store privacy details. Privacy labels lists the answers for App Store Connect and Google Play with strict and standard attribution.
Set isTrackingEnabled to false before configure, and to true once the user consents. The flag is persisted, so set it to false only while the user has not answered yet:
if !Consent.hasAnswered {
Appy.shared.isTrackingEnabled = false
}
Appy.configure(configuration)
Consent.onAnswer = { granted in
Appy.shared.isTrackingEnabled = granted
}Before consent the SDK sends no launch open, no events and no user ID. Links your app passes to handle(url:) are still resolved, and the server stores nothing for them.
When tracking turns on, the first-launch lookup, its deferredDeepLinkTimeout and onDeepLink start at that moment, also when the user consents on a later launch. The install attribution follows through onAttribution. If an Appy link opened the app earlier in the same launch, the lookup waits for the next launch.
To erase one user's installs and events, call DELETE /v1/apps/{appId}/installs?userId=<user ID> from your backend with a secret key. It removes every install and event tied to that user ID, so call setUserId after sign-in. Deleting is permanent; see the REST API reference.
let url = Appy.shared.link(slug: "spring", parameters: ["item": "42", "ref": referralCode])This returns https://acme.appy.to/spring?item=42&ref=…:
- the host is the first link domain;
- the slug is lowercased and must match
^[a-z0-9-]{1,64}$; - parameters are sorted by key and percent-encoded;
- the result is
nilbeforeconfigure, without a link domain, or for an invalid slug.
The method only builds the URL, so the slug must belong to a link in your account. Keys starting with appy_ are reserved.
Logs go to the unified log with subsystem to.appy.sdk and category Appy, prefixed with [Appy], and appear in the Xcode console. The levels, from quiet to verbose:
| Level | Logs |
|---|---|
.none |
Nothing |
.error |
Misconfiguration, failed requests with the server's code and message, dropped events, invalid deduplication IDs |
.warning |
Also rejected events, retries, invalid revenue, a first launch that timed out, and a deferred link the SDK did not open |
.info |
Also configuration, attribution, sent batches, duplicate events and local fallbacks |
.debug |
Also every request and decision |
In Console.app, filter on subsystem:to.appy.sdk and turn on Include Info Messages and Include Debug Messages.
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.toExpect 200, JSON, no redirect, and ABCDE12345.com.acme.shop in the applinks section. Settings > Developer > Universal Links > Diagnostics tests a URL on a device. xcrun simctl openurl booted https://acme.appy.to/spring opens one in the Simulator.
Common pitfalls:
- A link typed or pasted into Safari's address bar opens the website. Universal Links react to taps, so test from Notes or Messages.
- A tap on a page of the same domain stays in Safari.
- 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.
- A scene-based app gets the cold-start link in
connectionOptions, not inscene(_:continue:). - The Team ID registered with Appy must be the one that signs the build.
- The CDN caches the AASA file, so use
?mode=developerwhile developing. handle(url:)returnsfalsefor URLs that are not Appy links.- With
openDeferredDeepLinks, adeeplinkURLwhose scheme is not under URL Types is not opened; the log says so.
- Delete the app from the phone.
- On the same phone, tap an Appy link, for example in Notes or Messages. It opens the store or the link's fallback page; you do not need to install from there.
- Install your build from Xcode or TestFlight.
- Open the app.
Every install gets a new random install ID, so there is no test device to register and nothing to reset in Appy. To test again, repeat the same steps; each tap is a new click.
With configuration.logLevel = .debug, the console shows Appy's answer: Opening session (firstLaunch true, url none), then The install is attributed to spring., and onDeepLink receives .found.
Appy |
|
|---|---|
static let shared: Appy |
The SDK instance |
static let sdkVersion: String |
"1.0.0", also sent as X-Appy-SDK: ios/1.0.0 |
static func configure(_ configuration: AppyConfiguration) |
Starts the SDK. Call once, early. |
var onDeepLink: ((AppyDeepLinkResult) -> Void)? |
Called on the main thread, once on the first launch and for each Appy link that opens the app |
var onAttribution: ((AppyAttribution) -> Void)? |
Called on the main thread once, when Appy answers the first launch |
var attribution: AppyAttribution? { get } |
The install's attribution, kept across launches |
var latestDeepLink: AppyDeepLink? { get } |
The most recent link |
@discardableResult func handle(url: URL) -> Bool |
Resolves an Appy link; false for other URLs |
@discardableResult func handle(userActivity: NSUserActivity) -> Bool |
Resolves the webpageURL of a web-browsing activity |
func track(_ name: String, properties: [String: Any] = [:], revenue: Decimal? = nil, currency: String? = nil, deduplicationId: String? = nil) |
Queues an event |
func setUserId(_ userId: String?) |
Sets or clears the user ID (at most 128 characters) |
var isTrackingEnabled: Bool { get set } |
Persisted opt-out, default true |
func link(slug: String, parameters: [String: String] = [:]) -> URL? |
Builds a share link |
func flush() |
Sends queued events now, within the rules above |
The other public types:
AppyConfigurationhasinit(publishableKey:linkDomains:)and the properties in Configure the SDK.AppyDeepLinkResultis anEquatable,Sendableenum:found(AppyDeepLink),notFound,failed(AppyError).AppyDeepLinkisEquatableandSendable. It has the properties in Handle deep links and a public initializer where every argument exceptslughas a default.AppyAttributionisEquatableandSendable. It has the properties in Install attribution and a public initializer where every argument exceptisOrganichas a default.AppyErroris anError:timeout,network,invalidResponse.AppyLogLevelis anInt-backed,Comparableenum:none,error,warning,info,debug.
All public methods are safe to call from any thread. track, setUserId, flush and the setters return immediately. handle, link and the getters wait only for the SDK's queue, never for the network.
Each request carries Authorization: Bearer <publishable key>, Content-Type: application/json, Accept: application/json and X-Appy-SDK. Network errors, 429 and 5xx responses are retried up to three attempts in total, after about 1 and 2 seconds, or after a Retry-After of at most 10 seconds. Other 4xx responses are not retried.
The SDK lives in sdks/ios of the Appy monorepo. SwiftPM needs Package.swift at a repository root, so releases go to a dedicated repository split from that folder.
-
Update
Appy.sdkVersioninSources/AppySDK/Appy.swiftand add the release toCHANGELOG.md. The tag must equalAppy.sdkVersion. -
From
sdks/ios, runswift build,swift test,xcodebuild -scheme AppySDK -destination 'generic/platform=iOS Simulator' buildandxcodebuild test -scheme AppySDK -destination 'platform=iOS Simulator,name=<an installed iPhone simulator>'. -
If the source changed, remeasure Lightweight by design:
- count non-blank lines in
Sources/AppySDK/*.swift; - build with
xcodebuild -scheme AppySDK -destination generic/platform=iOS -configuration Release build CODE_SIGNING_ALLOWED=NO CLANG_COVERAGE_MAPPING=NOand take the size ofAppySDK.o; - link a minimal app that calls the public API against that
AppySDK.owithswiftc -target arm64-apple-ios13.0 -O … -Xlinker -dead_strip -Xlinker -map -Xlinker app.map, and add up the sizes of the symbols thatapp.mapattributes toAppySDK.o.
- count non-blank lines in
-
Commit, then publish the folder's history and the version tag from the monorepo root. Without
--pushthe script only prints the split commit:sdks/scripts/mirror-sdk.sh ios git@github.com:appy-to/appy-ios-sdk.git --push
git subtree splitproduces the same commits for the same history, so each release fast-forwardsmain. The script refuses to move a tag that already exists. -
AppySDK.podspecat the package root reads its version fromAppy.sdkVersion. CocoaPods trunk no longer accepts new pods, so CocoaPods users install withpod 'AppySDK', :git => '<repository URL>', :tag => '<version>'; check it withpod lib lint AppySDK.podspec. -
Add the package to a clean app project with File > Add Package Dependencies…, and with the
podline above, before announcing the release. The private monorepo release guide (sdks/RELEASING.md) has the order across all SDKs.