iOS SDK
Native Swift deep linking, deferred deep linking, first-touch attribution, privacy-tiered enrichment, events, and campaign link generation.
Jump to
- iOS: deployment target 12 or later.
- Package: Deeplinkly 1.2.1 through Swift Package Manager or CocoaPods.
- API key: create or copy one in App Settings.
- Frameworks: UIKit and SwiftUI integrations are supported.
Choose Swift Package Manager or CocoaPods and link the Deeplinkly product to your app target.
Swift Package Manager
In Xcode choose File → Add Package Dependencies, enter https://github.com/Deeplinkly/ios_deeplinkly, and add the Deeplinkly product to the app target. For a package manifest:
dependencies: [
.package(
url: "https://github.com/Deeplinkly/ios_deeplinkly.git",
from: "1.2.1"
)
].product(name: "Deeplinkly", package: "ios_deeplinkly")CocoaPods
pod 'Deeplinkly', '~> 1.2'Run pod install, open the generated .xcworkspace, and import Deeplinkly from Swift.
<key>DeeplinklyApiKey</key>
<string>your_api_key_here</string>
<key>DeeplinklyLinkDomains</key>
<array>
<string>yourbrand.deeplinkly.com</string>
<string>links.yourbrand.com</string>
</array>The API key is required. Link domains are strongly recommended and are required for deferred links on custom domains. Subdomains of a configured domain also match. Without the domain key, automatic pasteboard restore accepts only deeplinkly.com and its subdomains.
| Optional key | Default | Purpose |
|---|---|---|
| DeeplinklyAttributionLevel | full | Initial full, reduced, minimal, or none device-signal tier. |
| DeeplinklyCheckPasteboardOnInstall | true | Run the once-per-install automatic deferred-link read. |
| DeeplinklyEnableIDFA | false | Permit IDFA collection after your ATT prompt is authorized. |
Custom-scheme fallback
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>In the app target's Signing & Capabilities tab, add Associated Domains and one entry per Deeplinkly link domain:
applinks:yourbrand.deeplinkly.com
applinks:links.yourbrand.comApp Settings must contain the exact bundle ID and Apple team ID used to sign the app. Deeplinkly then serves the matching file at https://<your-link-domain>/.well-known/apple-app-site-association.
Test by tapping from another app
Use Notes, Messages, Mail, or another app. Pasting a URL into Safari's address bar is browser navigation and does not exercise Universal Link handoff. Delete and reinstall the app after association changes because iOS caches the result.
Attach one listener before initialization and forward each URL entry point used by your app lifecycle.
import Deeplinkly
final class DeepLinkRouter: DeeplinklyDeepLinkListener {
func onDeepLink(_ payload: [String: Any]) {
let clickId = payload["click_id"] as? String
let params = payload["params"] as? [String: Any] ?? [:]
if params["screen"] as? String == "product",
let productId = params["product_id"] as? String {
openProduct(productId)
}
}
private func openProduct(_ id: String) {
// Integrate with your router or navigation coordinator.
}
}The payload contains click_id and routing params. Delivery always occurs on the main thread. Links resolved before listener attachment are buffered, and duplicate arrivals are deduplicated.
AppDelegate
import Deeplinkly
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
private let router = DeepLinkRouter()
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Deeplinkly.setDeepLinkListener(router)
Deeplinkly.initialize()
return true
}
func application(
_ application: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
Deeplinkly.handleLink(url)
return true
}
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard let url = userActivity.webpageURL else { return false }
Deeplinkly.handleLink(url)
return true
}
}SceneDelegate
Scene-based apps receive cold-start links in connectionOptionsand warm links in the other two callbacks. Keep SDK initialization in the application delegate and forward all three scene paths:
import Deeplinkly
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
for context in connectionOptions.urlContexts {
Deeplinkly.handleLink(context.url)
}
for activity in connectionOptions.userActivities {
if let url = activity.webpageURL {
Deeplinkly.handleLink(url)
}
}
}
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
for context in contexts {
Deeplinkly.handleLink(context.url)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard let url = userActivity.webpageURL else { return }
Deeplinkly.handleLink(url)
}
}SwiftUI
Use @UIApplicationDelegateAdaptor for initialization and onOpenURL for URL delivery:
import Deeplinkly
import SwiftUI
import UIKit
final class DeeplinklyAppDelegate: NSObject, UIApplicationDelegate {
let router = DeepLinkRouter()
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Deeplinkly.setDeepLinkListener(router)
Deeplinkly.initialize()
return true
}
}
@main
struct ExampleApp: App {
@UIApplicationDelegateAdaptor(DeeplinklyAppDelegate.self)
private var appDelegate
var body: some Scene {
WindowGroup {
RootView()
.onOpenURL { Deeplinkly.handleLink($0) }
}
}
}- A redirect-based URL contains a
click_id. The SDK resolves it regardless of whether the final URL uses HTTP, HTTPS, or a custom scheme. - Direct Universal Links resolve their first path segment as a short code only when the host matches
DeeplinklyLinkDomains. Without that key, every HTTP(S) URL passed to the SDK is eligible. - Custom-scheme URLs without
click_idare ignored, so an app-owned route such asyourapp://settings/notificationsremains yours.
Allowlist mixed-purpose domains
Set DeeplinklyLinkDomains whenever the app has Universal Link entitlements for non-Deeplinkly hosts, or a marketing page's first path segment could look like a Deeplinkly short code.
Because iOS has no install-referrer API, Deeplinkly restores a pre-install destination through the pasteboard.
The interstitial copies the link after a user gesture. On first launch the SDK can restore it automatically, or your app can offer a user-initiated iOS 16+ paste control.
Automatic read
Enabled by default and run once per install. The SDK first checks for a URL without showing a banner; reading the actual URL can then show iOS's “Pasted from…” banner. Matching links are queued before the pasteboard is cleared, so an offline first launch can retry later.
Disable the initialization-time read when your UI controls the consent flow:
<key>DeeplinklyCheckPasteboardOnInstall</key>
<false/>Deeplinkly.setCheckPasteboardOnInstall(true, checkNow: false)
Deeplinkly.willShowPasteboardBanner { willShow in
guard willShow else {
Deeplinkly.checkPasteboardNow()
return
}
presentPasteboardExplanation {
Deeplinkly.checkPasteboardNow()
}
}willShowPasteboardBanner reads no content and shows no banner. It returns false when the check is disabled, completed, tracking is off, or no URL-like content exists.
Banner-free UIPasteControl on iOS 16+
import Deeplinkly
import UIKit
@available(iOS 16.0, *)
final class DeeplinklyPasteControlView: UIView {
var onResult: ((Bool) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let configuration = UIPasteControl.Configuration()
configuration.displayMode = .iconAndLabel
let control = UIPasteControl(configuration: configuration)
control.target = self
control.translatesAutoresizingMaskIntoConstraints = false
addSubview(control)
NSLayoutConstraint.activate([
control.centerXAnchor.constraint(equalTo: centerXAnchor),
control.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
itemProviders.contains {
$0.hasItemConformingToTypeIdentifier("public.url") ||
$0.hasItemConformingToTypeIdentifier("public.plain-text")
}
}
override func paste(itemProviders: [NSItemProvider]) {
Deeplinkly.handlePaste(itemProviders: itemProviders) { [weak self] handled in
self?.onResult?(handled)
}
}
}The resolved destination still arrives through onDeepLink(_:). The completion reports only whether the pasted content was a valid URL for an allowed domain. Unlike the automatic path, an explicit paste does not clear the user's pasteboard item.
Tracking opt-out behavior
Automatic pasteboard reading is skipped while tracking is disabled. A user-initiated paste remains available as an explicit deep-link action, while device enrichment stays suppressed.
let attribution = Deeplinkly.getInstallAttribution() // first-touch, write-once
let deeplinklyId = Deeplinkly.getDeeplinklyId() // stable local install ID
Deeplinkly.setUserId("user_123")
Deeplinkly.setUserId(nil) // clear on logoutFirst-touch attribution is empty until the first link resolves and is never overwritten by later links. It can contain click_id, source, UTM values, and gclid, fbclid, or ttclid. Clear your custom user ID on logout.
The fields a conversion is matched on once it reaches Meta's Conversions API or Google's enhanced conversions.
Deeplinkly.setUserData(
userId: "user_123",
email: "ada@example.com",
phoneNumber: "+441234567890",
firstName: "Ada",
lastName: "Lovelace",
city: "London",
country: "GB"
) // false if any field was malformed, in which case nothing was storedEvery field is optional and each call merges, so you can supply an email at sign-up and an address at checkout. A malformed field rejects the whole call — nothing is stored — so you never have to guess which of the values took.
Supply only what your own privacy policy and consent flow allow; the SDK cannot know what you told your users. These fields survive a reduced downgrade, because attribution levels gate what the SDK observes about a device and an email someone typed into your app is not an observation. At none nothing is sent.
dateOfBirth:YYYY-MM-DD.gender:"m"or"f"— the only two values Meta'sgeaccepts. Anything else is refused rather than coerced.country: ISO-3166-1 alpha-2, for example"US".- Every field has a maximum length, enforced before anything is stored.
Your own identifiers
customData carries identifiers Deeplinkly does not name — typically product-analytics ids such as a Mixpanel distinct id or a CleverTap id. Attach a new identifier anytime; no app release required.
Deeplinkly.setUserData(
userId: "user_123",
customData: [
"mixpanel_distinct_id": "d-8837",
"clevertap_id": "ct-4412",
]
)Up to 10 entries, keys up to 64 characters and values up to 256. Anything larger rejects the whole call, exactly as one bad typed field does.
Erasing it
Deeplinkly.clearUserData() // erases everything setUserData and setUserId recorded
Deeplinkly.setUserId(nil) // clears only the idThis is not merely “stop sending”: the next enrichment reports each previously-set field as empty, which the service reads as null this column. The erasure is re-sent until it is delivered, so calling it on a device that is offline still takes effect once it is not.
Deeplinkly.setAttributionLevel(.reduced)
let level = Deeplinkly.getAttributionLevel()
Deeplinkly.setTrackingEnabled(false)
let enabled = Deeplinkly.isTrackingEnabled()| Level | Device information sent |
|---|---|
| full | All catalogued signals. IDFA still requires explicit opt-in and authorized ATT status. |
| reduced | Coarse app, OS, locale, timezone, environment, and campaign context; high-entropy hardware and ad identifiers are removed. |
| minimal | Install and app identity plus link identity, with no descriptive device profile. |
| none | No enrichment or event device block. Links and the event itself still work. |
To start restricted before initialization, set the Info.plist default:
<key>DeeplinklyAttributionLevel</key>
<string>reduced</string>The selected level persists. Disabling tracking takes precedence: no enrichment, events, or SDK error reports are sent, and automatic pasteboard reading is skipped. Deep links and generated-link requests continue to work. Set consent before initialize() when it must govern first-launch work.
Hashing identifiers on the device
Off by default. With it on, the email, phone number and names given to setUserData are SHA-256 hashed before they are sent, so plaintext never reaches Deeplinkly.
Deeplinkly.setPIIHashingEnabled(true) // SHA-256 on device before sending
Deeplinkly.isPIIHashingEnabled() // off unless you turned it onOnly those four are hashed. Gender, country and date of birth are not: their value ranges are small enough that a digest is reversed by enumerating them, so hashing them would be protection in appearance only.
It costs attribution quality
A digest is computed once, under one normalisation, and advertising destinations disagree about phone formatting — so a conversion forwarded to a destination whose rules differ will not match, and the service can no longer re-derive per destination because the value it would need is gone. Turn it on when a compliance requirement says plaintext must not reach a processor, not by default.
Privacy manifest and IDFA
SwiftPM and CocoaPods bundle the SDK's required-reason PrivacyInfo.xcprivacy automatically. The SDK does not request App Tracking Transparency permission and IDFA collection is off by default.
- Set
DeeplinklyEnableIDFAto true in Info.plist. - Add
NSUserTrackingUsageDescription. - Request ATT authorization in your app's own consent flow.
- Merge the IDFA declarations from the repository's
Resources/IDFA/PrivacyInfo.xcprivacytemplate into the app's privacy manifest.
IDFA is collected only when the opt-in key is enabled, the active attribution level permits it, and ATT status is authorized.
Events are validated before a request is made; transient failures enter the retry queue.
Deeplinkly.logEvent(
"purchase",
parameters: [
"order_id": "ord_42",
"amount": 49.99,
"currency": "USD",
"is_first_purchase": true,
]
) { accepted in
// Main thread. False means validation or delivery failed.
}- Trimmed event name: non-empty and at most 64 UTF-16 code units.
- At most 25 caller parameters.
- Keys: non-empty and at most 64 UTF-16 code units.
- Keys beginning with
_dl_are reserved. - Values may be strings, numbers, booleans, JSON-compatible arrays, or string-keyed JSON-compatible dictionaries.
NSNullis rejected. - Strings and compact-encoded arrays or dictionaries are limited to 256 UTF-16 code units.
At level .none, the event is sent without a device block. With tracking disabled, it is not sent.
Deeplinkly.logPurchase(
value: 49.99,
currency: "USD",
orderId: "ord_42",
quantity: 1,
productId: "sku_9"
) { accepted in /* optional */ }Not a separate pipeline: it sends the event named purchase with value and currency set, and everything true of logEvent — the retry queue, the parameter limits, the device block — is true of this too.
value and currency are what Meta's Conversions API and Google's enhanced conversions both key off, so this one call feeds both without you having to match their spelling.
Rejected, sending nothing, if the value is negative or not finite (a refund is a different event, not a negative purchase), the currency is not three letters, the quantity is negative, or parameters contains any of the keys this method sets.
Pass orderId
It is what Google deduplicates conversions on, and how you reconcile a forwarded conversion against your own records.
let payload: [String: Any] = [
"content": [
"canonical_identifier": "product/sku_42",
"title": "Pro Plan",
"description": "Upgrade to Pro",
"image_url": "https://example.com/images/pro.png",
"metadata": ["screen": "product", "product_id": "sku_42"],
],
"options": [
"channel": "email",
"feature": "upgrade_campaign",
"tags": ["spring", "sale"],
],
]
Deeplinkly.generateLink(payload: payload) { result in
if result["success"] as? Bool == true {
let url = result["url"] as? String
} else {
let code = result["error_code"] as? String
let message = result["error_message"] as? String
}
}canonical_identifier, channel, and feature are required. tags is an array of strings. Completion always runs on the main thread and always receives a result map.
| API | Purpose |
|---|---|
| initialize() | Initialize from DeeplinklyApiKey in Info.plist. |
| initialize(apiKey:) | Initialize from a key supplied by the app; the first call wins. |
| isEnabled / version | Read initialization state and native SDK version. |
| setDeepLinkListener(_:) | Attach or detach the resolved-link listener. |
| handleLink(_:) | Forward a Universal Link or custom-scheme URL. |
| getInstallAttribution() | Read the persisted first-touch attribution map. |
| getDeeplinklyId() | Read or create the stable local install identifier. |
| setUserId(_:) | Set or clear the app's custom user identifier. |
| setUserData(...) | Merge the conversion-matching fields, plus your own customData ids. |
| clearUserData() | Erase everything setUserData and setUserId recorded, here and on the server. |
| logEvent(_:parameters:completion:) | Validate and report a custom event. |
| logPurchase(value:currency:...) | Report revenue under the one spelling every destination is built from. |
| generateLink(payload:completion:) | Create a Deeplinkly URL. |
| setTrackingEnabled(_:) | Enable or disable all reporting. |
| setAttributionLevel(_:) | Set the persistent device-signal tier. |
| setPIIHashingEnabled(_:) | SHA-256 the identifying fields on device before they are sent. Off by default. |
| isPIIHashingEnabled() | Whether on-device hashing is on. |
| setCheckPasteboardOnInstall(_:checkNow:) | Configure automatic deferred-link reading. |
| willShowPasteboardBanner(completion:) | Probe whether an automatic read would show the system banner. |
| checkPasteboardNow() | Run the enabled automatic pasteboard check. |
| handlePaste(itemProviders:completion:) | Handle a user-initiated UIPasteControl action. |
| setDebugMode(_:) | Enable or disable verbose SDK logs. |
| shutdown() | Detach the current listener. |
initialize(apiKey:) is useful when build configuration supplies the key. Initialization is idempotent, so the first call wins. Native apps generally do not need takePendingLink(); initialization flushes buffered URLs through the listener automatically.
Deeplinkly.setDebugMode(true)
print(Deeplinkly.version)
print(Deeplinkly.isEnabled)onDeepLink never runs
Confirm isEnabled is true, attach the listener at launch, forward custom-scheme and Universal Link callbacks, and handle SceneDelegate connectionOptions for cold starts.
Universal Links open Safari
Check the signed Associated Domains entitlement, dashboard domain/bundle/team values, and the association endpoint without redirects. Reinstall after changes and test by tapping from another app.
A deferred link is not restored
Allowlist the exact link host, confirm the App Store action was tapped, and check whether automatic reading was disabled, already completed, or suppressed by tracking opt-out.
The paste banner appears
iOS owns this banner when an app reads pasteboard content. Disable the automatic install check and use UIPasteControl on iOS 16+ for a fully user-initiated restore.
Source repository
The native package, CocoaPods specification, complete integration guide, device-signal catalogue, privacy manifests, and tests are open source.
View ios_deeplinkly on GitHub →