iOS — Rich pushes and extensions
Add a Notification Service Extension so iOS can show image-based and carousel pushes.
iOS handles rich-content notifications through a separate target called a Notification Service Extension (NSE). The extension runs in its own process, intercepts every incoming push before the OS displays it, and gets up to 30 seconds to download attachments and modify the content.
Without an NSE, iOS shows the title and body only — image attachments and carousel items will not render. This guide walks you through adding the NSE to your app and wiring it to the Swan SDK's Templates.renderContent(...) API.
What setup is required per notification type
| Notification type | iOS setup |
|---|---|
| Standard (title + body) | None — works out of the box |
| Image (rich media) | NSE required |
| Carousel (swipeable cards) | NSE required. The Swan SDK packs every carousel item as an UNNotificationAttachment so the NSE alone is sufficient for the default UI. If you want a custom swipeable interface, add a Notification Content Extension (NCE) — Templates.parseCarousel(_:) exposes the typed payload for you to render. |
| Custom sound | Add the sound file (.wav) to your Xcode app bundle. No extension needed — see Push notifications → Notification channels for the sound configuration. |
The most common iOS push integration issue: image / carousel notifications arriving with no image. Cause: the NSE is missing. Add it and the images render automatically.
React Native quick-setup
If your host app uses @loyalytics/swan-react-native-sdk, the SDK ships a
script that copies a ready-made NSE + NCE implementation into your ios/
directory:
node node_modules/@loyalytics/swan-react-native-sdk/scripts/setup-ios-extension.jsThe script copies:
ios/SwanNotificationServiceExtension/— handles delivery ACKs and image attachment downloads.ios/SwanNotificationContentExtension/— renders the carousel UI on long press (only needed if you use carousel pushes).
After running it, you still need to add the extension targets in Xcode, enable App Groups on all targets, and set Swift Language Version on each extension — those Xcode-side steps are detailed below.
Native iOS host apps (Swift / Obj-C) follow the manual setup that starts in §1 below.
1. Add the extension target in Xcode
- Open your app's
.xcworkspacein Xcode. - File → New → Target.
- Choose Notification Service Extension.
- Product name:
SwanNotificationServiceExtension(or any name you prefer). - Language: Swift.
- Click Finish. When prompted "Activate scheme?", click Cancel.
Verify in General for the new target that the Bundle Identifier is a child of your main app's bundle ID (e.g. com.yourcompany.app.SwanNotificationServiceExtension).
The extension's deployment target must be iOS 13.0+ (same as the SDK).
2. Enable App Groups on both targets
Cross-process credential sharing — required for the extension to fire killed-state delivery ACKs — runs over an App Group.
For each target (host app + NSE):
- Select the target in Xcode.
- Signing & Capabilities → + Capability → App Groups.
- Click + and enter the same identifier on both targets, e.g.
group.com.yourcompany.app.
The two targets must list the exact same App Group identifier. If they differ, the NSE can't read the host app's credentials and delivery ACKs from killed state will be silently skipped.
Then pass the identifier into your SwanConfig:
Swan.shared.initialize(
appId: "your-app-id",
config: SwanConfig(appGroup: "group.com.yourcompany.app")
)The SDK will migrate any existing credentials from the per-process store into the App Group store on first init, so apps that upgrade from an older version don't need to re-register.
3. Add the SDK to the extension target
The extension is a separate target, so it needs its own copy of SwanSDK linked in.
In your Podfile, add a target block for the extension and depend on SwanSDK:
target 'YourApp' do
pod 'SwanSDK', '~> 1.3'
# ... your other pods ...
end
target 'SwanNotificationServiceExtension' do
pod 'SwanSDK', '~> 1.3'
endRun pod install, then open the .xcworkspace again.
In Xcode, select the SwanNotificationServiceExtension target, go to General → Frameworks and Libraries, click +, and add the SwanSDK library that's already in the project from your app target's SPM dependency.
4. Replace the auto-generated NotificationService.swift
Xcode generates a placeholder NotificationService.swift when you add the extension target. Replace its body with:
import UserNotifications
import SwanSDK
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent = bestAttemptContent else {
contentHandler(request.content)
return
}
// One call does both: fires the killed-state delivery ACK over
// the App Group, then renders any rich content (image,
// carousel) into the notification.
Templates.handleServiceRequest(
request: request,
content: bestAttemptContent,
appGroup: "group.com.yourcompany.app" // same as SwanConfig.appGroup
) { rendered in
contentHandler(rendered ?? bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler,
let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}That's the entire extension. Templates.handleServiceRequest(...) handles:
- Reading host-app credentials from the shared App Group suite
- Firing a
deliveredACK to the env-resolved webhook URL (so campaign analytics counts delivery even when the host app is killed) - Detecting which template the payload represents (basic / carousel-manual / carousel-auto)
- Downloading the image attachments over HTTPS
- Attaching them to
UNMutableNotificationContent.attachments - Returning the modified content within the 30-second extension window
If the payload isn't a Swan-templated push, the call completes with nil and the host falls back to bestAttemptContent — the unmodified original. If the App Group isn't configured (or its credentials aren't reachable), the delivery ACK is silently skipped and rendering still runs.
5. Send a test push and verify
iOS push notifications do not work on the Simulator. Test on a real device:
- Build and run on a device.
- Send a test push with an image from your Swan campaigns dashboard.
- Tap and hold (or pull down) on the notification to expand it — the image should be visible.
If the image doesn't appear, see Troubleshooting below.
6. (Optional) Custom Notification Content Extension for carousel
The default NSE-driven carousel uses iOS's native attachment UI — each item becomes a tap-through attachment. If your design calls for a fully custom swipeable interface (page dots, item-specific tap routing without an attachment indirection), add a Notification Content Extension (NCE) and render the carousel yourself.
The Swan SDK exposes the typed carousel payload via Templates.parseCarousel(_:):
extension Templates {
public static func parseCarousel(_ data: [String: String]) -> CarouselPayloadPublic?
}CarouselPayloadPublic provides items[] (each with imageURL, title, body, route), mode (.manual / .auto), intervalMs, and defaultRoute — everything you need to drive your own UIKit / SwiftUI carousel view inside the NCE.
The category to register the NCE against is swan_carousel — that's the UNNotificationCategory the SDK stamps on carousel payloads.
Troubleshooting
Delivery ACKs aren't firing in killed-app state
Symptom: campaign analytics show clicks but no deliveries when the host app is killed.
Possible causes:
- App Group identifier mismatch. Confirm the same identifier is listed under Signing & Capabilities → App Groups on BOTH the host app target AND the NSE target. They must match exactly.
- Identifier not passed to
SwanConfig. The host SDK only writes credentials into the App Group suite whenSwanConfig.appGroupis set. Pass the same string you used in the entitlement. appGroup:argument missing in the NSE. The composite call needs the App Group passed in explicitly — there's no way for the extension to discover it from the host process at runtime.- Backend ACK URL not yet persisted. The SDK persists the env-resolved webhook URL the first time it registers a device. On a brand-new install where no init has run before the first push, no ACK URL exists yet → ACK is skipped. Verify by checking that
Swan.shared.initialize(...)has completed at least once on a launch before the killed-state push arrives.
NSE doesn't fire
Symptom: a test push arrives, but no image is attached and the NSE breakpoint never hits.
Possible causes:
- APNs payload doesn't request mutable content. Swan-issued pushes set
mutable-content: 1automatically. If you're testing with a hand-crafted payload, include that flag. - Extension target isn't in the build. Product → Scheme → Edit Scheme → Build → confirm the NSE target is listed.
- Testing on Simulator. Push notifications don't work on the iOS Simulator. Use a real device.
Image doesn't render
Possible causes:
- Image URL is not HTTPS. iOS rejects
http://attachments. - Image format isn't supported. JPEG, PNG, and GIF only. Max 10 MB.
- Network failure within the 30-second window. Check the device console for
[SwanSDK]log lines from the extension.
Build error: No such module 'SwanSDK'
The extension target doesn't have the SDK linked. Repeat Step 2 for the extension target.
Build error: Use of undeclared type 'UNNotificationServiceExtension'
The extension target's deployment target is below iOS 10. Set it to iOS 13.0+ to match the main app and the SDK.