Apple SDK
Track user interactions across iOS, iPadOS, tvOS, macOS and watchOS, deliver APNs push from journeys, and manage configuration from the Intempt console.
Apple SDK
The Intempt Apple SDK tracks user interactions in your app, resolves experiments and recommendations, records consent, and registers devices for push notifications sent from Intempt journeys.
One package covers every Apple platform. The page is still called the iOS SDK in most places because that is where nearly all traffic comes from, but nothing here is iOS-only except the autocapture hooks, which need UIKit.
This SDK replaces the Objective-C SDK (intempt-intemptios), which is no longer
distributed. The API is not source-compatible — see
Migrating from the Objective-C SDK.
Requirements
| Swift | 5.9+ |
| Xcode | 15.0+ |
| iOS / iPadOS / tvOS | 15.0+ |
| macOS | 12.0+ |
| watchOS | 8.0+ |
The iOS floor is 15.0. A target still set to 14.x fails to build against the SDK
with compiling for iOS 14.0, but module 'Intempt' has a minimum deployment target of iOS 15.0.
Installation
Swift Package Manager
dependencies: [
.package(url: "https://github.com/intempt/intempt-swift.git", from: "0.1.0")
]In Xcode: File → Add Package Dependencies, enter
https://github.com/intempt/intempt-swift.git, and choose Up to Next Major Version
from 0.1.0.
CocoaPods
pod 'Intempt', '~> 0.1.0'A cross-platform wrapper depends on the same pod:
s.dependency 'Intempt', '0.1.0'Setup
Collect these from the Intempt console under Integrations → Sources, on the Apple source you are sending to:
| Value | Notes |
|---|---|
| Organization name | The orgId passed to initialize |
| Project name | The projectId passed to initialize |
| Source ID | Numeric. Also forms the push token attribute name |
| API key | Of the form prefix.secret |
Initialization
import Intempt
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
do {
let intempt = try IntemptInstance.initialize(
apiKey: "yourPrefix.yourSecret",
orgId: "your-org",
projectId: "your-project",
sourceId: "your-source-id"
)
intempt.autocapture.configure(.all)
intempt.autocapture.start()
} catch {
// Blank identifiers and malformed keys throw here rather than failing silently.
print("Intempt initialization failed: \(error)")
}
return true
}
}initialize is declared on IntemptInstance. The Intempt enum is a namespace for
constants (sdkVersion, defaultFeedFields), not an entry point.
It throws rather than logging and continuing: a blank orgId is an integration
error, and posting to a malformed URL forever is not a recovery. Calling it twice with
the same instance name returns the existing instance instead of building a second one.
Reach the same instance later from anywhere:
guard let intempt = IntemptInstance.mainInstance() else { return }SwiftUI
There is no AppDelegate in a pure SwiftUI lifecycle, so initialize from the App
initializer and keep the instance yourself:
@main
struct MyApp: App {
init() {
_ = try? IntemptInstance.initialize(
apiKey: "yourPrefix.yourSecret",
orgId: "your-org",
projectId: "your-project",
sourceId: "your-source-id"
)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Autocapture hooks UIViewController, so a pure SwiftUI view hierarchy produces few
screen events. Track screens explicitly with track(eventTitle:) in onAppear.
Capture
track()
Records a custom event.
intempt.track(eventTitle: "flight-booking", data: [
"flightId": 1,
"bookingDate": "2024-01-29",
"bookingStatus": "booked",
])record()
One call that carries user and account context together.
intempt.record(
eventTitle: "flight-booking",
userId: "xyz@intempt.com",
accountId: "intempt.com",
data: ["flightId": 1],
userAttributes: ["city": "New York", "country": "USA"],
accountAttributes: ["mtuCount": "1000+"]
)Every tracking method returns @discardableResult Bool — false means the event was
not queued, most often because the user has opted out.
Commerce
intempt.productView(productId: "2369423736890")
intempt.productAdd(productId: "2369423736890", quantity: 2)
intempt.productOrdered(products: [
(productId: "2369423736890", quantity: 2),
(productId: "2369423736891", quantity: 1),
])Consent
intempt.consent(action: .accept, validUntil: 31_536_000)
intempt.consent(action: .reject, validUntil: 0, category: "marketing")| Parameter | Type | Description |
|---|---|---|
action | ConsentAction | .accept or .reject |
validUntil | TimeInterval | Seconds the decision stays valid |
email | String? | Optional subject identifier |
message | String? | The exact wording the user agreed to |
category | String? | Scopes the decision, e.g. marketing |
.reject enforces the decision rather than only recording it: collection stops and
queued events are purged, the same gate as optOut().
The withdrawal record itself is still transmitted, even while opted out. It goes to its own endpoint, unbatched — it is the evidence the user objected, and dropping it would leave the objection recorded nowhere.
Identity and lifecycle
| Method | Sets | Signature |
|---|---|---|
identify() | The user | identify(userId:eventTitle:userAttributes:data:) |
group() | The account the user belongs to | group(accountId:eventTitle:accountAttributes:) |
alias() | Links two identities | alias(userId:anotherUserId:) |
intempt.identify(userId: "xyz@intempt.com", userAttributes: [
"city": "New York",
"plan": "pro",
])
intempt.group(accountId: "intempt.com", accountAttributes: [
"mtuCount": "1000+",
])
intempt.alias(userId: "xyz@intempt.com", anotherUserId: "legacy-id-42")group takes accountAttributes. The Objective-C SDK called this parameter
userAttributes while sending an account, which is worth knowing if you are porting
old code across.
Read the current identity back:
| Method | Returns | Description |
|---|---|---|
getProfileId() | String | Current anonymous or identified profile |
getSessionId() | String | Current session |
sdkVersion | String | SDK version |
Opt in / out
| Method | Returns | Description |
|---|---|---|
isUserOptIn() | Bool | Whether collection is permitted |
hasOptedOut() | Bool | The inverse |
optIn() | Void | Resume collection |
optOut() | Void | Stop collecting and discard what is queued |
logOut() | Void | Rotate to a fresh anonymous identity |
reset() | Void | New identity and an empty queue |
optOut() discards the queue as well as stopping collection. If you need the
events already gathered, flush() before opting out.
Delivery
| Method | Returns | Description |
|---|---|---|
flush() | Void | Send everything queued now |
flush { sent in } | Void | Completion carries the delivered count |
flushInterval | TimeInterval | Seconds between automatic flushes; 0 disables the timer |
Events are batched and flushed on a timer. flush { sent in } is the only way to
observe delivery — the tracking calls return whether an event was queued, not
whether it arrived.
Personalization
intempt.products(feedId: "9", count: 4, productId: "2369423736890") { result in
switch result {
case .success(let products): render(products)
case .failure(let error): print(error)
}
}fields: defaults to Intempt.defaultFeedFields on purpose. An unfielded request
returns every catalog column, including raw ML embedding vectors — measured at 443
times the payload size. Widen it deliberately, never by omission.
Experiments and personalizations are not in this SDK. There is no
server-side support for experiment assignment on any SDK — mobile or server-side
— so no Intempt SDK exposes it. The assignment endpoint answers 200 with an
empty set, which is indistinguishable from a profile that simply has no
assignments, so a client could never branch on a result.
Recommendation feeds are a separate capability against a separate endpoint and are unaffected.
Automatic events
Two mechanisms produce events without call sites, and they are configured differently. Mixing them up is the usual reason an expected event never arrives. This is the first; Autocapture is the second.
Lifecycle events. A settable property, no start call, and sessions are already on.
| Event | Emitted when | Option |
|---|---|---|
| Session start | A new session begins | sessions (on by default) |
| Session end | 5 minutes of inactivity, or the app closes | sessions (on by default) |
| App Install/Upgrade | First launch after an install or a version change | versionChanges |
| Application Opened | The app enters the foreground | appStateChanges |
| Application Backgrounded | The app leaves it | appStateChanges |
intempt.automaticEvents = AutomaticEventOptions(
sessions: true,
versionChanges: true,
appStateChanges: true
)Autocapture
UI interactions. Needs configure(_:) and start(), and everything is off until
you call them, because it works by swizzling UIKit.
| Event | Emitted when | Option |
|---|---|---|
| View screen | A UIViewController appears | screens |
| Leave screen | A UIViewController disappears, carrying how long it was visible | screenExits |
| Action | A UIControl fires an action — a button press | taps |
| Edit Field | A UIControl value changes — a switch, slider or text field | controlChanges |
| Touch | A tap that does not land on a UIControl | rawTouches |
Autocapture is iOS and tvOS only; it needs UIKit.
taps and rawTouches are deliberately separate. A button press already produces an
Action, so counting it as a Touch as well would double-count every press.
Push notifications
Intempt sends push straight to APNs. There is no Firebase or FCM dependency at any layer, on the device or on the server.
1. Register the device
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
IntemptInstance.mainInstance()?.setPushToken(deviceToken)
}Pass the raw Data. Do not stringify it first — since iOS 13 the widespread
deviceToken.description idiom produces the literal string "32 bytes", not a token.
setPushToken rejects anything too short to be an APNs token and returns false
rather than registering it.
The token is sent as a user attribute named apns_token_<sourceId> on an App
Install/Upgrade event. The per-source name is deliberate: one device can be registered
against several sources, and a flat name would collide.
2. Attribute opens and receipts
// UNUserNotificationCenterDelegate — the user tapped the notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
IntemptInstance.mainInstance()?.trackPushOpen(response.notification.request.content.userInfo)
completionHandler()
}
// Silent or foreground arrival
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
IntemptInstance.mainInstance()?.trackPushReceived(userInfo)
completionHandler(.newData)
}Only the campaign identifier and the developer-authored title are read from the payload. The notification body is never collected.
3. Configure the credentials in Intempt
Push is delivered with a token-based APNs key (.p8), not a certificate — one key
works for every app in your team and does not expire annually.
| Field | Where it comes from | Notes |
|---|---|---|
| Signing key | Apple Developer → Keys → a key with Apple Push Notifications service enabled | The .p8 file. Downloadable only once |
| Key ID | Shown beside the key | Exactly 10 alphanumeric characters |
| Team ID | Apple Developer → Membership | Exactly 10 alphanumeric characters |
| Bundle ID | Your app's bundle identifier | Becomes the APNs topic |
| Sandbox | Toggle | On for builds signed with a development profile |
Two mistakes account for most failures, and Apple reports both as the same opaque error hours later:
- Pasting the key filename into Key ID.
AuthKey_ABC1234567.p8contains the right value but is not it. Intempt rejects this at save time. - Wrong environment. A token minted by a development build is not valid against
production APNs, and the resulting
400 BadDeviceTokenis indistinguishable from a malformed token. Match the Sandbox toggle to how the build was signed.
4. Send from a journey
Add the Apple push destination to a journey. It resolves each profile's
apns_token_<sourceId> attribute, so a device only becomes reachable after the app has
called setPushToken at least once and the event has flushed.
Configuration reference
Everything configurable, and where it lives. Unlike the server SDKs there is no
options object — initialize takes credentials only, and behaviour is set on the
instance afterwards.
| setting | type | default | what it does |
|---|---|---|---|
apiKey | String | — | a public key, <prefix>.<secret> |
orgId | String | — | your organization name, from the console URL |
projectId | String | — | your project name, from the console URL |
sourceId | String | — | the source events are attributed to |
instanceName | String | "default" | names the instance. mainInstance() returns the "default" one |
flushInterval | TimeInterval | 60 | seconds between automatic flushes. 0 disables the timer |
automaticEvents | AutomaticEventOptions | sessions on, others off | lifecycle events — see Automatic events |
autocapture | AutocaptureOptions | all off | UI interactions. Installs nothing until start() |
let intempt = try IntemptInstance.initialize(
apiKey: "yourPrefix.yourSecret",
orgId: "your-org",
projectId: "your-project",
sourceId: "your-source-id")
intempt.flushInterval = 30
intempt.automaticEvents = AutomaticEventOptions(sessions: true, versionChanges: true)
intempt.autocapture.configure(.all)
intempt.autocapture.start()Logging
Off by default. Nothing is written to the console unless you ask for it.
IntemptLogger.shared.addLogging(IntemptPrintLogger())
IntemptLogger.shared.enableAllLevels() // or .enable(.warning)Levels are .debug, .info, .warning, .error. The logger never writes API
keys or event bodies.
Queue limits
| Queue ceiling | 5,000 events — beyond that the oldest are evicted |
| Batch deletion | only after the server acknowledges; never before the request |
| Storage | SQLite WAL under Library/, excluded from backup |
Privacy manifest
The SDK ships PrivacyInfo.xcprivacy inside its bundle, which Apple requires of
a third-party SDK. You do not need to add one for Intempt — but you do need to
account for what it declares when you fill in your own App Store privacy
answers.
| Declaration | Value |
|---|---|
NSPrivacyTracking | false — the SDK does not track across apps or websites |
| Collected data types | Product interaction, User ID, Device ID |
Accessed API — UserDefaults | reason CA92.1 (access to the app's own defaults) |
| Accessed API — disk space | reason E174.1 (writing the event queue) |
NSPrivacyTracking is false because nothing here joins a user across other
companies' apps. If your app links Intempt data to a third-party advertising
identifier, that is your declaration to make, not this one.
Property types
Event and attribute values conform to IntemptType: String, Int, UInt, Double,
Float, Bool, Date, URL, NSNull, NSNumber, and arrays or dictionaries of the
same, nested to any depth.
Values that cannot survive the wire — NaN, infinity — are rejected at the call
boundary rather than serialized as the string "nan".
Data on disk
Events are queued in SQLite (WAL mode) under Library/, excluded from iCloud and
iTunes backup, with data protection set to COMPLETEUNTILFIRSTUSERAUTHENTICATION so a
background flush cannot hang on a locked screen.
The queue is capped at 5,000 events; beyond that the oldest are evicted, so an offline device cannot fill the user's disk. A batch is deleted only after the server acknowledges it — never before the request, and never as a blanket delete.
Errors and troubleshooting
| Issue | Solution |
|---|---|
no such module 'Intempt' when resolving the package | Clean the package cache (File → Packages → Reset Package Caches) and confirm the target actually links the Intempt product |
compiling for iOS 14.0, but module 'Intempt' has a minimum deployment target of iOS 15.0 | Raise the target's iOS Deployment Target to 15.0 |
setPushToken returns false | You passed a stringified token. Pass the raw Data from the registration callback |
| Push never arrives, no error | The device has not registered. Confirm setPushToken ran and the event flushed, then check the profile carries apns_token_<sourceId> |
APNs reports BadDeviceToken | The Sandbox toggle does not match how the build was signed |
| No autocapture events | Autocapture is opt-in — call configure(...) and start() |
| No data in the console | Events batch periodically. Call flush { sent in } to send now and read the count |
| Events stop after a consent prompt | .reject enforces the decision and purges the queue. Call optIn() or record an .accept |
| Field type errors | Match schema types exactly. New fields are fine; the type of an existing field cannot change |
Migrating from the Objective-C SDK
| Objective-C SDK | Apple SDK |
|---|---|
Intempt.initialize(_:projectName:sourceId:apiKey:intemptConfig:) | IntemptInstance.initialize(apiKey:orgId:projectId:sourceId:) |
IntemptConfig(queueEnabled:withItemsInQueue:...) | flushInterval; batching is automatic |
| Completion handler on every call | Synchronous Bool; flush { sent in } for delivery |
Intempt.track(_:data:completion:) | track(eventTitle:data:) |
chooseExperiments(...) / choosePersonalizations(...) | removed — no server-side experiment support on mobile |
recommendation(_:fields:quantity:productId:) | products(feedId:count:fields:productId:) |
enableLogging() / disableLogging() | IntemptLogger levels |
startTrackingSession() / endTrackingSession() | Managed automatically |
disableTextInput(true) | AutocaptureOptions.controlChanges = false |
Data types are unchanged: [String: Any] dictionaries become [String: IntemptType],
which accepts the same values with compile-time checking.
