iOS SDK Integration
Native iOS SDK for tracking events, managing user sessions, sending push notifications, and displaying in-app messages.
Features
- Lightweight - Minimal footprint
- Fast - Optimized for performance
- Offline Support - SQLite-based event queue
- Auto-Retry - Exponential backoff on failures
- Batching - Efficient event batching (50 events / 5s)
- In-App Messaging - native and HTML messages, 5 types, with frequency capping
- Push Notifications - Apple Push Notification Service (APNS)
- Privacy-First - GDPR compliant, respects user consent
- iOS 14+ - Support for modern iOS versions
Requirements
- iOS 14.0+
- Xcode 15.0+
- Swift 5.9+
The SDK's API is Swift-only, so it cannot be called directly from a .m or
.mm file. An Objective-C app integrates it by adding a single Swift file
that imports Joryio and exposes the few calls the app needs, then calling that
from Objective-C as usual. Xcode sets up the bridging header the first time you
add a Swift file to an Objective-C target.
Installation
Swift Package Manager (recommended)
The SDK is distributed as a Swift package from https://github.com/joryio/joryio-ios-sdk. This is the recommended way to install it; CocoaPods is deprecated (see CocoaPods). Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/joryio/joryio-ios-sdk.git", from: "1.3.3")
]
The package provides three products. Link the first two into your app as needed; the third is only for a Notification Service Extension (see Rich push images):
.target(
name: "YourApp",
dependencies: [
.product(name: "Joryio", package: "joryio-ios-sdk"), // tracking, identity, push
.product(name: "JoryioUI", package: "joryio-ios-sdk"), // + in-app message display
]
)
Or in Xcode:
- File → Add Package Dependencies
- Enter:
https://github.com/joryio/joryio-ios-sdk.git - Select version, then choose the products to add to your target
CocoaPods (deprecated)
CocoaPods is deprecated for the Joryio iOS SDK in favour of
Swift Package Manager. CocoaPods trunk, the registry a
plain pod 'Joryio' downloads from, is closing (read-only from 2 December 2026),
so new releases are not guaranteed to be on it. If your app has to stay on
CocoaPods, install from the SDK's git tag as below: it does not use trunk, and
every release from 1.3.0 on supports it, betas included. Pin the version with
:tag and change the tag to upgrade - pod update does not move it.
To move to Swift Package Manager: delete the pod 'Joryio...' lines from your
Podfile, run pod install, then add the package as described
above. Nothing else in your code changes - except that a
Notification Service Extension writes import JoryioNotificationServiceExtension
instead of import Joryio.
pod 'Joryio/UI', :git => 'https://github.com/joryio/joryio-ios-sdk.git', :tag => '1.3.3' # everything, including in-app display
# pod 'Joryio', :git => 'https://github.com/joryio/joryio-ios-sdk.git', :tag => '1.3.3' # tracking, identity and push only - no WebKit linked
# Only in a Notification Service Extension target:
# pod 'Joryio/NotificationServiceExtension', :git => 'https://github.com/joryio/joryio-ios-sdk.git', :tag => '1.3.3'
JoryioUI depends on Joryio, so linking the UI product gets you both.
Take Joryio alone when your app has no use for in-app messages, or when a
security review objects to a web view being linkable at all. In-app messages
will simply not display; everything else works unchanged. Note the CocoaPods
default is the UI-free Joryio - pod 'Joryio' will not display in-app
messages until you switch it to pod 'Joryio/UI'.
Privacy manifest
The SDK ships Apple's privacy manifest (PrivacyInfo.xcprivacy) with the Swift package and the CocoaPods pod. It declares the one required-reason API the SDK uses - UserDefaults, reason CA92.1 - and the data the SDK collects: user ID, device ID, product interaction and purchase history, each linked to the user and not used for tracking. Every one is declared for App Functionality, Analytics and Developer's Advertising or Marketing, and all but the device ID also for Product Personalization. No location is declared: the SDK never reads the device location, and the country the Joryio backend derives from the IP address is not data the SDK collects. Xcode includes it in your app's privacy report. Attributes and event properties you choose to send are yours to declare in your app's own manifest.
Quick Start
1. Initialize the SDK
In your AppDelegate.swift:
import Joryio
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Initialize with your SDK key
Joryio.shared.initialize(
sdkKey: "jry_sdk_ios_YOUR_SDK_KEY",
apiHost: "https://api-eu1.joryio.com/api"
)
return true
}
Find your SDK key in the Joryio dashboard under Settings → Apps → [Your App] → SDK Keys.
Every public method is safe to call before initialize(). optOut(), optIn(), wipeData(), updateConsent(_:), setSdkAuthenticationToken(_:), setSdkAuthenticationErrorHandler(_:) and setInAppMessageCallback(_:capabilities:) are remembered and applied when the SDK starts. Calls that collect or send - track, identify, attributes, addAlias, subscriptions, reset, flush, the push token, receipt, click and unregister calls, and in-app reports - are queued (at most 1000, oldest dropped first, the same limit on every Joryio SDK) and run in order once initialize() succeeds. Getters return empty values until then. A call that collects data - an event, an attribute, an alias, a subscribe, a push receipt or click, an in-app report - made while the user is opted out or consent is denied is dropped at once: a later optIn() or .granted never sends it. identify is still kept, so that withdrawals carry the right user. Decisions made before initialize() - optOut(), optIn(), updateConsent(_:), wipeData() - take effect before anything the SDK does at start (Session Start, in-app sync, push re-registration), and an optOut() / optIn() made before initialize() wins over optOut in the config. isUserOptedOut() and getTrackingConsent() answer from what is stored even before initialize(). When nothing has been decided yet, getTrackingConsent() answers .granted - the consent an app without a consent banner starts with. If initialize() refuses the SDK key or the host, registerPushToken(_:onResult:) answers success: false at once instead of waiting.
Launched before the first unlock. Only a launch before the device has been unlocked once since it started (a background push or a location wake right after a reboot) waits: the SDK's storage cannot be read until then. initialize() finishes by itself on that first unlock, calls made meanwhile stay queued, and isInitializationPending is true while it waits - this is not a failure. A device that is merely locked, having been unlocked once since it started, starts at once: a silent push on a locked phone is handled normally.
2. Track Events
// Basic event
Joryio.shared.track("Button Tapped")
// Event with properties
Joryio.shared.track("Product Viewed", properties: [
"product_id": "abc123",
"product_name": "Wireless Headphones",
"price": 99.99,
"category": "Electronics"
])
// Screen view
Joryio.shared.trackScreen("ProductDetail", properties: [
"product_id": "abc123"
])
3. Identify Users
Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled
// Identify a user
Joryio.shared.identify("user-123")
Joryio.shared.setAttributes([
"name": "John Doe",
"plan": "premium"
])
// Set attributes later
Joryio.shared.setAttribute("last_purchase", value: Date())
Joryio.shared.incrementAttribute("lifetime_value", by: 99.99)
// A second identifier for the same person - works before identify() too
Joryio.shared.addAlias("loyalty_id", name: "L-12345")
// On logout
Joryio.shared.reset()
identify joins the device's anonymous history to the user on the server, re-registers the push token under them and re-syncs in-app messages.
Identifying a different user without reset() in between is treated like a logout and a login for what the previous user left behind: their unsent attribute writes go out under their own IDs, never the new user's, and with SDK authentication their token stays bound to their queued items. While the person has opted out or consent is denied, identify only keeps the user ID on the device, so an unsubscribe, the opt-out record or a push unregister names the right user - nothing is sent, tracked or written to a profile. The normal identify runs once tracking is allowed again; reset() and wipeData() forget the kept ID.
reset starts a new anonymous identity at once: a new anonymous ID, no user ID, no pending attribute writes and no in-app state, and a new session announced with a Session Start event. An identify on the very next line already uses the new identity. The push token is re-registered under it and in-app messages re-sync. Events queued before the reset are still sent, with the IDs they were tracked under.
A call missing a required value - identify(""), track(""), an empty attribute key - logs a warning and does nothing. A number that is not finite (NaN or infinity) in event properties or attribute values is dropped with a log warning, and the rest of the call goes ahead.
addAlias labels use lowercase letters, digits and underscores. An alias added to an anonymous visitor stays with their profile when they are identified.
Where identifiers are stored
| Value | Stored in | After the app is deleted |
|---|---|---|
| Anonymous ID, device ID, session | UserDefaults | Gone |
Opt-out, a stored consent denial, decisions made before initialize, in-app display history | UserDefaults | Gone |
| User ID, APNs device token, unsent attribute writes, pending push unregisters (they carry the token), a user ID kept while opted out, a logged-out user's SDK-authentication token (until that user's queued items are sent or dropped) | Keychain (this device only, after first unlock) | Gone - the SDK detects a reinstall and clears its Keychain items |
Queued events; identify, alias, subscription and opt-out writes waiting for the server; attribute writes a previous user left when the app identified someone else without reset() | SQLite in Application Support, excluded from backups | Gone |
Every key the SDK writes to UserDefaults starts with jry_ or joryio_.
Automatic Session Data
The iOS SDK enriches Session Start events with device and environment data:
$device_id$platform(ios)$model$os_name/$os_version$app_version/$build_number$bundle_id$screen_width/$screen_height$locale$language/$languages$timezonecountry(ISO-3166-1 alpha-2, derived from IP at session start)
Push Notifications
Enable push notifications to send targeted messages via Apple Push Notification Service (APNS).
Ask for the permission when the user has a reason to say yes. A prompt in the first seconds of a session is the one most often denied, and a denial is far harder to reverse than a delay - the system prompt is one-shot, so a "no" there is effectively permanent, while a later ask costs you nothing.
The SDK never prompts on your behalf. If you have decided you want it at
startup anyway, set requestPushPermissionAtLaunch in the config - it is off by
default rather than unavailable.
1. Setup in AppDelegate
import Joryio
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Initialize SDK
Joryio.shared.initialize(
sdkKey: "jry_sdk_ios_YOUR_KEY",
apiHost: "https://api-eu1.joryio.com/api"
)
// Request push permissions
Task {
let granted = await Joryio.shared.requestPushPermission()
if granted {
print("Push notifications enabled")
}
}
return true
}
// Handle device token registration
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Joryio.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
// Handle registration failure
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Joryio.shared.didFailToRegisterForRemoteNotifications(error: error)
}
// Handle received push notification
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
Joryio.shared.didReceiveRemoteNotification(userInfo, completionHandler: completionHandler)
}
}
The SDK becomes the UNUserNotificationCenter delegate at initialize() and keeps the delegate your app set before it. It acts only on Joryio notifications (payload key joryio) - showing them in the foreground and handling their taps - and forwards every other notification, and the settings callback, to your delegate unchanged. If you set your own delegate after initialize(), report Joryio taps yourself with trackPushClick(trackingId:).
didRegisterForRemoteNotifications(deviceToken:) is also the entry point for an engine that receives the APNs token itself - the Unity bridge passes it the token from kUnityDidRegisterForRemoteNotificationsWithDeviceToken. A token that arrives before initialize() is kept and registered when the SDK starts.
The token is registered with Joryio only while the user is opted in and tracking consent is granted. Otherwise it stays on the device and is registered when that changes. Delivery receipts and click tracking follow the same rule.
2. Configure APNS in Dashboard
To send push notifications, enter your APNs credentials on the iOS app in Joryio:
- Go to Settings → Apps and click the Push Notifications (bell) icon on your iOS app's row
- Choose the Environment - Development (Sandbox) for builds run from Xcode, Production for App Store and TestFlight builds
- Choose Token-Based (.p8 Key) and enter the Key ID, the Team ID, and the content of your
.p8auth key - Click Save configuration
For step-by-step instructions on creating the auth key in your Apple Developer account, see Getting your APNs credentials.
3. Push Features
// Check if push is enabled - true exactly when the status is "granted"
let isEnabled = await Joryio.shared.isPushEnabled()
let status = await Joryio.shared.getPushPermissionStatus() // "granted", "denied" or "notDetermined"
// Get device token
if let token = Joryio.shared.getDeviceToken() {
print("Device token: \(token)")
}
// Badge management
Joryio.shared.updateBadgeCount(5)
Joryio.shared.clearBadge()
// Unregister from push - removes the token on the Joryio server and with iOS.
// Holds across launches until registerPushToken(_:) or requestPushPermission().
Joryio.shared.unregisterPush()
// Your own push stack: hand the token over and report what happened
Joryio.shared.registerPushToken(hexToken) { result in
// result.success, result.message, result.isUnauthorized, result.isRetryable
}
Joryio.shared.reportPushDelivered(trackingId)
Joryio.shared.trackPushClick(trackingId: trackingId)
Provisional and ephemeral authorization count as "granted". The push_permission user attribute uses the same values, with not_determined for "notDetermined".
The token must be the APNs device token as a hex string. Anything else - such as a Firebase (FCM) registration token - is refused: onResult reports success: false and the SDK logs why. unregisterPush() is remembered across launches: the token iOS hands over at a later launch is kept on the device but not registered, until the app calls registerPushToken(_:onResult:) or requestPushPermission() again. registerPushToken(_:onResult:) calls onResult once with the outcome; a 200 response with success: false is reported as a failure, never as a success. reportPushDelivered(_:) and trackPushClick(trackingId:) are for an app that handles notifications itself, so none reach the SDK's own handlers.
Rich push images
APNs cannot attach an image by itself; a Notification Service Extension in your app has to. Add one (File → New → Target → Notification Service Extension) and link the extension product into that target only:
- Swift Package Manager:
JoryioNotificationServiceExtension - CocoaPods:
pod 'Joryio/NotificationServiceExtension'in the extension's target block
Do not link Joryio into the extension: it calls UIApplication.shared, which app extensions may not use, and it brings SQLite into a process limited to 24 MB. The extension product has no dependencies. Replace the generated class with:
import JoryioNotificationServiceExtension // CocoaPods: import Joryio
class NotificationService: JoryioNotificationService {}
It downloads the URL in the payload's jry_image_url key (or attachment-url) and attaches it.
Push registration and consent
The SDK registers for remote notifications whether or not the user grants the notification permission. On iOS the two are separate: registering yields a device token without consent, and that token can only ever deliver background (silent) pushes - it cannot display anything the user has not authorised.
This is what lets an in-app message reach a user who declined notifications, and it means a token already exists if they later enable notifications in Settings. Disclose it in your privacy policy.
In-App Messaging
Display targeted in-app messages to users based on their behavior and attributes.
Delivery tokens
When the backend serves an eligible campaign it also issues a short-lived signed delivery token for it. The SDK echoes that token back when it reports an impression, click or dismissal, and the server verifies the signature before recording anything.
You do not have to do anything - the SDK handles this for you. It is
documented because the server can be configured to require it. By default a
report that carries no token is still accepted - the server logs it as a
pre-token client - and only when the backend runs with
IN_APP_REQUIRE_DELIVERY_TOKEN=true is that same report rejected:
POST /v1/in-app/track (no deliveryToken)
{ "success": false, "error": "A delivery token is required" }
A token that is present but does not verify - forged, tampered with or past its lifetime - is rejected whatever the setting.
The token is what makes an impression trustworthy: without it, anyone holding the SDK key - which ships inside every app and page - could report impressions and clicks for a campaign that was never shown, and your reporting would count them.
If in-app impressions stop being recorded on a backend that enforces tokens, check that the app is built against a current version of the SDK: a build made before delivery tokens existed sends no token.
Automatic Campaign Sync
The SDK automatically syncs campaigns from the server:
- At launch and when the app enters the foreground - at most once per
syncInterval(default 300 seconds) - Immediately when the user is identified or reset, when a push is tapped, when a silent sync push arrives, and when an attribute a campaign watches changes
syncInAppCampaigns() syncs on demand and is not rate-limited.
Messages triggered by an event, a push tap or an attribute threshold go through the same frequency caps and the same one-message-at-a-time rule as any other message.
Manual Campaign Control
// Manually sync campaigns from server
await Joryio.shared.syncInAppCampaigns()
// Manually trigger campaign evaluation
await Joryio.shared.evaluateInAppCampaigns()
// Reset displayed campaigns (for testing)
Joryio.shared.resetDisplayedCampaigns()
enableDebugevaluateInAppCampaigns() and resetDisplayedCampaigns() change live state - one can force a message on screen, the other wipes real frequency caps - so they are IGNORED unless the SDK was initialised with enableDebug: true; a release build logs a warning and does nothing. The read-only diagnostics (getQueueSize(), getSessionInfo(), getIdentity(), getDeviceToken()) are not gated. syncInAppCampaigns() is ordinary API and always runs.
Two kinds of message content
A campaign arrives as one of two content shapes, and the SDK renders each one differently:
| Content | What it is | How it renders |
|---|---|---|
| Native | Structured data - headline, body, image, buttons | Real UIKit views, using your app's tint colour, Dynamic Type, dark mode and VoiceOver. No web view. |
| HTML | Author-supplied markup and CSS | A WKWebView inside the message, under a strict Content-Security-Policy. |
Allowing HTML messages
HTML messages are turned off by default. An HTML message renders author-supplied markup in a web view inside your app, so turning it on is a decision your app team makes - not something switched on from a marketing dashboard:
let config = JoryioConfig(
inApp: InAppConfig(allowHtmlJsInAppMessages: true) // default: false
)
Joryio.shared.initialize(
sdkKey: "jry_sdk_ios_YOUR_KEY",
apiHost: "https://api-eu1.joryio.com/api",
config: config
)
Leaving it off does not disable in-app messaging. Native messages still display, because they are data rendered by the SDK's own views with no interpreter involved. HTML campaigns are skipped and logged, so an app that has not opted in shows no message rather than a broken one.
This gates the SDK's own renderer. If your app draws in-app messages itself with setInAppMessageCallback(_:capabilities:), the SDK's web view never runs, so the setting does not apply to that path - declare content.html in capabilities and HTML campaigns are handed to your callback whether or not this is on. Your renderer, your decision.
The message document is shown under a Content-Security-Policy that blocks all script, frames, network access and form posts - the same rules as the web SDK. Only the SDK's own joryioBridge runs, so links, buttons, data-joryio-action attributes, bridge calls and data-jry-form forms work, but an author's own <script>, inline on* handlers and <iframe> embeds do not execute on iOS (or on web).
If your security policy forbids rendering authored HTML in-process, leave this off and author your campaigns as native messages.
Rendering messages yourself
The SDK draws native messages for you, but you can take over completely - the
same escape hatch other vendors call a custom view factory. Conform to
InAppMessagePresenter and assign it:
Joryio.shared.inAppPresenter = MyPresenter()
Auto-discovery only runs when inAppPresenter is nil, so yours replaces the
built-in renderer rather than fighting it. Set it before or after initialize.
Your presenter receives the campaign with content already resolved - the
Liquid is rendered server-side, and native fields arrive as plain text. Return
true from present(campaign:in:delegate:) only when the message is on screen:
the SDK then reports "displayed" and counts the display against the campaign's
frequency cap itself. Report taps and closing through the delegate you are
given - messageViewClicked(_:action:url:) with the button or link id, and
messageViewDidDismiss(_:) when the message goes away. Do not call
trackInAppImpression from a presenter: the message would be reported twice.
To render with your own UI without a presenter, use a callback instead:
Joryio.shared.setInAppMessageCallback({ campaign in
showMyMessage(campaign)
Joryio.shared.trackInAppImpression(campaignId: campaign.id, action: "displayed")
}, capabilities: ["content.native"])
// When a button is tapped, and when the message closes:
Joryio.shared.trackInAppImpression(campaignId: campaign.id, action: "clicked", elementId: "buy_now")
Joryio.shared.trackInAppImpression(campaignId: campaign.id, action: "dismissed")
// Hand display back to the SDK's own renderer:
Joryio.shared.clearInAppMessageCallback()
action is "displayed", "clicked", "dismissed" or "converted"; any other value is refused with a log message. elementId optionally names the button or link for a click. Your "displayed" report is the only one sent for that message, and it is what counts the message against the campaign's frequency cap - a message handed to your callback and never displayed uses none of it. "dismissed" frees the SDK to hand over the next message: a message you reported "displayed" holds the display until you report "dismissed", so nothing is drawn on top of it. Without any report, the SDK frees itself after 30 seconds; a message reported "displayed" and never "dismissed" holds the display for at most 5 minutes. Report "dismissed" whenever the message leaves the screen, including after a click. A message waiting on its display delay is not handed over once the user is identified as someone else, opts out or changes consent.
Since 1.3.2. A callback set before initialize() is installed before the first sync. If you set it after initialize(), the first sync can finish before it and the SDK draws that message itself; pass InAppConfig(holdInAppMessagesForHandler: true) to prevent that. A message that is ready before your callback is set is then held - not drawn, not reported, nothing counted against a frequency cap - and handed to the callback the moment you set it. Calling clearInAppMessageCallback() instead ends the hold and the SDK draws the held message.
You never configure this, but it is worth knowing when something does not display. The SDK finds its built-in renderer by name at runtime, and the module that name lives in depends on how you integrated:
| how it is packaged | class the SDK looks for | |
|---|---|---|
| SwiftPM | JoryioUI is its own target | JoryioUI.DefaultInAppMessagePresenter |
| CocoaPods | Joryio/UI is a subspec, and subspecs share the pod's module | Joryio.DefaultInAppMessagePresenter |
Both names are tried. If no in-app message ever appears, look for the SDK's
warning "No in-app presenter found" - that means the UI product is not
linked (pod 'Joryio/UI', or the JoryioUI product under SwiftPM), not that
the campaign failed to arrive. Those two look identical from the outside.
Your own presenter needs none of this: assign it and discovery never runs.
Message Types
The SDK supports 5 message types:
- Modal - Center of screen with backdrop
- Banner - Top of screen
- Slide-Up - Small notification from the bottom
- Full-Screen - Takeover message
- Custom - Your app decides placement
Banners and slide-ups take touches only inside the message, so the rest of your app stays usable behind them. Modal and full-screen messages block the screen.
Frequency Capping
Messages respect workspace-level touching rules and campaign-level frequency caps:
- Maximum impressions per time window
- Minimum delay between messages
- Per-campaign frequency limits
When a message counts as displayed
A message counts as displayed - in_app.displayed is reported and the campaign's frequency cap is spent - only once the person could have seen it. The time it must be on screen is IN_APP_DISPLAY_DWELL_MS (1000 ms), the same constant on every Joryio SDK.
- The SDK presents a message only while the app is active and the top view controller is settled - not being presented, dismissed or moved. Otherwise it waits and tries again.
- The message counts as displayed after it has been on screen for 1 second while the app stays active. If the app goes to the background first, the second starts again when it is back.
- If the message leaves the screen before then, nothing is reported and the cap is untouched, so it stays eligible.
- A tap or a close before then counts as seen:
displayedis reported first, then the click or dismissal. A reset, a change of user or an opt-out before then reports nothing. - Rendering messages yourself: report
displayedby the same rule - once the message has been visible forInAppDisplay.IN_APP_DISPLAY_DWELL_MS, or right before a click or dismissal that comes sooner.
Attribute delivery
setAttributes, setAttribute, incrementAttribute and unsetAttribute are durable. A write is queued until the server acknowledges it,
so an attribute set while the device is offline is delivered when connectivity
returns rather than dropped. An increment is sent as an atomic delta, never as a locally computed total.
- Retried on the next attribute write, when the app returns to the
foreground, and when sending resumes (
optIn(), consent granted). - Batched - a burst of calls becomes one request (800ms window). A single write still goes out promptly. A write made while a request is in flight waits for it and follows it; it never cancels it, so an increment is never counted twice.
- Acknowledged only by the server - a
200response withsuccess: falsekeeps the writes for the next attempt. A write the server refuses as invalid (HTTP 400, 413 or 422) is split and the halves resent, so one bad key cannot hold up the others; a single write still refused is dropped and logged. At most 200 unsets go in one request, and the rest follow. nilremoves -setAttribute("nickname", value: nil), orNSNullfrom a bridge, unsets the attribute likeunsetAttribute.- Persisted in the iOS Keychain, so a write survives the process being killed. Cleared the moment the server acks, and purged by
optOut(),reset()andwipeData(). A write the server refuses with HTTP 403 is dropped and logged, so it cannot hold up later writes.
Attributes are also used for in-app targeting. The server profile is authoritative: only writes the server has not yet acknowledged can override it, which is what keeps two devices belonging to the same contact from disagreeing about who that contact is.
Marketing Subscription
Whether this person may be messaged on a channel. A different consent from
optOut() above, which is whether they may be observed - neither implies
the other, and someone can decline tracking and still want the newsletter.
This is what an in-app preference centre writes.
Joryio.shared.setSubscription(channel: "email", status: "unsubscribed")
Channels: email, sms, whatsapp, push, viber.
Statuses: optedIn, subscribed, unsubscribed.
Subscription groups are lists a person can join or leave per channel:
Joryio.shared.addToSubscriptionGroup("list_abc123", channel: "email")
Joryio.shared.removeFromSubscriptionGroup("list_abc123", channel: "email")
A subscription change made while tracking consent is pending waits on the device and is sent when consent is granted. One that fails is kept, across restarts and reset(), and sent again - see Writes that must reach the server.
An unsubscribe - status: "unsubscribed", or removeFromSubscriptionGroup - is a withdrawal: it is accepted and delivered even when the person has opted out, consent is pending or denied, or after wipeData(). Subscribing and joining a group are not.
An identifier is required - the call is skipped, with a logged error, if there is neither a user id nor an anonymous id.
Configuration Options
Customize SDK behavior with JoryioConfig:
JoryioConfig(
// User Identification
userId: String?, // Initialize with known user ID
anonymousId: String?, // Custom anonymous ID
// Batching & Performance
batchSize: Int, // Default: 50 (at most 500 per request)
flushInterval: TimeInterval, // Default: 5.0 seconds (minimum 1.0)
sendImmediately: Bool, // Default: false
maxQueueSize: Int, // Default: 1000
// Session Management
sessionTimeout: TimeInterval, // Default: 1800 (30 minutes)
trackSessionStart: Bool, // Default: true
// Push
requestPushPermissionAtLaunch: Bool, // Default: false - prefer a primer
// In-App Messaging
inApp: InAppConfig, // Default: InAppConfig() - see below
// Network & Retry
maxRetries: Int, // Default: 3
retryBackoffMs: Double, // Default: 1000.0
requestTimeout: TimeInterval, // Default: 10.0
// SDK Authentication (opt-in)
enableSdkAuthentication: Bool, // Default: false
sdkAuthenticationToken: String?, // Default: nil
// Privacy & GDPR
optOut: Bool, // Default: false (optOut() persists regardless)
trackingConsent: TrackingConsent?, // Default: nil (unset) - see Tracking consent
// Debugging
enableDebug: Bool, // Default: false
logLevel: LogLevel? // Default: nil - .debug with enableDebug, otherwise .error
)
InAppConfig(
enabled: Bool, // Default: true - false turns in-app off
allowHtmlJsInAppMessages: Bool, // Default: false
syncInterval: TimeInterval, // Default: 300 - seconds between automatic syncs
onMessageDisplay: ((InAppMessage) -> Void)?, // after the SDK displays a message
onMessageClick: ((InAppMessage, String) -> Void)?, // with the tapped element's name
onMessageDismiss: ((InAppMessage) -> Void)?, // after a message is dismissed
holdInAppMessagesForHandler: Bool // Default: false - hold messages until setInAppMessageCallback (since 1.3.2)
)
The three callbacks run on the main thread.
Advanced Features
User Attributes Management
// Set multiple attributes
Joryio.shared.setAttributes([
"age": 28,
"city": "San Francisco",
"premium": true
])
// Set single attribute
Joryio.shared.setAttribute("language", value: "en")
// Increment numeric attribute
Joryio.shared.incrementAttribute("page_views", by: 1)
Joryio.shared.incrementAttribute("total_spent", by: 29.99)
// Remove attribute
Joryio.shared.unsetAttribute("temporary_flag")
Privacy Controls
// Stop collecting. PERSISTED - survives an app restart.
Joryio.shared.optOut()
// Opt back in
Joryio.shared.optIn()
// Check opt-out status
if Joryio.shared.isUserOptedOut() {
print("User has opted out")
}
// Delete everything the SDK stored on this device.
// SEPARATE from optOut(): "stop collecting" and "delete what you have" are
// different requests. This is the one an erasure request needs. It does NOT
// opt the user out - call optOut() as well if that is also intended.
Joryio.shared.wipeData()
// Get identity info
let (userId, anonymousId) = Joryio.shared.getIdentity()
print("User: \(userId ?? "anonymous"), Anonymous ID: \(anonymousId)")
What optOut() does, precisely:
| stops collection | yes - every write path: track, identify, attributes (including increments and unsets), aliases, subscribing, push registration and receipts, in-app impressions. Withdrawals still go out: an unsubscribe, the opt-out record and a push unregister |
| survives a restart | yes - the flag is stored on the device and read before anything is collected. It is not tied to the SDK key, so it also survives a key change. optOut: true in the config is stored the same way |
| drops what is already queued | yes - queued events, un-acked attribute writes and waiting identify, alias and subscribe writes are discarded, not delivered later. Withdrawals waiting to be sent are kept and delivered |
| tells the server | yes - the profile attribute tracking_opted_out (true; optIn() sends false). Sent only when the opt-out changes - including optOut: true in the config or a call made before initialize() - and retried until the server stores it. It is sent whatever the consent - pending or denied too |
| deletes stored data | no - use wipeData() |
The tracking_opted_out attribute is a record, not enforcement: it lands on
the profile so campaigns can exclude on it. Server-side suppression is a
separate setting.
What wipeData() erases: the user ID and anonymous ID, the device ID, queued events, unsent attribute writes, in-app state, the session and the push token - which is also unregistered on the Joryio server, so nothing more is sent to this install. The opt-out is kept: erasing someone's data does not opt them back in. The next event (or the next time the app comes to the foreground) starts a new session, announced with a Session Start.
Push then stays off until the app calls registerPushToken(_:) or requestPushPermission() again - a token iOS hands over on a later launch is not registered automatically. Registering and unregistering run one at a time: registering a token again cancels its unregister if that has not reached the server yet, and an unconfirmed unregister of another token is sent before any new registration.
Tracking consent
Pass the consent your consent manager holds at launch, and update it when the user decides:
Joryio.shared.initialize(
sdkKey: "jry_sdk_ios_YOUR_KEY",
apiHost: "https://api-eu1.joryio.com/api",
config: JoryioConfig(trackingConsent: .pending)
)
// Later, from your consent banner:
Joryio.shared.updateConsent(.granted) // or .denied
| Consent | Behaviour |
|---|---|
.granted | Normal |
.pending | Events and attribute writes are kept on the device (up to maxQueueSize) and nothing is sent - no Session Start, no in-app sync, no push registration. .granted sends what was kept; .denied discards it |
.denied | Nothing is collected, and anything kept is cleared |
Two independent switches decide whether anything is collected: the consent your banner reports, and the person's own opt-out. Collection happens only when consent is .granted AND the person has not opted out - either "no" wins.
.deniedis stored on the device and holds across launches. Only an explicitupdateConsent(.granted), ortrackingConsent: .grantedpassed in the config, lifts it.trackingConsentdefaults to unset (nil). Unset with nothing stored behaves as.granted, so an app without a consent banner just works; unset with a stored.deniedstays denied..pendingis not stored: it applies to this launch only and never overrides a.denied- stored, or set earlier beforeinitialize().- Consent never lifts an opt-out. Only
optIn()does, andoptOut()never changes the stored consent. getTrackingConsent()returns the consent in effect;isUserOptedOut()reflects only the opt-out.- A session that began while collection was not allowed is announced with a Session Start once it is allowed - later in the same launch, or at a later launch as a new session.
Manual Queue Flushing
// Flush immediately (e.g., before app termination)
Joryio.shared.flush()
// Check queue size
let queueSize = Joryio.shared.getQueueSize()
print("Pending events: \(queueSize)")
Events go out in batches of at most 500 and about 900 KB. If the server refuses a batch as invalid (HTTP 400, 413 or 422), the SDK splits it and resends the halves, so one bad event cannot hold up the rest; an event refused on its own is dropped and logged. Events the server reports as failed are kept and retried. HTTP 429, 5xx and network failures keep everything and retry with exponential backoff, honouring Retry-After. HTTP 402 or 403 stops sending for the rest of the session and keeps the events. Failures never delete events; only maxQueueSize does, oldest first. The SDK drops an event it knows the server would refuse - an empty name or one longer than 255 characters, or properties over 50 KB serialized (measured as the server measures it), over 200 top-level keys or nested deeper than 5 levels - at track(), with a log line; events the server lists in rejectedIndices are dropped too, not retried. flush() skips the SDK's own backoff but never a server Retry-After. With SDK authentication a batch never mixes two users, and the queued events of a user who has logged out are dropped when the server refuses them for any SDK-authentication reason, so they cannot block the next user's events.
Writes that must reach the server
identify(_:), addAlias(_:name:), the subscription calls, the tracking_opted_out record and the server side of unregisterPush() / wipeData() are kept on the device until the server acknowledges them - across restarts, and across reset() (each carries the IDs it was made under). They are sent in order, one at a time, and a newer change by the same person for the same channel or group replaces one still waiting, so an older retry never lands after it. A write made while anonymous and sent after identify(_:) goes out under the user ID, as long as the anonymous ID it was made under is still this device's. A write the server refuses for good (HTTP 400, 403, 409, 413, 422) is dropped and logged; a subscription change answered 404 (no profile yet) is retried after the queued events are sent, at most 5 times; success: false and the current user's SDK-authentication 401 are retried with backoff, at most 10 times per write. Being offline, timeouts, HTTP 402, 429 and 5xx never count against a write. While ingestion is paused for your workspace (HTTP 402), withdrawals still go out ahead of the writes that have to wait. When 1,000 writes are waiting, the oldest one that is not a withdrawal makes room. optOut(), consent .denied and wipeData() discard the identify, alias and subscribe writes still waiting; withdrawals - an unsubscribe, the tracking_opted_out record, a push unregister - are kept and delivered.
SDK authentication
The token proves who an identified user is: requests that name a user ID must carry a valid one. Anonymous visitors have no token to show, so their requests are accepted without it, unless the workspace also turns on strict identity.
When SDK authentication is enabled for your workspace, every ingest request must carry a JWT minted by your backend. Supplying a token - in sdkAuthenticationToken or with setSdkAuthenticationToken(_:), before or after initialize() - turns the header on, with or without enableSdkAuthentication:
Joryio.shared.setSdkAuthenticationToken(tokenFromYourBackend)
Joryio.shared.setSdkAuthenticationErrorHandler { error in
fetchFreshToken { Joryio.shared.setSdkAuthenticationToken($0) }
}
reset() clears the token. Events and writes the outgoing user had already queued still go out with that user's token, so a normal logout loses nothing - the token is kept in the Keychain, bound to those items, until they are sent or dropped, so closing the app right after a logout loses nothing either. Identifying a different user without reset() does the same with the previous user's token, and so does setting the next user's token with setSdkAuthenticationToken(_:): set it before or after identify, in either order the previous user's leftovers keep their own token. Each request carries the token whose sub is that request's user, never one that names someone else; a token set for the next user before reset() - also while no one is signed in - is kept for them and used again at their identify, and wipeData() forgets it - unless it is the token of the user being erased (also one identified while tracking is off), or the token supplied for this launch when a wipeData() made before initialize() runs. Events and writes left over from a user who is no longer the current one are dropped when SDK authentication refuses them, whatever the reason (missing, sub_mismatch, expired, invalid), so they cannot block the next user's. The error handler hears only about the current user's requests - never about a previous user's leftovers. While the person has opted out or consent is denied, still set the user's token at login: the user identified meanwhile counts as the current one for their unsubscribe, so if it is refused the handler is asked for their token and the unsubscribe is kept.
API reference
Every public member of Joryio.shared, with its exact Swift name.
| Method | What it does |
|---|---|
initialize(sdkKey:apiHost:config:) | Start the SDK with your SDK key, API host and optional JoryioConfig. A second call is ignored |
track(_:properties:) | Record an event with optional properties |
trackScreen(_:properties:) | Record a Screen Viewed event with screen_name |
identify(_:) | Join this device to a user ID; re-registers push and re-syncs in-app messages |
addAlias(_:name:) | Attach a labelled second identifier, for anonymous visitors too |
reset() | Logout: a new anonymous identity and session |
setAttributes(_:) | Set several attributes; nil / NSNull values unset |
setAttribute(_:value:) | Set one attribute; nil unsets it |
incrementAttribute(_:by:) | Atomic numeric increment (default 1) |
unsetAttribute(_:) | Remove an attribute |
getUserId() | The current user ID, or nil |
getAnonymousId() | The current anonymous ID |
getIdentity() | (userId, anonymousId) |
getSessionId() | The current session ID |
getSessionInfo() | (sessionId, sessionStart, duration) |
optOut() | Stop all collection and sending; persisted |
optIn() | Resume after optOut() |
isUserOptedOut() | Whether the user is opted out; before initialize(), from what is stored |
getTrackingConsent() | The tracking consent in effect: .granted, .denied or .pending |
updateConsent(_:) | Set tracking consent: .granted, .pending or .denied |
wipeData() | Erase everything the SDK stored on the device; keeps the opt-out |
setSubscription(channel:status:) | Marketing subscription status for one channel |
addToSubscriptionGroup(_:channel:) | Join a subscription group on a channel |
removeFromSubscriptionGroup(_:channel:) | Leave a subscription group on a channel |
setSdkAuthenticationToken(_:) | Supply or replace the SDK-authentication JWT |
setSdkAuthenticationErrorHandler(_:) | Called when the server rejects the JWT; reply with a fresh token |
clearSdkAuthenticationErrorHandler() | Remove that handler, for example when a bridge or screen tears down |
flush() | Send queued events now |
getQueueSize() | Events waiting on the device |
lastTransportError | The most recent request failure (JoryioTransportError), or nil after a success |
currentApiEndpoint | The API base URL the SDK is sending to |
isInitializationPending | true while initialize() waits for the first unlock since the device started; not a failure |
ecommerce(config:) | The e-commerce helper; pass a config to set the currency |
inAppPresenter | Your own InAppMessagePresenter, replacing the built-in renderer |
setInAppMessageCallback(_:capabilities:) | Receive in-app messages and draw them yourself |
clearInAppMessageCallback() | Hand drawing back to the SDK |
trackInAppImpression(campaignId:action:elementId:) | Report what your own UI did with a message |
submitInAppForm(campaignId:values:) | Submit a form shown in a message (async); never throws |
syncInAppCampaigns() | Fetch in-app campaigns now (async) |
evaluateInAppCampaigns() | Testing only, needs enableDebug: evaluate campaigns now |
resetDisplayedCampaigns() | Testing only, needs enableDebug: forget which messages were shown |
requestPushPermission() | Ask for notification permission (async); lifts unregisterPush() |
getPushPermissionStatus() | "granted", "denied" or "notDetermined" (async) |
isPushEnabled() | true exactly when the status is "granted" (async) |
didRegisterForRemoteNotifications(deviceToken:) | Forward the APNs token from your AppDelegate |
didFailToRegisterForRemoteNotifications(error:) | Forward an APNs registration failure |
didReceiveRemoteNotification(_:completionHandler:) | Forward a remote notification |
registerPushToken(_:onResult:) | Register a hex token yourself, with an outcome callback; lifts unregisterPush() |
getDeviceToken() | The stored APNs token, or nil |
unregisterPush() | Remove the token on the server and with iOS; holds across launches |
reportPushDelivered(_:) | Report that a push arrived, by its trackingId |
trackPushClick(trackingId:) | Report a push tap, by its trackingId |
updateBadgeCount(_:) | Set the app badge |
clearBadge() | Clear the app badge |
Joryio.pushPermissionAttribute | The attribute name push_permission |
Best Practices
1. Track Meaningful Events
Focus on events that matter for your business:
// Good: Specific, actionable events
Joryio.shared.track("Trial Started", properties: ["plan": "premium"])
Joryio.shared.track("Feature Used", properties: ["feature": "export"])
// Avoid: Overly generic events
Joryio.shared.track("Button Tapped") // Too generic
2. Handle User Lifecycle
// On login
func handleLogin(userId: String, userInfo: UserInfo) {
Joryio.shared.identify(userId)
Joryio.shared.setAttributes([
"email": userInfo.email,
"name": userInfo.name
])
}
// On logout
func handleLogout() {
Joryio.shared.reset()
}
// On signup - identify() joins the anonymous history to the new user
func handleSignup(userId: String, userInfo: UserInfo) {
Joryio.shared.identify(userId)
Joryio.shared.setAttributes(userInfo.attributes)
}
3. Flush on Critical Events
override func applicationWillTerminate(_ application: UIApplication) {
Joryio.shared.flush()
}
Troubleshooting
Events Not Appearing
- Check SDK key is correct:
jry_sdk_ios_* - Enable debug logging:
enableDebug: true(logs everything unless you also setlogLevel) - Check console logs for errors
- Verify network connectivity
- Call
flush()to send immediately
Push Not Working
- Verify the APNs credentials are saved on the app in the dashboard
- Check device token is registered
- Ensure proper entitlements in Xcode
- Test in proper environment (dev vs production)
- Look up the error from Send test push in Getting your APNs credentials
Build Errors
- Ensure iOS 14.0+ deployment target
- Clean build folder: Cmd+Shift+K
- Update Swift Package dependencies
E-Commerce Tracking
The iOS SDK includes a built-in e-commerce tracker for product, cart, checkout, and order events. See the cross-SDK E-Commerce Tracking guide for the full API with Swift examples.
let ecommerce = Joryio.shared.ecommerce(config: EcommerceConfig(currency: "USD"))
ecommerce.purchase(order: EcommerceOrder(
orderId: "ORD-1001",
items: [EcommerceCartItem(productId: "SKU123", name: "Blue Shirt", price: 29.99, quantity: 2)],
value: 59.98
))
A call missing what makes the event meaningful - a productId, an orderId, a finite price or value, an items list in a purchase (it may be empty) - logs a warning and sends nothing, never an empty or zero order.
purchase sends Order Completed with the keys revenue reporting reads: orderId and total (plus currency and the items). If you track a purchase with track() yourself, use the same keys - order_id and value are not read, and the order counts as zero revenue.
Next Steps
- Android SDK - Integrate Android SDK
- Push Notifications Guide - Learn about push campaigns
- In-App Campaigns Guide - Create in-app messages
- Custom Events - Track custom events
- E-Commerce API - Server-side e-commerce integration