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

See the changelog.

What's New in 2.4.0

See the changelog.

What's New in 2.3.0

What's New in 2.2.2

What's New in 2.2.1

What's New in 2.2.0

What's New in 2.1.0

What's New in 2.0.1

What's New in 2.0.0

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.

Terminal
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)
  1. In Xcode choose File → Add Package Dependencies → Add Local.
  2. Select the extracted Vendor/contentflow-sdk-swift-2.4.1 directory.
  3. Under the app target's Frameworks, Libraries, and Embedded Content, add the ContentFlow library (and ContentFlowLocation only if you use device location).
  4. Build once. import ContentFlow resolves with no network access.

For a package-based application:

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

Swift
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

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

XML
<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

Swift
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:

Swift
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:

Swift
// 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:

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

Swift
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

Swift
try await client.deleteIdentity()   // DELETE /sdk/identify for this installation

Erases 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

Swift
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

Swift
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 guess

An empty list is a valid answer. A failure throws; the SDK never invents en/ar.

Registering source strings (server-side only)

Swift
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):

Swift
let result = try await client.disableStrings(
    ["legacy.promo_title", "legacy.promo_body"],
    writeKey: ProcessInfo.processInfo.environment["CONTENTFLOW_WRITE_KEY"]!
)
// result.disabled, result.alreadyDisabled, result.unknown

disableStrings 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:

Swift
.product(name: "ContentFlowLocation", package: "contentflow-sdk-swift-<version>")
Swift
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 batched

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

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

Swift
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:).

Swift
// 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, .workspaceGuard

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

Swift
// 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)

Block popups

Changed in 2.4.1. In 2.4.0 a block popup without onBlockPopup answered .notHandled. Set blockPopups: .off to 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

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.

Swift
// 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

Swift
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:

Swift
_ = await client.discardUncertainEvents()  // at-most-once; possible loss
_ = await client.retryUncertainEvents()    // at-least-once; possible duplicates

Ambiguous 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

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

Swift
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:

Terminal
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 base64

Current 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:

  1. 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.
  2. 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.

Swift
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 untouched

What the app must add.

How it runs.

Limits.

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:

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:

Swift
let features = await client.features   // SDKFeatures: liveUpdates, campaigns, location, frequencyCaps, contentSync, strings, analyticsEvents, pushPreferences, geofences, profileUpdates

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

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

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:

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.

Swift
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

Swift
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

Terminal
swift run ContentFlowExample

CONTENTFLOW_PUBLIC_KEY='<publishable-key>' \
CONTENTFLOW_TENANT_ID='<tenant-id>' \
CONTENTFLOW_LOCALE='ar-SA' \
swift run ContentFlowExample

With 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:

Terminal
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 yet

Then run a smoke test in a Debug build:

Swift
#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

  1. Download and verify the new archive (see Install), then extract it next to the old one.
  2. 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 every package: argument, because a local package's identity is its folder name:
    Swift
    .package(path: "../Vendor/contentflow-sdk-swift-<new-version>")
    .product(name: "ContentFlow", package: "contentflow-sdk-swift-<new-version>")
  3. Read the Action needed items in the changelog for every version you skip (2.2.0: link ContentFlowLocation and call enableDeviceLocation(); 2.2.1: add the api-host pins if you pin and don't set baseURL).
  4. 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).

Terminal
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 build

The 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

See CHANGELOG.md.

Generated from the ContentFlow repository by sdk-release/scripts/render-docs.mjs. Downloads and checksums are on Releases and downloads. The SDKs are licensed under the ContentFlow SDK License.