iOS SDK

Native Swift deep linking, deferred deep linking, first-touch attribution, privacy-tiered enrichment, events, and campaign link generation.

Requirements

  • 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.

Install

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:

Package.swift dependency
dependencies: [
    .package(
        url: "https://github.com/Deeplinkly/ios_deeplinkly.git",
        from: "1.2.1"
    )
]
target dependency
.product(name: "Deeplinkly", package: "ios_deeplinkly")

CocoaPods

Podfile
pod 'Deeplinkly', '~> 1.2'

Run pod install, open the generated .xcworkspace, and import Deeplinkly from Swift.

Configure Info.plist

Set the API key, allowlisted link domains, and optional custom URL scheme.

Info.plist
<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 keyDefaultPurpose
DeeplinklyAttributionLevelfullInitial full, reduced, minimal, or none device-signal tier.
DeeplinklyCheckPasteboardOnInstalltrueRun the once-per-install automatic deferred-link read.
DeeplinklyEnableIDFAfalsePermit IDFA collection after your ATT prompt is authorized.

Custom-scheme fallback

Info.plist
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>yourapp</string>
    </array>
  </dict>
</array>

Which URLs the SDK claims

The SDK distinguishes Deeplinkly redirects from app-owned routes.

  • 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_id are ignored, so an app-owned route such as yourapp://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.

Deferred deep linking

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:

Info.plist
<key>DeeplinklyCheckPasteboardOnInstall</key>
<false/>
explain before reading
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+

DeeplinklyPasteControlView.swift
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.

Attribution and identity

identity
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 logout

First-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.

User data

The fields a conversion is matched on once it reaches Meta's Conversions API or Google's enhanced conversions.

setUserData
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 stored

Every 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's ge accepts. 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.

customData
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

clearUserData
Deeplinkly.clearUserData()   // erases everything setUserData and setUserId recorded
Deeplinkly.setUserId(nil)    // clears only the id

This 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.

Attribution levels, consent, and IDFA

Control device context independently from link resolution.

runtime controls
Deeplinkly.setAttributionLevel(.reduced)
let level = Deeplinkly.getAttributionLevel()

Deeplinkly.setTrackingEnabled(false)
let enabled = Deeplinkly.isTrackingEnabled()
LevelDevice information sent
fullAll catalogued signals. IDFA still requires explicit opt-in and authorized ATT status.
reducedCoarse app, OS, locale, timezone, environment, and campaign context; high-entropy hardware and ad identifiers are removed.
minimalInstall and app identity plus link identity, with no descriptive device profile.
noneNo enrichment or event device block. Links and the event itself still work.

To start restricted before initialization, set the Info.plist default:

Info.plist
<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.

PII hashing
Deeplinkly.setPIIHashingEnabled(true)   // SHA-256 on device before sending
Deeplinkly.isPIIHashingEnabled()       // off unless you turned it on

Only 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.

  1. Set DeeplinklyEnableIDFA to true in Info.plist.
  2. Add NSUserTrackingUsageDescription.
  3. Request ATT authorization in your app's own consent flow.
  4. Merge the IDFA declarations from the repository's Resources/IDFA/PrivacyInfo.xcprivacy template 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.

Custom events

Events are validated before a request is made; transient failures enter the retry queue.

purchase event
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. NSNull is 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.

Purchases

A typed wrapper over logEvent, so revenue is spelled the same way by every caller.

logPurchase
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.

Public API reference

APIPurpose
initialize()Initialize from DeeplinklyApiKey in Info.plist.
initialize(apiKey:)Initialize from a key supplied by the app; the first call wins.
isEnabled / versionRead 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.

Debugging and troubleshooting

development diagnostics
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 →