ContentFlow Swift SDK 2.4.1
Native Swift and SwiftUI delivery client for ContentFlow dynamic blocks, localized strings, campaigns, string Sensors, consent, push registration, and analytics.
What's New in 2.4.1
- Action needed:
presentPopupandautoPresentnow draw a block popup that uses the standard fields (a non-empty#titleor#body, like the Standard popup) in the built-in card, where 2.4.0 answered.notHandled. Setclient.popups.options.blockPopups = .offto keep the 2.4.0 behavior. - Action needed: block links go to the new
CFPopupOptions.onBlockAction, notonAction. If you setonActionto route your own links, setonBlockActiontoo: without it a block link with an app custom scheme is not opened. - Action needed: a custom SwiftUI host should observe
popups.currentPopup(campaign or block);popups.currentstill holds campaigns only. - Fixed: a closed in-app message no longer comes back on every sync when analytics consent is not granted.
See the changelog.
What's New in 2.4.0
- Action needed:
ContentFlowEnvironmenthas a new case,.unknown, which a created publishable key (<workspaceId>_cfpk...) now reports onclient.environmentinstead of.live(its environment is stored on the server). AswitchoverContentFlowEnvironmentwithout adefaultno longer compiles: add the case, or adefault. - New: popup frequency control.
shouldShow(_:),frequencyReason(_:),getPopup(screen:)andresetFrequency()apply the portal's frequency rules and the workspace popup limits on the device, through a local ledger (see Popups and frequency). - New: SDK-drawn campaign popup.
presentPopup(screen:locale:),screenChanged(_:locale:),closePopup()and.contentFlowPopups(client:screen:locale:)draw the in-app campaign thatgetPopup(screen:)offers; automatic presentation is off by default (CFPopupOptions.autoPresent). New tracking helperstrackCampaignImpression(_:),trackCampaignClick(_:),trackCampaignDismiss(_:),trackCtaClick(_:)andtrackDismiss(_:). - Fixed: one session id per session instead of one per event batch, sent as
X-CF-Session-Idon content reads and ascontext.sessionIdon every event batch.getConsentConfig(preset:)keeps the whole preset (optional,industry,region,defaultTopics,availableTopics,legalFramework). - Changed: requests declare
frequency-v1as well, soX-CF-SDK-Capabilitiesiserasure-partial-v1,frequency-v1.
See the changelog.
What's New in 2.3.0
- Action needed:
ContentFlowErrorhas a new case,.invalidKey(statusCode:code:droppedCount:). AswitchoverContentFlowErrorwithout adefaultno longer compiles: add the case, or adefault. - Action needed: a rejected key (HTTP 401, or 403
INVALID_SDK_KEY/MISSING_SDK_KEY/SDK_KEY_IN_QUERY/TENANT_KEY_MISMATCH) now stops the client for that key: stream, sync and location stop, queues are dropped, and later calls throw.invalidKeywithout a request. Start a new client with a corrected configuration, or restart the app, to resume. - Action needed: 2.3.0 is licensed under the proprietary ContentFlow SDK License (
LICENSE); earlier releases keep their terms. - New: on-device geofences (
startGeofencing(_:)withContentFlowLocation, see On-device geofences), workspace feature switches (client.features),disableStrings(_:writeKey:)(server/CI only). - Fixed: retries cover only transport failures, timeouts, 429 and 5xx; every retry or offline buffer is capped at 500 items; location is sent at most 100 points per request; location batches are no longer re-sent, refused ones are dropped, and fixes are handled in order; an app-set diagnostics level is no longer overwritten.
- Changed: requests declare
X-CF-SDK-Capabilities: erasure-partial-v1, so a partial erasure answers 207 (deleteIdentity()throws and keeps the device id) instead of 502.CFSecureStorageno longer falls back to an XOR-obfuscated copy inUserDefaults. - Deprecated:
CFRequestSigner,CFSecureStorageandgetIndustryProfile().
What's New in 2.2.2
- New: every request sends
X-CF-SDK-Version: swift/2.2.2and an emptyX-CF-SDK-Capabilities, so the server can tell SDK versions apart.User-Agentis unchanged.
What's New in 2.2.1
- Action needed: the default base URL is now
https://api.contentflow.click/sdk/v1(it washttps://app.contentflow.click/sdk/v1). If you enable certificate pinning and don't setbaseURL, add the api-host pins first; the hosts have different keys (see Certificate pinning). A base that doesn't end in/sdk/v1logs a one-time "legacy path" notice. - New: configure the base URL without code, with
ContentFlowBaseURLinInfo.plist(see Configuring the base URL).
What's New in 2.2.0
- Breaking / action needed: device location is in the separate opt-in product
ContentFlowLocation(await client.enableDeviceLocation()), and it's gated on analytics consent.updateIndustryProfilerequires a signed-in user. - New:
getLocales(),deleteIdentity()(GDPR/PDPL erasure), device location and geofences,registerStrings(_:writeKey:)(server-side only). - Fixed: location batches and geofence events now reach
POST /sdk/location;updateIndustryProfileusesPOST /sdk/profile.
What's New in 2.1.0
- Certificate pinning works and is wired into the SDK (off by default). Set
ContentFlowConfiguration.certificatePinsto at least 2sha256/<base64>public-key pins. The system trust check (chain, validity, hostname) runs first, then a key in the validated chain must match, during the TLS handshake and before any request is sent. Pins survive certificate renewal. See CHANGELOG for theCFCertificatePinnerAPI changes.
What's New in 2.0.1
- Bug fix: Fixed cache not cleared on trait changes for same user
- Bug fix: Fixed uncertain events not purged on identity switch
- Version alignment: All SDKs now share the same version number
What's New in 2.0.0
Event categories:
commerce(_:),engagement(_:),identity(_:),growth(_:),account(_:),system(_:),location(_:)IP Geolocation:
getIPGeolocation(),enrichWithIPGeolocation()Location Batch:
sendLocationBatch(_:sessionId:)(reachesPOST /sdk/locationfrom 2.2.0)Localization:
setLocale(_:),getStrings()withstrings.string(_:fallback:), string Sensors (getLocales()arrived in 2.2.0)iOS 15+, macOS 12+, tvOS 15+, watchOS 8+
Swift Package Manager, no third-party dependencies
async/await with mutable client state isolated in an actor
canonical SDK base and default:
https://api.contentflow.click/sdk/v1(2.2.0 and earlier defaulted tohttps://app.contentflow.click/sdk/v1, which keeps working)
Install from an extracted release
Download contentflow-sdk-swift-2.4.1.zip and the checksum file SHA256SUMS-2.4.1.txt (for example curl -fLO https://dev.contentflow.click/downloads/sdk/2.4.1/contentflow-sdk-swift-2.4.1.zip). Every version is listed on Releases and downloads.
cd Vendor # a folder in your app repository
shasum -a 256 contentflow-sdk-swift-2.4.1.zip # must equal this file's hash in SHA256SUMS-2.4.1.txt
unzip contentflow-sdk-swift-2.4.1.zip # creates contentflow-sdk-swift-2.4.1/ (keep the layout)- In Xcode choose File → Add Package Dependencies → Add Local.
- Select the extracted
Vendor/contentflow-sdk-swift-2.4.1directory. - Under the app target's Frameworks, Libraries, and Embedded Content, add the
ContentFlowlibrary (andContentFlowLocationonly if you use device location). - Build once.
import ContentFlowresolves with no network access.
For a package-based application:
dependencies: [
.package(path: "../Vendor/contentflow-sdk-swift-2.4.1")
]
.target(
name: "App",
dependencies: [.product(name: "ContentFlow", package: "contentflow-sdk-swift-2.4.1")]
)This local dependency resolves and builds offline after extraction. No package-registry or Git access is needed.
Initialize
Get your publishable key in the dashboard under Developers → Your keys. The Environment toggle switches between the test key <workspace-id>_test (Debug builds; also sees content published to Test) and the live key <workspace-id>_app (release builds). The publishable key is safe in an app; the write key and read key are server-only.
import SwiftUI
import ContentFlow
@main
struct MyApp: App {
init() {
// The workspace is derived from the key. start() loads the disk cache, syncs and opens live updates.
ContentFlow.start(apiKey: "<workspace-id>_test", locale: Locale(identifier: "ar"))
}
var body: some Scene { WindowGroup { HomeScreen() } }
}Anywhere later, ContentFlow.shared is the running client.
Full configuration
let client = ContentFlow.start(
configuration: ContentFlowConfiguration(
apiKey: "<workspace-id>_test",
tenantId: "<workspace-id>", // the part of the key before _test / _app
locale: Locale(identifier: "ar"),
diagnostics: .verbose // Debug builds; the default is .errors
)
)Migrating from a hand-written client: pass its stable installation id once as deviceId: in ContentFlowConfiguration so identify, consent, push and analytics stay on the same installation (see Identity and consent). New installs omit it.
Configuring the base URL
Prefer configuration over code, so a future host or version change needs no code change. Precedence: an explicit baseURL: > ContentFlowBaseURL in Info.plist > the default https://api.contentflow.click/sdk/v1.
<key>ContentFlowBaseURL</key>
<string>https://api.contentflow.click/sdk/v1</string>The Info.plist value must be https; http is accepted only for localhost, 127.0.0.1 and 10.0.2.2. Any other value is ignored with an error diagnostic, and the default is used. An explicit baseURL: keeps its existing check (http or https). The base URL is never fetched remotely: a remote value could redirect your app's traffic and keys, and would bypass pinning. A base that doesn't end in /sdk/v1 logs once per client: ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).
ContentFlow.start reuses an identical configuration. Starting with a different configuration closes the previous shared client so tenant timers, cache, identity, and live-update state never overlap.
Pass the language selected inside the app, even when it differs from the device language. When the user changes it, call await client.setLocale(selectedLocale). The SDK sends the normalized BCP 47 tag in both ?locale= and Accept-Language for blocks, strings, and campaigns.
Dynamic content and SwiftUI
ContentFlow.registerRenderer(for: "home.discovery") { block in
AnyView(DiscoveryCard(block: block))
}
struct HomeScreen: View {
var body: some View {
ContentFlowSlot(placement: "home.discovery")
}
}Placement is the exact, case-sensitive block key. ContentFlow supplies fields and targeting; the app owns native layout. An unregistered placement renders a generic development view in DEBUG and nothing in release.
Fields are addressed by tag:
enum DiscoveryFields {
static let title = FieldKey<String>("#title", type: .text)
static let image = FieldKey<ContentImage>("#image", type: .image)
}
let title = try block.require(DiscoveryFields.title)
let image = try block.value(for: DiscoveryFields.image)Use sync() for the authoritative block snapshot or fetchBlock(key:) for one block. updates() and blockState(for:) observe client state without creating another network connection:
// Live updates: published changes arrive over SSE while the app is in the foreground.
Task {
for await update in await client.updates() {
switch update {
case .blocks(let blocks, _): render(blocks)
case .strings(let strings): apply(strings)
case .invalidated: break // a revalidation follows automatically
case .failed(let error): print("ContentFlow update failed:", error)
}
}
}To see it work, publish a change to a block in the dashboard while the app is open: ContentFlowSlot re-renders with no relaunch.
Show in-app messages
In-app campaign sends and journey in-app steps arrive on sync() as blocks with the key journey_message (or the placement you chose for the campaign or journey), one per message (instanceId jm_<id>; the jm_ prefix, not the key, is what marks a message), newest first, up to 20. Each carries #title, #body, and, when set, #image and a #cta_label / #cta_url button (web links only). They show on the next sync: at start, on returning to the foreground, or when you call sync(). Live updates don't announce them, and polling runs only while the live stream is down, so call sync() when the user opens the screen that shows them. ContentFlowSlot(placement: "journey_message") shows only the newest one (and moves to the next once the user closes it); read the list instead:
enum MessageFields {
static let title = FieldKey<String>("#title", type: .text)
static let body = FieldKey<String>("#body", type: .text)
static let image = FieldKey<ContentImage>("#image", type: .image)
static let ctaLabel = FieldKey<String>("#cta_label", type: .text)
}
let messages = try await client.sync().blocks.filter { $0.key == "journey_message" }
for message in messages {
let title = try message.value(for: MessageFields.title) ?? ""
let body = try message.value(for: MessageFields.body) ?? ""
let imageURL = try message.value(for: MessageFields.image)?.url
// #cta_url is typed "url", which this SDK keeps as an unknown field value.
var link: URL?
if case .unknown(_, let raw)? = message.field("#cta_url")?.value, let text = raw.stringValue {
link = URL(string: text)
}
show(message, title: title, body: body, imageURL: imageURL, link: link, label: try message.value(for: MessageFields.ctaLabel))
_ = try? await client.trackEngagement(.impression(message)) // once, when it is on screen
}
func onButton(_ message: ContentBlock) async {
_ = try? await client.trackCtaClick(message) // counts the click once and stops later syncs delivering it
}
func onClose(_ message: ContentBlock) async {
_ = try? await client.trackDismiss(message) // stops later syncs delivering it
}When you track dismiss, cta_click or tap for a message, the SDK hides it on this device at once, with or without analytics consent: sync(), currentBlocks(), fetchBlock(key:), updates(), ContentFlowSlot and getPopup(screen:) all leave it out from then on, across app restarts. The SDK keeps only the message's instance id and the time, for 8 days (at most 200), and forgets them on logout(), on a change of user in identify(userId:), on deleteIdentity() and on resetFrequency(). The event itself is still sent only with setAnalyticsConsent(.granted), and the message's server-side removal and campaign statistics depend on it; without consent a message you have not closed is delivered until it expires (the campaign's end, or 7 days). await client.currentBlocks() gives the same list without a network call. Details: SDK guide: Show in-app messages.
Identity and consent
let identity = try await client.identify(
userId: signedInUser.id,
traits: ["plan": "premium", "country": "SA"]
)
try await client.setAnalyticsConsent(.granted)
let channelReceipt = try await client.setChannelConsent(.granted, for: .push)Import a handwritten client's stable device id on the first SDK launch. The SDK persists it for later starts. An invalid or conflicting value throws before any request and is never silently replaced, which keeps identify, consent, push, and analytics attached to the same installation.
Nonempty traits replace the remote trait set; pass all values to keep. An empty dictionary omits traits and leaves the profile unchanged. IdentifyResult.applied and status report whether the requested user was identified, remained anonymous, or was unchanged. A partialIdentity error carries the applied result when identity succeeded but push-token linkage was unconfirmed; the SDK commits that identity before throwing. Ambiguous transport or 5xx failures clear old personalized content without claiming a rollback.
Fresh installs are denied locally. Until the app records an analytics choice, identify omits consent; it does not silently revoke a decision made elsewhere. Content delivery works while analytics consent is denied.
Right to erasure
try await client.deleteIdentity() // DELETE /sdk/identify for this installationErases the device record, its link to a user and its analytics events on the server. Only after the server confirms does the SDK forget local identity, traits, segments, pending events, personalised cache and the analytics consent decision (back to "not decided"). A failed or ambiguous erasure throws and changes nothing locally; retry it.
Localization and Sensors
let strings = try await client.getStrings(locale: Locale(identifier: "ar"))
titleLabel.text = strings.string("home.title", fallback: "Welcome")
if strings.isSensor("home.title") {
_ = try await client.trackSensorImpression(key: "home.title", source: .viewport)
_ = try await client.trackSensorInteraction(key: "home.title")
}Sensor events are accepted only for keys in the most recently loaded catalog for the active locale. Sensor impressions are session deduplicated; interactions are not.
await client.setLocale(locale) clears locale-specific in-memory content and revalidates both blocks and strings. Block and string bodies have independent ETags and independent per-locale cache files. Late responses for the previous language are cached only in their own locale and cannot replace the selected language on screen. The SDK never invents a translation. When Arabic content is absent or withheld, the server may return the workspace source string or the block's base source fields, which can be English. LocalizedStrings.string(_:fallback:) uses the app fallback only when the key is genuinely absent from the delivered catalog; it does not replace server-supplied source text.
Locales
let catalog = try await client.getLocales() // GET /sdk/locales
let offerable = catalog.locales.filter { $0.coverage >= 80 }
let lastRead = await client.getCachedLocales() // memory only, never a guessAn empty list is a valid answer. A failure throws; the SDK never invents en/ar.
Registering source strings (server-side only)
let result = try await client.registerStrings(
[StringRegistration(key: "home.title", defaultValue: "Welcome", context: "Home header")],
writeKey: ProcessInfo.processInfo.environment["CONTENTFLOW_WRITE_KEY"]!
)Posts to POST /api/v1/strings/sync with X-CF-Write-Key. Run it from a build script or backend tool; a write key must never ship in an app.
To stop tracking keys (their text is still served):
let result = try await client.disableStrings(
["legacy.promo_title", "legacy.promo_body"],
writeKey: ProcessInfo.processInfo.environment["CONTENTFLOW_WRITE_KEY"]!
)
// result.disabled, result.alreadyDisabled, result.unknowndisableStrings posts { "keys": [...] } to POST /api/v1/strings/disable with X-CF-Write-Key. It turns the keys' sensors off, so the SDK stops sending string events for them (they drop out of smartKeys on the next strings refresh) and the server rejects any that still arrive; apps keep receiving their text. It takes 1 to 500 non-empty keys of at most 200 characters; duplicates are dropped, and anything else throws ContentFlowError.invalidConfiguration before a request is sent. Same write-key rule as registerStrings: server or CI only. Tracking is turned back on in the portal (Sensors → Enable sensors); there is no SDK call for it, and registerStrings never changes whether a key is tracked.
Device location
Location lives in a separate, opt-in product, ContentFlowLocation, so apps that never use location don't link CoreLocation's permission APIs (App Store ITMS-90683). Link it next to ContentFlow, then install it once:
.product(name: "ContentFlowLocation", package: "contentflow-sdk-swift-<version>")import ContentFlow
import ContentFlowLocation
await client.enableDeviceLocation()
// Ask only from your own UI, after explaining why. The SDK never prompts on its own.
let permission = await client.requestLocationPermission() // .whenInUse, .denied, …
guard permission.isAuthorized else { return }
if await client.locationAccuracyAuthorization() == .reduced {
// Approximate location only: fixes are km wide. Don't set a small minAccuracyMeters.
}
let here = try await client.requestLocation() // one fix, not sent anywhere
await client.registerGeofence(GeofenceConfig(fence: .circle(id: "branch-1", lat: 24.71, lng: 46.67, radiusMeters: 200)))
try await client.startLocationTracking(LocationTrackingConfig(mode: .balanced, batchSize: 10))
// …
await client.stopLocationTracking() // sends what is still batchedWithout ContentFlowLocation (or your own setLocationProvider(_:)), location calls report .unavailable.
Tracked fixes worse than minAccuracyMeters, closer than distanceFilterMeters to the last kept fix, or reported as mocked are dropped (the SDK logs a warning once when accuracy filtering drops fixes). The rest are batched to POST /sdk/location. Geofences (circle or polygon) are evaluated on each fix and report enter, exit and dwell (dwell is checked when a later fix arrives). The server now attributes device reports to the fences defined in the dashboard, so events for an app-defined fence id that the dashboard doesn't have no longer reach analytics. Prefer on-device geofences (below) for dashboard fences. Errors are LocationError (permissionNotRequested, permissionDenied, unavailable, timedOut, failed). Add NSLocationWhenInUseUsageDescription (and NSLocationAlwaysAndWhenInUseUsageDescription for always) to your Info.plist. Continuous tracking is unavailable on tvOS.
A batch counts as delivered on any 2xx with success: true, with or without data (servers before #930 answered without it). If the server refuses a batch (DEVICE_NOT_IDENTIFIED, LOCATION_CONSENT_REQUIRED), the tracker drops it with a location warning instead of retrying, because the same points can't succeed until identity or consent changes; called directly, sendLocationBatch and reportGeofenceEvent throw. A batch that failed in transport, with a 5xx or with a 429 is kept (up to 100 points) and sent with the next flush.
Consent and identity. Location is gated on analytics consent; there is no separate location-tracking consent. Nothing is sent while analytics consent is not granted.
- Revoking analytics consent stops collection (CoreLocation updates) and discards unsent points. Call
startLocationTrackingagain after a new grant. deleteIdentity()stops collection and discards unsent points before the erasure request, and waits for any upload already on the wire, so nothing reaches the server after erasure.- Logout or signing in a different user sends what the outgoing user collected while that user is still current, drops what can't be sent, and never retries it under the next user. Tracking continues in a new session.
updateIndustryProfile writes a domain profile for the signed-in user through POST /sdk/profile; the data is segmentable. It is write-only: getIndustryProfile() is deprecated and always returns nil without a request, because there is no read-back route, by design. KYC submission/state call routes the SDK API does not expose yet.
Campaigns
let campaigns = try await client.getCampaigns(placement: "home.hero")
if let campaign = campaigns.first {
_ = try await client.trackCampaign(campaign, type: .impression)
}The server evaluates campaign eligibility and returns campaigns in descending priority. The current campaign endpoint returns campaign records and variants without assigning a campaign variant; block variant assignment is a separate contract. The SDK preserves any delivered variant identifier and never invents one.
trackCampaign(_:type:) takes .impression, .tap, .ctaClick, .conversion and .dismiss (the user closed it). The shorthands trackCampaignImpression(_:), trackCampaignClick(_:) (cta_click) and trackCampaignDismiss(_:) send the same events; for blocks, trackCtaClick(_:) and trackDismiss(_:) match trackEngagement(.ctaClick(block)) and .dismiss(block).
Popups and frequency
New in 2.4.0. Server-side caps also apply to older SDK versions. Full guide: Popup frequency and limits.
A block or in-app campaign can carry a frequency rule set in the portal: at most N impressions or taps per hour, day, week, month or ever, at most N per session, a minimum time between shows, or "until the customer taps / closes / converts". The server already withholds an item that is capped. The SDK also keeps a local count, so the rule holds before the event batch is sent, offline, from cache and within a session. Your existing tracking calls feed it: trackEngagement(.impression(block)), .tap, .ctaClick, .dismiss, .conversion, and trackCampaign(_:type:).
// At most one modal, popup or interstitial for this screen: the screen's
// interruptive blocks and the in-app campaigns for the `home` placement,
// highest priority first, after their rules and the workspace popup limits.
if let popup = await client.getPopup(screen: "home") {
switch popup {
case .block(let block):
present(block)
_ = try await client.trackEngagement(.impression(block))
// When the user closes it:
_ = try await client.trackEngagement(.dismiss(block))
case .campaign(let campaign):
present(campaign)
_ = try await client.trackCampaign(campaign, type: .impression)
_ = try await client.trackCampaign(campaign, type: .dismiss)
}
}
// Any block or campaign you render yourself:
if await client.shouldShow(block) { /* show it */ }
let reason = await client.frequencyReason(block) // e.g. .cappedImpressions, .cooldown, .workspaceGuardgetPopup(screen:)matches blocks by their deliveredscreenand asks for campaigns withplacement=screen(no request while the workspacecampaignsswitch is off; a failed campaign request leaves the blocks). Blocks rank by their deliveredpriority(5 when absent), campaigns bypriority; ties keep the delivered order, blocks first.- Interruptive items also obey the workspace popup limits from
/sync(config.frequency.fatigue: per session, per day, minimum gap; 1, 3 and 30 minutes by default). Items marked to bypass them (regulatory notices) do not. - Windows use the workspace time zone (
config.frequency.timezone, Asia/Riyadh by default): "once per day" resets at local midnight, weeks start on Sunday. - A session is the client's lifetime; coming back after 30 minutes or more in the background starts a new one.
- Counts made before sign-in still count after it. The local counts are cleared by
logout()anddeleteIdentity().resetFrequency()forgets them on this device, so a "once ever" popup can be tested again (meant for debug builds and QA; the portal's "Reset popup frequency" clears the server's counts). - With the workspace
frequency_capsswitch off,shouldShowis always true and nothing is counted. - Local counting works without analytics consent (the count stays on the device and no event is sent); the server-side caps need analytics consent.
Session id
From 2.4.0 the SDK keeps one session id per session and sends it as the X-CF-Session-Id header on content reads (/sdk/sync, /sdk/blocks/{key}, /sdk/campaigns) and as context.sessionId on every event batch, so session funnels and the server's per-session rules agree. A session is the frequency ledger's session: it starts at cold start and a new one starts after 30 minutes or more in the background (and when the ledger is reset by resetFrequency(), logout or erasure). The id is a UUID, held in memory only, and carries no personal data. An event queued before a new session starts is still reported with the session it happened in.
Built-in campaign popup
New in 2.4.0.
The SDK can draw an in-app campaign offered by getPopup(screen:) itself: a centered card over a dimmed backdrop with the campaign image, title, body, CTA button and a close button. A block popup goes to your onBlockPopup handler when you set one; without one, a block that uses the standard fields is drawn in the same card (see Block popups below).
// SwiftUI: once, at the root. A change of `screen` calls screenChanged.
RootView()
.contentFlowPopups(client: client, screen: router.currentScreen)
client.popups.options = CFPopupOptions(
autoPresent: true, // off by default: upgrading never shows surprise UI
delayMs: 500, // 0...60000
theme: CFPopupTheme(accent: .indigo, cornerRadius: 20),
onBlockPopup: { block in presentMyModal(block) }, // optional: you draw and track blocks yourself
onAction: { campaign, url in router.handle(url) }, // return true when handled; false = the SDK opens http(s) / app schemes
onBlockAction: { block, url in router.handle(url) } // the same, for a block drawn in the built-in card
)
// Or by hand, without autoPresent (UIKit apps: presented over the top view controller)
let result = await client.presentPopup(screen: "home")
// result.shown, result.kind (.campaign / .block), result.id,
// result.reason (.nothingToShow, .alreadyShowing, .notHandled, .featuresOff, .noUIContext)- One popup at a time: while one is on screen,
presentPopupanswers.alreadyShowing.client.screenChanged(_:)cancels a pending, not yet shown, automatic presentation. - The SDK tracks the impression when the popup is shown and exactly one of
cta_click(CTA) ordismiss(close button, backdrop, Escape / Menu), through the normal consent-gated event path. Both feed the frequency ledger, so "until the customer taps / closes it" rules hold. javascript:,data:,file:,vbscript:,blob:andabout:URLs are never opened and never passed toonAction(the click is still tracked). The campaign image is loaded over https only.client.closePopup()takes the popup down with no dismiss event and frees the one-at-a-time slot;client.popups.dismissCurrent()closes it as a dismissal. Campaign text is rendered as plain text.- Direction follows the requested locale, else the campaign's, else the client's (Arabic, Hebrew, Persian, Urdu and other RTL languages mirror the layout, close button on the left). Dynamic Type, VoiceOver modal focus on the title, a localized close label ("Close" / "إغلاق") and Reduce Motion are respected.
renderCampaignPopupreplaces the built-in view with your own (CFCampaignPopupContext:campaign,layoutDirection,closeLabel,performAction(),dismiss()); tracking stays automatic.- watchOS has no system URL opener here: set
onAction(andonBlockAction) to handle the CTA.
Block popups
Changed in 2.4.1. In 2.4.0 a block popup without
onBlockPopupanswered.notHandled. SetblockPopups: .offto keep that.
When a block popup wins the screen and onBlockPopup is not set, the SDK draws it in the built-in card if the block uses the standard field tags:
| Tag | Card |
|---|---|
#title |
Title |
#body |
Body |
#image |
Image (https only) |
#cta_label |
Button label |
#cta_url |
Button link (same scheme filter as campaigns) |
#cta_visible |
false hides the button |
- A block "uses the standard fields" when it has a non-empty
#titleor#body. A block without them answers.notHandled, as before. Missing image or CTA fields are left out of the card. onBlockPopup, when set, always wins.client.popups.options.blockPopups = .offturns the built-in card off for blocks (.builtInis the default).- The result is
CFPopupPresentation(shown: true, kind: .block, id: <instanceId>). Same theme, RTL, accessibility, delay and one-popup-at-a-time rule as campaigns; a campaign and a block are never on screen together. - Tracked once per presentation with the block events (impression, then exactly one of
cta_clickordismiss), so frequency rules, statistics and dismissed in-app messages behave as when you track the block yourself. - The CTA closes the card, then calls
onBlockAction(block, url)(onActionstays campaign-only); see Block CTA links below. renderCampaignPopupdoes not apply to blocks; they always use the built-in card. In a SwiftUI host,popups.currentPopupis the campaign or block on screen (CFCampaignPopupContextorCFBlockPopupContext);popups.currentholds campaigns only.
Block CTA links (from 2.4.1). A block drawn in the built-in card follows the campaign link rules: only http, https and app custom schemes are passed on, and the cta_click is always tracked. The handler that gets the link differs per platform: JavaScript onAction(item, url) (item is the block), Flutter onAction(item, url) (item is a CFBlockActionContext, item.block), Kotlin and Swift onBlockAction(block, url) (onAction stays campaign-only there). Return true when handled; otherwise the SDK does what it does for a campaign. One extra rule on Kotlin and Swift: if you set onAction but not onBlockAction, a block link with an app custom scheme (myapp://...) is not opened by the system, because your app routes its own scheme; http and https links still open. Set onBlockAction to route block deep links.
Sending events
Events are recorded only after setAnalyticsConsent(.granted); before that, every call returns .droppedNoConsent.
// Block engagement (ContentFlowSlot records impressions for you)
_ = try await client.trackEngagement(.ctaClick(block))
// Business events
_ = try await client.commerce(CommerceEvent(action: .transfer, amount: 250, currency: "SAR", transferType: "local"))
// Identity events
_ = try await client.trackSignUp(username: signedInUser.id)
// Send now instead of waiting for the batch
_ = await client.flushEvents()Analytics acknowledgements
switch try await client.trackEngagement(.tap(block)) {
case .accepted:
break
case .partiallyAccepted(let accepted, let dropped):
logger.notice("ContentFlow accepted \(accepted), dropped \(dropped)")
case .deliveryUncertain(let ids):
logger.warning("ContentFlow result uncertain for \(ids)")
case .droppedNoConsent, .droppedInactiveSensor, .droppedQueueFull, .deduplicated, .queued:
break
}
switch await client.flushEvents() {
case .acknowledged(let receipt):
logger.notice("accepted \(receipt.accepted), dropped \(receipt.dropped), reasons \(receipt.dropReasons)")
case .deliveryUncertain(let ids, _), .rejected(let ids, _):
logger.warning("ContentFlow result uncertain/rejected for \(ids)")
case .nothingToFlush:
break
}The server returns aggregate accepted, dropped, optional byCategory, and dropReasons counts, with no per-item result or event-id deduplication. The SDK requires accepted plus dropped to account for the complete submitted batch. A partial ACK consumes the whole batch; incomplete accounting becomes delivery uncertainty instead of silent loss.
For a mixed [CFEvent] call, use await client.trackDetailed(events). Its CFEventBatchResult preserves accepted and dropped totals already confirmed by one category even if a later category fails, and reports uncertain ids, queued ids, local refusals, and typed per-event failures. Check accountedCount == submittedCount before treating the whole call as handled. The compatibility track(events) method throws its first failure, so it cannot carry the same complete feedback.
A timeout, cancellation, 5xx, or malformed ACK may have reached the server. The SDK removes that batch from automatic flushing and holds it in a separate in-memory uncertainty set. Inspect uncertainEventIds(), then choose one policy:
_ = await client.discardUncertainEvents() // at-most-once; possible loss
_ = await client.retryUncertainEvents() // at-least-once; possible duplicatesAmbiguous writes are never retried automatically. A 429 is different: the server did not apply the batch, so it goes back on the front of the event queue and is re-flushed after an exponential backoff with jitter (1 s base, 5 min cap), never sooner than Retry-After (seconds or an HTTP date, capped at 1 h); flushEvents reports it as .rejected(_, .rateLimited) and trackEngagement as .queued. Any other 4xx (for example a 403 USER_MISMATCH) means the batch was not applied and cannot succeed if resent: it is dropped, not held as uncertain. A successful event response confirms acceptance or queueing only; it does not prove durable storage.
Behavioral events and the uncertainty set are never written to disk. They do not survive process termination. Consent revocation clears both immediately.
Push registration
try await client.registerPushToken(deviceToken, platform: .apns)
let result = try await client.unregisterPushToken()
// result.removed == false is a successful idempotent result.Push registration does not grant push consent. Device-scoped unregister sends only the stable SDK device id. Pass userId: only when the app wants the server to verify the linked profile too. A partial server clear is an error, not success.
Call logout(userId:) when the app session ends. It detaches the identity and clears the server push token while retaining channel consent. A partial push clear throws partialLogout with the already-applied anonymous identity result so the app can retry logout without restoring personalized content.
The platform/provider pairs are ios/apns, android/fcm, web/web_push, and huawei/hms. Huawei registration also requires appId:.
Certificate pinning (optional, off by default)
Pinning is off unless you pass certificatePins. Turn it on only if you can ship an app update before the pinned keys change: an app pinned to keys the server no longer uses cannot reach ContentFlow at all.
let configuration = ContentFlowConfiguration(
apiKey: AppConfiguration.contentFlowPublicKey,
tenantId: AppConfiguration.contentFlowTenantId,
certificatePins: [
"sha256/<current key>", // the key the server uses today
"sha256/<backup key>" // a second key you can switch to without an app release
]
)Pin format. A pin is sha256/<base64>: the SHA-256 of the server key's DER-encoded SubjectPublicKeyInfo (SPKI), the same format OkHttp and TrustKit use. It pins the key, not the certificate, so a routine certificate renewal that keeps the key keeps working. Hex certificate fingerprints are rejected because they break at every renewal. To read the pin of a live host:
openssl s_client -connect api.contentflow.click:443 -servername api.contentflow.click </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | openssl base64Current pins (certificates valid until 2026-12-11): api.contentflow.click (the default from 2.2.1) is sha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8=; app.contentflow.click (the default in 2.2.0 and earlier) is sha256/A3O5BGFGDQZm4wNh1QpbA4QYeI0vT6D5tCSOy65IrhI=. Only the baseURL host is pinned, so pin the host you actually use, plus a backup.
Run it against the host in your baseURL. To pin an intermediate CA instead of the server key, take the pin from that CA certificate with the last three commands. A pin matches any certificate in the validated chain.
Backup pin. At least two distinct pins are required. The second must be a key you can actually switch to: a spare key pair generated in advance and kept offline, or the key of the CA that issues the server certificate. Two pins for the same key are counted once.
Rotation. Ship an app version whose pins include the new key while the old key is still in use, wait until enough users have updated, then switch the server to the new key. Remove the old pin in a later release.
What is checked, and when. Every connection the SDK makes, including the live-updates stream, is checked during the TLS handshake before any request is sent:
- The system's normal trust evaluation for the host: chain to a trusted root, validity dates and hostname. A failure rejects the connection whatever the pins say.
- Then at least one certificate in the validated chain must have a pinned key.
An invalid pin list (fewer than two distinct pins, a malformed pin, or pins with an http base URL) leaves the client unconfigured: every call throws ContentFlowError.invalidConfiguration, and nothing is ever sent unpinned. TLS-intercepting proxies and debugging tools fail while pinning is on; that is the point of pinning.
CFCertificatePinner can also pin an app's own sessions: try CFCertificatePinner(pins: [...]).createSession().
On-device geofences
On-device geofencing lets iOS watch the workspace's fences with CLLocationManager region monitoring, with no continuous GPS. iOS wakes the app, or relaunches it in the background, when the device crosses a fence. The SDK reports each crossing to ContentFlow as a geofenceEvents entry on POST /sdk/v1/location, with source: "device". The server dedupes these against its own evaluation, so one stay fires once, whichever side sees it first.
Setup. Link ContentFlowLocation, install it, and start geofencing early at launch, for example in application(_:didFinishLaunchingWithOptions:). When iOS relaunches a terminated app for a crossing, the SDK must be listening again to report it.
import ContentFlow
import ContentFlowLocation
await client.enableDeviceLocation() // installs the CLLocationManager region monitor too
try await client.setAnalyticsConsent(.granted)
try await client.setChannelConsent(.granted, for: .location)
await client.startGeofencing() // GeofencingOptions(reservedAppRegions: n) to keep slots free
// …
let status = await client.geofencingStatus // .stopped, .idle or .monitoring
await client.stopGeofencing() // unregisters every SDK region; the app's own are untouchedWhat the app must add.
- Info.plist:
NSLocationWhenInUseUsageDescriptionandNSLocationAlwaysAndWhenInUseUsageDescription. - "Always" authorization, which your app asks for:
requestLocationPermission(always: true), from your own UI. Region monitoring needs it. The SDK never prompts, and it stays idle without it, retrying at the next foreground. - You don't need the
locationentry inUIBackgroundModesfor region monitoring.
How it runs.
- Geofencing is opt-in. It runs only while all of these hold:
- the app called
startGeofencing; - analytics consent is granted;
- location channel consent is granted on this device (
setChannelConsent(.granted, for: .location)). The SDK checks this strictly, and a channel that was never set counts as not granted. On the server, location consent is enforced by/geofencesreturning no fences:/locationrefusals are keyed on analytics consent, not location consent; - the workspace's
locationswitch is on; - the app has "Always" permission.
- the app called
- When any of these stops holding, every SDK-owned region is unregistered and unsent events are dropped. This happens on an analytics or location consent revoke, when the switch turns off, on
deleteIdentity(), and when the server refuses an event for missing consent. Your request is kept, so geofencing resumes when everything is back. The switch never starts geofencing that your app didn't start. - The SDK fetches fences from
GET /sdk/v1/geofences(ETag; a 304 keeps the registered regions):- on start;
- at a foreground once the server's
refreshAfterSechas passed; - after a significant move.
- The SDK sends a coarse last-known position (lat and lng rounded to 3 decimals, about 100 m, sent together or not at all; never a new fix) so the server can order fences nearest-first.
- A polygon fence arrives as its bounding circle, and that circle is what gets registered. The server confirms the exact shape. Fences carry an id, a region and triggers, but no name: the dashboard owns names.
- Every fence is registered for both entry and exit, and both crossings are always reported, whatever the fence's enter/exit triggers. The server needs the exit to close a stay, and the triggers only decide what fires server-side. A dwell is reported only when the fence's dwell trigger is on.
- Each event carries the OS crossing time, lat and lng. The server rejects reports more than 5 minutes in the future or more than 24 hours old. A dwell is stamped with the time it was reached (enter time +
dwellMs, never later than now). A dwell more than 24 hours old is skipped: it is marked reported and never sent. - A 502 or 503 from
/geofenceskeeps the registered regions, and the SDK retries at the next wake. An empty list (fences: [], which the server sends when location is off for the workspace or the user has no location consent) unregisters every SDK region. - Until the SDK API serves
/geofences, the route answers 404. Geofencing then stays quietly idle and tries again at the next refresh.
Limits.
- iOS monitors at most 20 regions per app, shared with your app's own regions. The SDK counts the regions your app already monitors (anything not prefixed
cf.geo.) and never touches them. UsereservedAppRegionsto hold slots for regions you add later. The server'smaxRegionscaps it further. - The SDK asks for exactly its free slots (
max). The server answers withtruncated: truewhen it holds more eligible fences. The SDK then registers the nearest free slots − 1 fences and uses the last slot for a boundary region around the current position. That region reaches the nearest fence left out, or the farthest one returned. Withtruncated: falseeverything returned is registered, with no boundary. - Older servers don't send
truncated. With them the SDK treats a full answer as "more exist" and asks for one extra fence from then on, until an answer carries the field again. - Leaving the boundary re-fetches fences and re-selects the nearest. That refetch skips the ETag. Sending it would also be correct: the server hashes the list for the rounded position, so a 304 would mean the nearest set for the new position is unchanged.
- Dwell has no OS support on iOS. The SDK records the enter time (persisted, so it survives a relaunch) and reports the dwell at the first wake after
dwellMswhile the device is still inside. A wake is a crossing, a foreground or a fetch. A dwell is therefore reported late, never early, and not at all if the app isn't woken before the exit. - Crossing times are as precise as iOS makes them: typically tens of seconds to a few minutes, and regions under about 100 m are unreliable. If no recent fix exists, the event carries the fence centre.
- Events go through the same path as location batches. A refusal (for example
DEVICE_NOT_IDENTIFIED) is dropped. A transport failure, 5xx or 429 keeps up to 50 events for the next crossing, foreground or fetch. Retained events are dropped on an identity change. - iOS keeps monitored regions after the app is terminated. Once per launch, before registering anything, the SDK removes every
cf.geo.*region (the boundary included) left by an earlier launch. Every teardown does the same:stopGeofencing, a consent revoke, or thelocationswitch going off. This holds even in a launch that never started geofencing: a plainstopGeofencing(), or installing the monitor while consent is missing or the switch is off, removes them. Regions without the prefix belong to your app and are never touched. - Region monitoring is iOS and macOS only; on tvOS and watchOS geofencing stays idle. Web SDKs rely on server-side evaluation only.
Workspace feature switches
A platform operator can switch some built-in SDK features off for a workspace. The switches arrive in the /sync response as config.features, so there is no extra request, and the SDK honours them on the device:
live_updatesoff: the live-updates stream is not opened (or is closed). Content stays fresh through normal ETag polling (periodicInterval) and foreground revalidation. It reopens on a later sync that switches it back on, if yourSyncPolicyenables live updates.campaignsoff:getCampaignsreturns an empty list without a network request.locationoff: tracking pauses and points not yet sent are dropped, as the server would drop them. If your app started tracking, it resumes when the switch comes back on. On-device geofences unregister every SDK region and resume the same way. DirectsendLocationBatchcalls are not blocked on the device; the server drops them.frequency_capsoff: the server applies no popup frequency rule, and the SDK ignores its local counts (shouldShowis always true). The SDK side is new in 2.4.0.
ContentFlow can also turn these off for one workspace or for every workspace during an incident. The server handles each one with a response the SDK already treats as success, so nothing changes on the device beyond what features reports:
content_syncoff: the server keeps the content you already have (a304), or answers an empty list on a first install, so your built-in defaults show.stringsoff: the server keeps the strings you already have, or answers an empty catalog, so your bundled strings show.analytics_eventsoff: the server acknowledges and drops every event (dropReasons.feature_disabled).push_preferencesoff: topic and preference changes are accepted but not stored. Push token registration keeps working.geofencesoff: the server sends no fences to monitor.profile_updatesoff: the device is still registered and erasure is still enforced, but nothing is written to the customer's profile; the device keeps its last known segments.
let features = await client.features // SDKFeatures: liveUpdates, campaigns, location, frequencyCaps, contentSync, strings, analyticsEvents, pushPreferences, geofences, profileUpdatesEvery switch is on until a sync says otherwise, and a missing or malformed value counts as on. A 304 keeps the last value, which is cached with the blocks. A switch can only turn a built-in feature off: it never changes the base URL, keys, authentication, certificate pinning or consent, and never starts something your app did not start. Location still needs analytics consent.
What the SDK stores on the device
The SDK doesn't encrypt anything it stores, and it doesn't sign requests. Requests are authenticated by the publishable key, which the server binds to exactly one workspace. None of the stored values is a credential.
- Device id: a random UUID in
UserDefaults(never an advertising or hardware id), one per key and tenant. - Consent state and the segment names from the last
identify()(with the device and user id they belong to) inUserDefaults. - Content cache: blocks and strings in the app's Caches directory, with a SHA-256 checksum for integrity (not secrecy).
- Visitor id, only if your app calls
resetVisitorId(), inUserDefaults. - Popup frequency counts (from 2.4.0; impressions and taps per window, last shown time, whether an item was tapped, closed or converted, per user or device and block instance or campaign id) and the last
config.frequency, inUserDefaults. Counters and timestamps only, never content. Cleared bylogout(),deleteIdentity()andresetFrequency(). - Closed in-app messages: the instance ids of in-app messages (
jm_…) the user closed or tapped, with the time, inUserDefaults, so they stay hidden without analytics consent. Kept 8 days, at most 200. Cleared bylogout(), a change of user,deleteIdentity()andresetFrequency().
Queued analytics events stay in memory and are lost when the process ends. deleteIdentity() clears the local identity, segments, personalised cache and consent decision (see Right to erasure); uninstalling the app removes everything.
CFRequestSigner and CFSecureStorage are deprecated and the SDK never uses them. No ContentFlow server verifies X-CF-Signature, and a signing secret shipped in an app can be extracted from it, so signing adds no protection. CFSecureStorage writes to the Keychain only: if the Keychain write fails, setSecure returns false and nothing is stored (before 2.2.3 it silently fell back to an XOR-obfuscated copy in UserDefaults, which isn't encryption). Both will be removed in the next major version.
Cache, lifecycle, and URL behavior
- Cache namespace: base URL + tenant + complete publishable key + derived environment + stable device + hashed user scope + schema version.
- Locale is part of each block and string filename.
- Bodies and ETags are written atomically with a SHA-256 checksum.
- A 304 is used only with its matching cached body; a missing or corrupt body triggers one unconditional GET.
- A failed refresh keeps the last good snapshot.
- HTTP 2xx with
success:falsethrows (the location tracker drops a refused batch instead, see Device location). Explicit partial identity and logout responses commit only the confirmed identity transition, then throwpartialIdentityorpartialLogout. Other failure envelopes do not grant consent or update delivered content. Analytics withdrawal denies locally and purges events immediately even if server propagation fails; the call still throws so the app can retry propagation. - Backgrounding closes SSE and attempts a flush; foregrounding reconnects and revalidates.
close()cancels timers, stream and in-flight sync, then finishes update streams.
The default and canonical base is https://api.contentflow.click/sdk/v1, producing /sdk/v1/identify. The portal-host base https://app.contentflow.click/sdk/v1 (the default in 2.2.0 and earlier) reaches the same SDK API and keeps working. A legacy base ending in /api/v1 is accepted and produces /api/v1/sdk/identify. An already prefixed /api/v1/sdk base is not prefixed again.
Error handling: rejected keys and bounded queues
Rejected key (key-dead). The client treats its publishable key as dead on any HTTP 401 (whatever the code: INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY, or none), and on a 403 whose code is INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH. The code is read from either envelope shape: top-level {"code": ..., "error": "..."} (the SDK API) or nested {"error": {"code": ..., "message": ...}}. Every other 403 (DEVICE_NOT_IDENTIFIED, USER_MISMATCH, IDENTITY_*, LOCATION_CONSENT_REQUIRED, a 403 with no code) is a per-route refusal and leaves the key alive. Write-key calls (registerStrings, disableStrings) never affect the publishable key.
When the key dies, the client, once:
- stops the live-updates stream and does not reconnect, and cancels the periodic sync timer and any scheduled event retry;
- stops location tracking and drops everything queued for the key: queued events (string-sensor events included), uncertain events, location points, and batches on the wire;
- reports
ContentFlowError.invalidKey(statusCode:code:droppedCount:)throughContentFlowDiagnostics(categoryauth, the typed error indiagnostic.error), at error level and even when the diagnostics level is.none.
After that, no request is sent for the key. sync, trackEngagement, startLocationTracking and every other call throw .invalidKey immediately, flushEvents returns .rejected(_, .invalidKey), tracked events are not queued, and no further diagnostic is emitted. client.isKeyRejected tells you the client is in this state.
ContentFlowDiagnostics.setHandler { d in
if case .invalidKey(let status, let code, let dropped)? = d.error {
logger.fault("ContentFlow key rejected (\(status) \(code)); dropped \(dropped) queued items")
}
}To resume, fix the key or tenant and start a new client: ContentFlow.start(configuration:) with the new configuration closes the dead client and starts a fresh one (a ContentFlowClient(configuration:) you create yourself works the same way). The state lives in memory only, so an app restart also starts clean.
Bounded queues. Every offline or retry buffer holds at most 500 items and drops the oldest past that: the event queue (string-sensor events included; a full queue makes room instead of refusing new events), the uncertain-delivery set, and the location retry buffer. Drops are counted and reported as one events or location warning at most once a minute, carrying the count since the last report.
Retries. Only transport failures, timeouts, 429 and 5xx are retried, with exponential backoff and jitter; Retry-After is honoured as the minimum delay. Reads retry up to three attempts; event writes follow the rules in Analytics acknowledgements; location batches keep failed points for the next flush. Other 4xx responses are not retried.
Diagnostics
ContentFlowDiagnostics.level = .verbose // Debug builds; wins over the configuration
ContentFlowDiagnostics.setHandler { d in print("[CF][\(d.category)] \(d.message)") }A level you set with ContentFlowDiagnostics.level wins, before or after ContentFlow.start, and every later start keeps it, including a start with a different configuration. Until you set one, each start applies the configuration's diagnostics level (default .errors). ContentFlowDiagnostics.resetLevel() hands control back to the configuration.
Categories worth knowing: auth (the one-time rejected-key report, always emitted), config (base URL and configuration problems, error level), envelope (a response that is not a ContentFlow envelope), decode (schema problems in delivered blocks), fields (wrong-typed or missing field reads), slot (no renderer registered), cache (a discarded or unwritable snapshot), events (analytics not sent or dropped), location (fixes or batches dropped), sse (live-updates connection), http (read retries).
Runnable read-only example
swift run ContentFlowExample
CONTENTFLOW_PUBLIC_KEY='<publishable-key>' \
CONTENTFLOW_TENANT_ID='<tenant-id>' \
CONTENTFLOW_LOCALE='ar-SA' \
swift run ContentFlowExampleWith no environment values the executable prints setup help and exits. With values it performs read-only block and string fetches. It does not identify, change consent, register push, or emit analytics.
The SwiftUI project at Examples/ContentFlowSampleApp resolves this package locally, defaults its app-selected language to Arabic, and includes an Arabic/English toggle. It performs no ContentFlow request until its Run scheme has placeholder-free environment values.
Test your integration
Use the test key <workspace-id>_test in Debug builds and publish the block you are testing to Test in the dashboard. client.environment reports .test. First check the key and content without the app:
curl -s https://api.contentflow.click/sdk/v1/sync -H 'X-CF-Key: <workspace-id>_test'
# {"success":true,"data":{"blocks":[...]}} an empty blocks array means nothing is published for this key yetThen run a smoke test in a Debug build:
#if DEBUG
ContentFlowDiagnostics.setHandler { d in print("[CF][\(d.category)] \(d.message)") }
let client = ContentFlow.start(configuration: ContentFlowConfiguration(
apiKey: "<workspace-id>_test",
tenantId: "<workspace-id>",
diagnostics: .verbose))
Task {
// Content
let result = try await client.sync()
print("blocks:", result.blocks.map(\.key), "freshness:", result.freshness)
let strings = try await client.getStrings()
print("strings locale:", strings.locale)
// Events: fresh installs are analytics-denied, so nothing is recorded until granted
try await client.setAnalyticsConsent(.granted)
if let block = result.blocks.first { _ = try await client.trackEngagement(.impression(block)) }
if case .acknowledged(let receipt) = await client.flushEvents() {
print("events accepted:", receipt.accepted, "dropped:", receipt.dropped, receipt.dropReasons)
}
}
#endif| Symptom | Cause and fix |
|---|---|
invalidConfiguration mentioning the tenant |
The key does not end in _app/_test; pass tenantId: explicitly |
authenticationFailed (401) |
Wrong or missing key |
sync() returns no blocks |
Nothing is published for this key's environment, the instance targets a segment this device is not in, or the workspace id in the key is mistyped |
trackEngagement returns .droppedNoConsent |
Call setAnalyticsConsent(.granted) first (from the user's choice in a real build) |
dropReasons contains device_not_identified |
Identify failed for this device; check the diagnostics output |
Every call throws invalidConfiguration with pins set |
Fewer than two pins, a malformed pin, or pins for another host (see Certificate pinning) |
| Dashboard impression counters do not move | Events sent with a _test key are accepted but never change the production block counters |
Upgrading
- Download and verify the new archive (see Install), then extract it next to the old one.
- In Xcode, remove the old local package and Add Local… the new folder, then File → Packages → Reset Package Caches. In a
Package.swift, update the path and everypackage:argument, because a local package's identity is its folder name:.package(path: "../Vendor/contentflow-sdk-swift-<new-version>") .product(name: "ContentFlow", package: "contentflow-sdk-swift-<new-version>") - Read the Action needed items in the changelog for every version you skip (2.2.0: link
ContentFlowLocationand callenableDeviceLocation(); 2.2.1: add the api-host pins if you pin and don't setbaseURL). - Run Test your integration again, then delete the old folder.
Build and test the SDK package
This checks the SDK itself, not your app's integration (see Test your integration for that).
swift build -j 2
swift test -j 2
xcodebuild -scheme ContentFlow -destination 'generic/platform=iOS' build
xcodebuild -project Examples/ContentFlowSampleApp/ContentFlowSampleApp.xcodeproj \
-scheme ContentFlowSampleApp -destination 'generic/platform=iOS Simulator' \
-jobs 2 CODE_SIGNING_ALLOWED=NO buildThe test suite uses a mock transport and the shared runtime-contract.json fixture. It performs no production tenant mutations and contains no credentials.
The compiled fixtures and sample validate the client contract, not the live server rollout. Signed-out device-only consent, typed identity push-partial receipts, logout push detachment, and complete SSE invalidation depend on the final server lifecycle rollout; verify those flows against staging after deployment before enabling them in a production app.
Version and REST compatibility policy
Each released archive is immutable and versioned. Breaking changes to the public Swift API require a new major SDK version. ContentFlow REST contract changes preserve existing valid v1 request and response shapes until an announced migration or a new major contract. Deprecations are documented in the changelog with migration guidance and a retirement date before enforcement; no retirement date is currently announced.
Current limits
- Event ACKs are aggregate and represent acceptance or queueing, not durable storage.
- There is no backend event-id deduplication; explicit uncertain-event retry may duplicate analytics.
- Offline analytics are memory-only and are lost on process termination.
- SwiftPM and macOS tests do not validate APNs delivery, Keychain entitlements, physical-device background time, or your app's final SwiftUI layout.
- The package includes optional domain APIs inherited from the source tree. General ContentFlow 2.4.1 release coverage is the initialization, identity and consent, blocks, strings, campaigns, Sensors, engagement analytics, lifecycle and cache, and push surfaces documented above.
See CHANGELOG.md.