ContentFlow Swift SDK
A native Swift and SwiftUI client for ContentFlow blocks, localized strings, identity, consent, and engagement analytics. Async/await throughout, every piece of mutable state actor isolated, and no third-party dependencies. It speaks the same REST contract documented on this site, so nothing here is magic you cannot inspect.
https://github.com/… URL that resolves, and we would rather say that here than have you discover it as a 404 during integration.Quickstart
Register a renderer, start the client, place a slot, read fields by tag.
Tenant id
Derived from the key. No Info.plist entry required.
Renderers
The payload has no layout. You own the view for every placement.
Requirements
| Item | Value |
|---|---|
| Package name | ContentFlow |
| Library product | ContentFlow |
| Swift tools version | 5.9 |
| Platforms | iOS 15, macOS 12, tvOS 15, watchOS 8 |
| Dependencies | None |
| SDK version string | ContentFlowSDK.version, currently 1.0.0 |
Those platform floors come from Package.swift and are the real minimums. The SDK deliberately uses ObservableObject rather than the newer observation macros so it works below iOS 17.
Base URL
The client defaults to ContentFlowConfiguration.defaultBaseURL, and you should leave it alone:
https://app.contentflow.click/api/v1baseURL, that is the first thing to check.Every request the SDK makes, the live-update stream included, carries the same four headers. They are built in one dictionary literal inside APIClient and nowhere else, with no conditional on any of them, so no code path can forget one.
| Header | Value on every request |
|---|---|
X-CF-Key | ContentFlowConfiguration.apiKey, your publishable key. |
X-Tenant-Id | ContentFlowConfiguration.tenantId: the tenant you pinned, or the one derived from the key when you pinned nothing. The configuration stores one value and cannot tell those two apart afterwards. See tenant id. |
X-CF-Device | ContentFlowClient.deviceId, generated and persisted by the SDK. |
User-Agent | contentflow-swift/1.0.0, built from ContentFlowSDK.name and ContentFlowSDK.version. |
Accept-Language is added whenever a locale is configured, on reads and writes alike. Neither this SDK nor the Kotlin SDK ever sends X-API-Key: both use X-CF-Key exclusively, even though the server accepts either.
X-Tenant-Id on any /sdk/* call, because the key already names the workspace. This SDK sends it anyway, on every call and on the stream, and so does the Kotlin SDK. The header is on the wire from an iOS app configured with a key and nothing else. That matters because a tenant that disagrees with the key is answered 403 TENANT_KEY_MISMATCH rather than ignored, so the blast radius is identical on iOS and Android. An earlier version of this page described the header as optional and sent only on an explicit pin. That was wrong, and the Swift test suite pins the real behaviour in testEveryRequestCarriesAllThreeAuthHeaders.The device id is a random UUID created once and stored in the Keychain, with a UserDefaults fallback where the Keychain is unavailable. It is never the IDFA, never identifierForVendor, and never a hardware identifier. Clearing app data or resetting the Keychain rotates it.
GET /sdk/sync, GET /sdk/blocks/:key, GET /sdk/strings) answer 200 with a workspace key alone, with no device id and no identify call, and on a live _app key they write nothing server side. That matters for an App Store privacy filing. A persisted UUID is still a persistent identifier you have to declare, and sending traits on identify can pull otherwise-offline apps into declaring sensitive user data they never needed to collect. If all you want is remotely editable copy, call the REST reads directly rather than driving this SDK, and declare nothing. See Anonymous, content-only mode.X-API-Key instead of X-CF-Key on any /sdk/* call and the effect is identical: supported today, not deprecated. A ?key= query parameter is refused everywhere except the live-update stream route, GET /sdk/stream, where an EventSource-style client cannot set request headers; used on any other /sdk/* route it is refused with 401 SDK_KEY_IN_QUERY.GET /sdk/stream is requested with ?key=, ?device=, ?tenantId=, ?locale=, and ?lastEventId= once a stream has been resumed, while the same key, tenant and device id also go out as headers on that identical request. Give ?device= and ?tenantId= the treatment this site already prescribes for ?key=: a query string reaches access logs, proxy logs, and anything downstream that forwards a URL, so a device id you assumed lived only in a request header does not. The Kotlin SDK sends no query parameters on the stream at all.GET /api/v1/read/users/{userId}/devices, GET /api/v1/read/devices/{deviceId}, and GET /api/v1/read/users/{userId}/segments, take a separate key shaped rk_live_ plus 24 lowercase hex characters. It is a server side and CI credential, never something this SDK sends or should hold. Full contract in rest-api.html.Environment comes from the key suffix
ContentFlowEnvironment.derived(fromKey:) reads the key and nothing else: a key ending exactly in _test is .test, everything else is .live. There is no environment argument, no environment field in any request body, and no way to flip one from the client. Test and live keys also get separate on-disk cache namespaces, so their content can never mix. Read the resolved value from client.environment.
Installation
Honest status first, because this matters more than the snippet.
| Distribution | Status |
|---|---|
| Local package (path dependency) | Works today. This is the supported path. |
| Vendored copy inside your repo or workspace | Works today. |
| Public Git URL with a semantic version tag | Pending. No public repository, no tag. A .package(url:from:) line will not resolve. |
| CocoaPods / Carthage | Not provided. |
Local package, Swift Package Manager
Point at your checkout of contentflow-sdk-swift. The package directory name is the package identity for a path dependency, so the package: label below matches the folder.
dependencies: [
.package(path: "../contentflow-sdk-swift")
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "ContentFlow", package: "contentflow-sdk-swift")
]
)
]Local package, Xcode
- Drag the
contentflow-sdk-swiftfolder into your project navigator, or use File → Add Package Dependencies → Add Local. - In your target's General → Frameworks, Libraries, and Embedded Content, add the
ContentFlowlibrary product. import ContentFlow.
xcconfig that is not committed, so you can rotate it without an App Store release.Quickstart
Four things, in this order: register a renderer, start the client, place a slot, read fields by tag. Skipping the first one is the difference between a card on screen and an empty VStack.
1 · Bootstrap
import ContentFlow import SwiftUI @main struct BankApp: App { init() { // 1. Tell the SDK what to draw for each placement. ContentFlow.registerRenderer(for: "home.discovery") { block in AnyView(DiscoveryCard(block: block)) } // 2. Start it. ContentFlow.start( apiKey: "ws_a1b2c3d4_app", tenantId: "ws_a1b2c3d4", locale: .current ) } var body: some Scene { WindowGroup { HomeScreen() } } }
2 · Place a slot
struct HomeScreen: View { var body: some View { VStack { BalanceView(account: user.account) ContentFlowSlot(placement: "home.discovery") TransactionsView() } } }
The slot renders cached content immediately, revalidates in the background, and keeps stale content on screen through a transient refresh failure. A placement with no published block renders EmptyView, never an error state. Adding more slots never adds another sync or another live connection.
3 · Read the fields
enum DiscoveryFields { static let title = FieldKey<String>("#title_main", type: .text) static let image = FieldKey<ContentImage>("#header_image", type: .image) static let price = FieldKey<Decimal>("#price", type: .number) } struct DiscoveryCard: View { let block: ContentBlock var body: some View { VStack(alignment: .leading) { // Lenient: nil when the field is not published. if let title = try? block.value(for: DiscoveryFields.title) { Text(title) } // Strict: throws .fieldMissing or .unsupportedFieldValue. if let image = try? block.require(DiscoveryFields.image), let url = image.url { AsyncImage(url: url) } } } }
Tests/ContentFlowTests/LandingPageSnippetTests.swift, which contains the advertised integration snippet verbatim along with the app-side types it assumes. Both start overloads shown on this page, the plain slot, the inline-renderer slot, and the explicit-client slot are all constructed there, so if any of them stops compiling, that file fails first.The workspace is derived from the key, not configured
Marketing publishes this two-argument form, and it works, no tenant required:
ContentFlow.start(apiKey: "ws_a1b2c3d4_app", locale: .current)
A real key is shaped <tenantId>_app for live or <tenantId>_test for test, and this SDK strips exactly that suffix. The server is more forgiving than that: it strips _app or _test as well, and when neither one terminates the key it falls back to everything before the last underscore. The two rules agree on every well-formed key and part company on legacy ones, which is why the difference gets its own table below. The server does not require X-Tenant-Id on any /sdk/* call, which is what lets this overload reach the right workspace with no Info.plist entry and no environment variable; the SDK still sends the header on every request, as Requirements sets out. Nothing about this two-argument form is a trap.
Pinning the tenant explicitly is optional
Pass it if you would rather a mismatch fail loudly than silently trust whatever workspace the key resolves to:
ContentFlow.start(apiKey: "ws_a1b2c3d4_app", tenantId: "ws_a1b2c3d4", locale: .current)
403 TENANT_KEY_MISMATCH and the call fails outright. Pinning can only narrow access, never widen it: there is no configuration that reaches a workspace the key alone would not.A key this SDK refuses is not always a key the server refuses
The server's resolver and this SDK's resolver are not the same function, and the difference is visible from your app. The server strips _app or _test, then falls back to the text before the last underscore. The resolver behind start(apiKey:locale:) strips _app or _test and has no last-underscore fallback at all: anything else resolves to nothing, the client is left unconfigured, and the first call throws ContentFlowError.invalidConfiguration before a request is ever built. The Kotlin SDK implements the server's rule byte for byte instead. The same legacy key can therefore be stopped on iOS and dialled on Android.
| Key you configure | Server resolves | Swift SDK | Kotlin SDK |
|---|---|---|---|
ws_abc_app | ws_abc | ws_abc | ws_abc |
pk_live_abc | pk_live, then 404 TENANT_NOT_FOUND | Refused before the call | pk_live, then 404 TENANT_NOT_FOUND |
acme_application | acme, then 404 TENANT_NOT_FOUND | Refused before the call | acme, then 404 TENANT_NOT_FOUND |
abc_ | abc, then 404 TENANT_NOT_FOUND | Refused before the call | abc, then 404 TENANT_NOT_FOUND |
_app | Nothing parses, 401 INVALID_SDK_KEY | Refused before the call | Refused before the call |
abc | Nothing parses, 401 INVALID_SDK_KEY | Refused before the call | Refused before the call |
Read the table this way: a legacy-shaped key that the server would resolve into a workspace id that does not exist is stopped client side on iOS and reaches the network on Android, where it comes back 404. Neither platform can reach a workspace the key does not name, so this is a difference in where the failure lands and what it is called, not in what is exposed. The Swift test suite asserts the divergence explicitly, with pk_live_abc as its named case.
Both the key and the workspace id come from Dashboard, Developers, "Your keys". Check ContentFlow.isStarted if you want a boolean: it is true only once start has been called with a configuration that actually validates.
start with the same configuration returns the running client. start with a different configuration, for example after a tenant switch, closes the old client first, so you never end up with two sets of timers or two live connections.X-CF-Key, X-Tenant-Id and X-CF-Device on every /sdk/* call including the stream even though the server requires only the key, both send ?locale= on GET reads and never on a POST, and neither ever sends X-API-Key. Different: (1) an unparseable key, per the table above, where Swift refuses what Kotlin dials; (2) stream resume, a ?lastEventId= query parameter here against the Last-Event-ID header there; (3) stream query parameters, sent here and absent there; (4) User-Agent, contentflow-swift/1.0.0 here against the HTTP client's own default there. See rest-api.html for the header contract both implement.Renderers are required, not optional
The payload carries content fields and their declared types. It carries no layout, no template identifier, and no rendering instructions. A field list cannot define a SwiftUI component, so ContentFlowSlot renders through a registry your app owns.
ContentFlow.registerRenderer(for: "home.discovery") { block in AnyView(DiscoveryCard(block: block)) }
Register during bootstrap, before or immediately after start. The closure is @MainActor and returns AnyView.
| Build | Unregistered placement renders |
|---|---|
| DEBUG | GenericBlockView, a deliberately plain field dump. Development affordance only. |
| Release | Nothing, plus an error diagnostic naming the placement. |
GenericBlockView has no design opinion and is off in release builds by default. A screen that looks right in DEBUG and blank in TestFlight is almost always a missing registration.Inline renderers
For a one-off, pass the view builder to the slot instead of registering. This bypasses the registry entirely for that slot.
ContentFlowSlot(placement: "home.carousel") { block in VStack { ForEach(block.fields) { field in Text(field.value.displayText ?? field.tag) } } }
Slot options
ContentFlowSlotModifier controls two things and nothing else.
| Property | Default | Effect |
|---|---|---|
showsDevelopmentFallback | ContentFlowSlotModifier.isDebugBuild | Draws GenericBlockView when no renderer is registered. |
tracksImpressions | true | Emits one impression event the first time a block instance becomes visible. Consent gated like every other analytics call. |
.default is both on (fallback in DEBUG only). .silent is both off. Apps running more than one tenant can pass an explicit client: ContentFlowSlot(placement:client:modifier:).
Placement is exactly the block key
There is no placement abstraction in the wire protocol yet. ContentFlowSlot(placement: "home.discovery") reads /sdk/blocks/home.discovery, and the string you pass is the block type key verbatim.
- Placement strings are case sensitive.
- The SDK performs no normalization, prefixing, case conversion, or fallback matching.
- If your dashboard keys do not look like
home.discovery, use whatever they actually are.
The renderer registry follows the same rule. Registering home.discovery and asking for Home.Discovery, home_discovery, or discovery resolves nothing. The package asserts exactly that, so it is a guarantee rather than a coincidence.
Fields are addressed by tag
On the wire, a block carries a values map keyed by tag, plus a fields schema array of { tag, type }. The SDK joins the two, so it renders by declared type instead of guessing from the tag name, and hands you a flat [ContentField]. Wire order is preserved on block.fields for debugging and generic rendering, but application code should never index into it: field order is not part of the contract, and a marketer reordering fields in the dashboard changes positions with no version bump you can react to.
Typed keys
Declare a FieldKey once, next to the screen that uses it. This is the whole type-safety story in v1: no code generation, no schema download, no build plugin.
// Explicit initializer let title = FieldKey<String>("#title_main", type: .text) // Or the named factories, which pick the type for you let subtitle: FieldKey<String> = .textarea("#body_copy") let hero: FieldKey<ContentImage> = .image("#header_image") let price: FieldKey<Decimal> = .number("#price") let enabled: FieldKey<Bool> = .toggle("#enabled") let variant: FieldKey<String> = .select("#variant") let slides: FieldKey<[ContentBlock]> = .collection("#slides")
Strict and lenient access
Both accessors live on ContentFieldContainer, which ContentBlock conforms to.
| Call | Field absent | Field present, wrong type |
|---|---|---|
value(for:) | Returns nil | Throws .unsupportedFieldValue |
require(_:) | Throws .fieldMissing | Throws .unsupportedFieldValue |
field(_:) | Returns nil | Returns the raw ContentField, untyped |
A type mismatch always emits a diagnostic as well as throwing, so a schema mistake surfaces as an error you can read rather than as UI that silently did not appear. Lookup is exact first; as a convenience the leading # is optional on the query, so field("#title") and field("title") resolve the same field. A duplicate tag resolves to the first occurrence and warns at decode time.
Field types
FieldType | Swift value |
|---|---|
.text | String |
.textarea | String |
.select | String |
.number | Decimal, never binary floating point, so money survives the round trip |
.toggle | Bool |
.image | ContentImage, with rawValue, url, alt, width, height |
.collection | [ContentBlock], the cards delivered under the tag. See Collections |
Seven types. Six are scalar; .collection is the one that holds a list, and its elements are ordinary ContentBlock values. A server type the SDK does not recognize decodes to ContentFieldValue.unknown(type:value:) with the raw JSON preserved, and warns. A value published with no schema entry is still surfaced, with its type inferred from the JSON kind, so content never disappears because a schema entry was missed. Neither case fails the block.
Collections: a card is a block
A field the dashboard declares as collection holds an ordered list of whole block instances, which the server inlines under the collection's tag. Each card arrives with its own key, instanceId, version, segment, values and field schema, and is decoded by the very same decoder that decodes a top-level block.
There is no Card type, no CollectionItem type and no second set of accessors. The model collapsed rather than grew: the only addition is that ContentFieldValue gained a .collection([ContentBlock]) case.
Reading one
collection(_:) lives on ContentFieldContainer, the protocol ContentBlock conforms to, so it is available on a wrapper and on a card alike.
func collection(_ tag: String) -> [ContentBlock] // ContentFieldContainer func element(at index: Int) -> ContentBlock? // [ContentBlock] func card(instanceId: String) -> ContentBlock? // [ContentBlock]
collection(_:) never throws and never returns nil. An absent tag, a tag carrying something other than a collection, and a collection the server filtered down to nothing all read as an empty array, so you can iterate the result without unwrapping and a wrapper never draws list chrome around zero cards. Tag normalization is the same as field(_:): the leading # is optional on the query.
element(at:) is the bounds-safe positional read for the times you genuinely want "the third card". Position is a way of reaching a card, never its identity. card(instanceId:) is the identity lookup.
A carousel, end to end
Register a renderer for the card key and one for the wrapper key. ContentFlowCollection resolves each card through the registry entry for the card's own block key, so a wrapper's renderer never has to know how its children look.
enum CardFields { static let title = FieldKey<String>("#title", type: .text) static let image = FieldKey<ContentImage>("#image", type: .image) } // A card is a block, so it gets an ordinary renderer. ContentFlow.registerRenderer(for: "reading_card") { AnyView(ReadingCard(block: $0)) } ContentFlow.registerRenderer(for: "reading_carousel") { carousel in AnyView( VStack(alignment: .leading) { if let heading = try? carousel.value(for: FieldKey<String>("#heading", type: .text)) { Text(heading) } ScrollView(.horizontal) { HStack(spacing: 12) { ContentFlowCollection(block: carousel, tag: "#slides") } } } ) } struct ReadingCard: View { let block: ContentBlock var body: some View { VStack(alignment: .leading) { if let image = try? block.value(for: CardFields.image), let url = image.url { AsyncImage(url: url) } if let title = try? block.value(for: CardFields.title) { Text(title) } } } }
Iterating yourself works just as well, and ContentBlock is Identifiable on instanceId, so ForEach keys correctly with no id: argument:
ForEach(carousel.collection("#slides")) { card in ReadingCard(block: card) // id is card.instanceId } let thirdTitle = try? carousel .collection("#slides") .element(at: 2)? .value(for: CardFields.title)
The two SwiftUI views
| View | What it does |
|---|---|
ContentFlowCardView(block:showsDevelopmentFallback:) | Renders one card through the renderer registered for that card's own block key. Falls back to GenericBlockView in DEBUG, and otherwise renders nothing and logs an error naming the unregistered key. |
ContentFlowCollection(block:tag:showsDevelopmentFallback:) | Renders every card under a wrapper's collection tag, in delivered order, each through ContentFlowCardView. An empty or absent collection renders nothing at all. |
ContentFlowCollection(cards:showsDevelopmentFallback:) | The same, for a card array you already hold. |
showsDevelopmentFallback defaults to ContentFlowSlotModifier.isDebugBuild on both, matching ContentFlowSlot. Identity is the card's instanceId rather than the array position, so reordering slides moves views instead of rebuilding them.
Analytics
Track a card the way you track any block: EngagementEvent.impression(card, position: index), .tap(card), and the rest. Each builder copies the block's own instanceId, key and screen onto the event, so a card is the owner of its own metrics in every wrapper it appears in, with no extra wiring. Per card performance works out of the box.
EngagementEvent builders take position. There is no wrapperInstanceId or collectionTag parameter on them today, so if you want a per placement breakdown, pass those yourself through the full initializer's properties. The metric owner stays the card either way.Malformed cards degrade, they never fail the wrapper
- A collection value that is not an array reads as an empty collection.
- Inside the array a card is skipped when it is not an object, has no usable
key, no usableinstanceId, or novaluesmap. One bad card never invalidates its siblings, and the survivors keep their delivered order. - Nesting is capped at one level, matching the server, so a hand-built or stale payload cannot drive unbounded recursion. A collection nested deeper reads as empty.
- Every one of those emits a diagnostic, so the reason is readable rather than invisible.
allowedBlockKeys, minItems and maxItems. The SDK reads only tag and type and ignores the rest: those are authoring constraints the server validates writes against, and an unrecognised card key still decodes as an ordinary ContentBlock.Collection decoding is covered by the test suite: a collection of cards decoding through the very same block decoder as a top-level block, asserted by equality against the same raw object parsed standalone; card identity being instanceId rather than array position; a card's image getting identical treatment to a top-level image; malformed cards skipped while siblings survive in order; a non-array value reading as empty; the one-level nesting cap; and an event on a card carrying the card's own instanceId.
Repeaters were withdrawn, and replaced by collections
RepeaterValue and no RepeaterItemThe repeater field type, and the RepeaterValue / RepeaterItem API documented on this page until now, were withdrawn and never shipped. They do not exist in the package. Its replacement is documented above: Collections.The withdrawn design stored a list's items as nested values inside a single block instance. That made every item a value rather than an entity, so an individual card could not carry its own segment targeting, its own schedule, its own approval state, its own A/B variants, or its own analytics. ContentFlow is instance centric and all of that machinery already exists at the instance level, so a list built out of values sat outside it.
A collection is that list expressed as a wrapper holding an ordered set of real block instances, so every card inherits all of it. In Swift that means FieldType.collection, ContentFieldValue.collection([ContentBlock]), collection(_:), and the two SwiftUI views. Nothing carries over from the old shape: declare FieldKey<[ContentBlock]>, not a repeater key, and expect whole blocks under the tag, never { id, values } items.
The platform-level explanation is at Blocks & fields, Collections.
Two different things are called version
| Property | Swift type | What it is |
|---|---|---|
ContentBlock.version | Int | That instance's revision counter. Useful for logs, support, and telling two renders of the same block apart. |
SyncResult.etag | String? | The snapshot-level opaque ETag validator, in the W/"…" form, owned by the cache layer. |
They are unrelated. The block counter is never sent in If-None-Match, and the snapshot validator is never a revision number. The SDK keeps them apart for you, and both are surfaced so you never have to reconstruct either one. Weak ETags are stored and replayed verbatim, W/ and quotes included, and are never parsed.
Caching and offline behaviour
Stale-while-revalidate, by default, with a disk snapshot. The policy in full:
- The last good disk snapshot is published immediately at
start, before any network call. - The SDK revalidates using the stored ETag.
- On
304the cached body is kept and only the validation metadata is refreshed. - On
200the snapshot and its ETag are replaced atomically. - On network failure the stale snapshot keeps serving. A failed refresh never clears good content.
- A successful full sync is authoritative: blocks absent from it are removed. A network failure is never treated as evidence of deletion.
Details worth knowing when you are debugging:
- The cache namespace is a SHA-256 of the full API key, tenant, derived environment, and cache schema version. Locale is part of the filename. Live and test content therefore cannot mix.
If-None-Matchis sent only when the matching cached body actually exists. A304with a missing or corrupt body triggers one unconditional retry.- Writes are atomic. A process kill mid-write leaves either the old snapshot or the new one, never a half file. Bodies are stored verbatim with a checksum, so a model change can never force-decode an old archive.
- A corrupt or schema-incompatible cache deletes only the affected ContentFlow namespace, emits a diagnostic, and falls through to the network. It never crashes startup.
- Identity traits, consent bodies, push tokens, and auth headers are never written into the content cache.
Refresh schedule
Controlled by SyncPolicy, which you pass through ContentFlowConfiguration.
| Property | Default | Meaning |
|---|---|---|
syncOnStart | true | One sync at startup, after the disk snapshot is published. |
foregroundRevalidateInterval | 300 | Revalidate on foreground when the last attempt is older than this, in seconds. |
periodicInterval | 900 | Foreground poll, used only while live updates are unavailable. Zero disables it. |
liveUpdates | true | Open the shared stream while foregrounded. |
SyncPolicy.manual turns all of it off, which is what you want in tests or in an app that drives its own schedule. No promises are made about background refresh timing, because the OS owns that.
Reading on demand
CachePolicy selects how a single read blends cache and network: .staleWhileRevalidate (default), .networkOnly, .cacheOnly. RefreshPolicy does the same job for sync: .revalidate (default), .force, .cacheOnly. ContentFreshness tells you which one you got: .fresh, .revalidated, or .cached(Date). Timestamps are informational; server freshness is decided by ETag validation, never by the device clock.
fetchBlock(key:) returns nil on a 404 and the slot goes empty. That is a content decision, not a transport failure, so it does not throw and it does not surface an error screen.Live updates
- One stream connection per client. Adding slots never adds connections.
- Closed on background, reopened on foreground, followed by a revalidation because events may have been missed.
- A reopened stream resumes from the last event it saw by sending that id as a
?lastEventId=query parameter. The Kotlin SDK resumes with the standardLast-Event-IDrequest header instead, so a proxy rule, a log filter, or a WAF signature written for one platform does not cover the other. - Exponential backoff with full jitter, capped at 60 seconds, reset after a stable connection.
- Authentication and authorization failures are terminal for the stream: no amount of reconnecting fixes a rejected key, so the SDK stops and says so. Restart the client after fixing the configuration.
- A burst of invalidations collapses into a single sync.
- Observable state is only ever published on the main actor. Disk and JSON work never runs there.
Subscribe to everything the client publishes with updates(), an AsyncStream<ContentUpdate>. Each caller gets its own stream; they all share one sync and one connection.
for await update in await ContentFlow.shared.updates() { switch update { case .blocks(let blocks, let freshness): break case .strings(let catalog): break case .invalidated(let reason): break case .failed(let error): break } }
For a single placement, blockState(for:) returns a ContentBlockStore, a MainActor ObservableObject publishing a ContentBlockState. Repeated calls for the same placement return the same store, and store.refresh() forces a network read for it.
ContentBlockState | Meaning |
|---|---|
.loading(cached:) | Nothing published yet. cached is the last good disk snapshot, if any. |
.available(_, freshness:) | Content to render, with where it came from. |
.empty | The tenant publishes no block for this placement. Not an error. |
.failed(_, cached:) | A refresh failed. cached stays populated, so good content is never replaced by an error. |
state.block gives you the best block to render right now, fresh or stale, without matching on the case.
Localized strings
let strings = try await ContentFlow.shared.getStrings(locale: Locale(identifier: "ar")) label.text = strings.string("home.title", fallback: "Welcome")
LocalizedStrings exposes locale, values, a subscript(key:) returning String?, isEmpty, and smartKeys with isSensor(_:) for keys the dashboard has armed as Sensors (empty where the server does not report them). There is deliberately no interpolation in v1: an interpolation syntax needs documented escaping, missing-variable behaviour, and localization rules before it becomes permanent API.
Switch locale at runtime with setLocale(_:). It drops locale-specific in-memory state, republishes the disk snapshot for the new locale, and revalidates. The locale is normalized to a BCP 47 tag. That tag travels as Accept-Language on every request, reads and writes alike, and as ?locale= on the GET reads only: /sdk/sync, /sdk/blocks/:key, /sdk/strings, and the stream. A POST carries the header and no locale query parameter. The Kotlin SDK splits it the same way.
Identify
Linking a device to a user is an explicit network call. There is no setUserId and no hidden mutable identity.
let client = ContentFlow.shared let identity = try await client.identify( userId: "u_123", traits: ["plan": "premium", "signupDaysAgo": 12] ) print(identity.segments)
- Pass
nilforuserIdto keep the device anonymous. - Passing a non-empty
traitsdictionary replaces the device's stored trait set wholesale: the SDK assigns it directly, it does not merge with what is already stored, so send the full set you want kept on every call. An empty dictionary, or omittingtraitsentirely, is a no-op on the server: stored traits are left exactly as they are. There is no way to clear stored traits through this field; callDELETE /sdk/identifyto erase a device's data. Trait values areJSONValue, which is expressible by string, number, boolean, array, and dictionary literals. - Segment membership is resolved at identify time. Re-call it on login, logout, and whenever a trait you target on changes. Sync does not re-evaluate segments on its own.
IdentitycarriesdeviceId,userId,segments, andanalyticsConsent. The server's echo of consent is informational only: the SDK never treats it as a grant.
Push tokens
try await client.registerPushToken(deviceToken, platform: .apns)
There are two overloads: one takes the raw Data from didRegisterForRemoteNotificationsWithDeviceToken and hex-encodes it for you, the other takes an already-encoded String. PushPlatform is .apns, .fcm, or .webPush. An empty token throws .invalidConfiguration.
Consent
Everything is denied on a fresh install: analytics, push, sms, whatsapp, email, location, marketing. Read the current state with consentSnapshot().
// The only call that changes the analytics decision. try await client.setAnalyticsConsent(.granted) // Per-channel. Needs an identified user: channel consent is stored // on a person, not on an anonymous device. try await client.setChannelConsent(.granted, for: .push) let snapshot = await client.consentSnapshot() snapshot.analytics // .granted / .denied snapshot.hasExplicitAnalyticsDecision // has the app ever decided? snapshot.status(for: .sms) // .denied until granted
The three-state wire contract
This is the part worth reading twice, because it is the difference between a correct integration and a compliance incident.
consent on the wire | Server reads it as | SDK state |
|---|---|---|
| Field absent entirely | Leave the existing consent state unchanged | hasExplicitAnalyticsDecision == false |
true | An explicit grant | analytics == .granted |
false | An explicit revoke | analytics == .denied |
| Anything non-boolean | Rejected with 400 | Not sent by this SDK |
false. Sending a default false would let a mobile launch silently revoke a grant the same person made on web, which is a compliance problem and not merely a correctness one. Once a decision exists, the boolean is transmitted on every identify, in either direction, and it survives relaunch. A revoke is a real user decision, so it must reach the server explicitly rather than being expressed as an omission.identifyhas no consent parameter and cannot change consent in either direction.setAnalyticsConsentis the only call that does.- Granting a delivery channel never fabricates an analytics decision.
hasExplicitAnalyticsDecisionstaysfalse. - With analytics consent denied, events are neither sent nor queued. The call returns
.droppedNoConsentinstead of pretending to succeed. - Revoking purges the local event queue immediately. Nothing analytics-related outlives a revoke.
- Content sync keeps working with consent denied. That is deliberate: consent gates analytics, not content. A user who declined analytics still gets a working app.
ConsentChannel is .push, .sms, .whatsapp, .email, .location, .marketing. The location case is sent on the wire as locationTracking; the SDK handles that mapping.
/sdk/identify and the granular channels on /sdk/consent are two different concepts, and the exact meaning of the identify boolean (analytics, marketing, or an aggregate) is not yet documented server side. This SDK names it analyticsConsent on the assumption that analytics is the server meaning. If your compliance position depends on the distinction, confirm it with us before general availability rather than inferring it from this page.Analytics events
Every tracking call reports what actually happened, so a consent drop is visible at the call site instead of looking like a silent success.
switch try await client.trackEngagement(.tap(block)) { case .accepted: break // delivered on this call case .queued: break // offline, will retry case .droppedNoConsent: break // consent denied, never sent }
| Builder | Event kind |
|---|---|
EngagementEvent.impression(_:position:) | impression |
EngagementEvent.tap(_:position:) | tap |
EngagementEvent.ctaClick(_:position:) | cta_click |
EngagementEvent.dismiss(_:position:) | dismiss |
EngagementEvent.conversion(_:properties:) | conversion |
Each builder takes a ContentBlock and copies its instanceId, key, and screen onto the event, so you do not thread identifiers through your view code. The full initializer is available when you need to set blockType, position, custom properties, or a specific occurredAt. Event ids are client generated so an ambiguous retry can be deduplicated server side.
trackEngagement also takes an array. Queued events are batched, persisted across launches, capped, and aged out; a batch that fails is requeued once and held rather than dropped, so duplicate delivery is possible by design and deduplication happens on the id. flushEvents() sends whatever is queued now and returns whether the queue drained.
Identity events
trackSignUp(username:properties:) and trackSignIn(username:properties:) are thin wrappers over trackIdentityEvent(_:), sending exactly sign_up and sign_in. IdentityEvent also carries optional fullName, email, and phone, and supports .custom(String) for your own event names. These are analytics calls and are consent gated; username is required by the wire contract, and an empty one throws.
impression per block instance the first time it becomes visible. Set tracksImpressions: false or use .silent if you would rather own that yourself.API reference
ContentFlow, the entry point
| Member | Notes |
|---|---|
start(configuration:) -> ContentFlowClient | Full control. Discardable result. |
start(apiKey:tenantId:locale:) -> ContentFlowClient | Explicit tenant pin. locale defaults to .current. A mismatch with the key fails with 403 TENANT_KEY_MISMATCH. |
start(apiKey:locale:) -> ContentFlowClient | The marketing overload. Derives the tenant from the key; see tenant id. |
shared: ContentFlowClient | Before start, returns an unconfigured client and logs, rather than trapping. Previews and tests render empty instead of crashing. |
isStarted: Bool | true only when the stored configuration validates. |
registerRenderer(for:renderer:) | Placement is the exact block key. |
stop() async | Stops timers, closes the stream, flushes what it can, clears the shared client. |
ContentFlowClient, an actor
| Member | Notes |
|---|---|
init(configuration:) | For apps running more than one tenant at once. |
configuration, environment, deviceId | Read-only. environment and deviceId are nonisolated. |
start() async | Publishes the disk snapshot, syncs, attaches live updates. Calling twice is a no-op. |
stop() async | Stops timers and the stream, keeps the cache and identity so a later start resumes cheaply. |
close() async | Full shutdown. Flushes, stops, and finishes every updates() stream. |
identify(userId:traits:) async throws -> Identity | traits defaults to empty. A non-empty dictionary replaces the stored trait set wholesale; an empty or omitted one is a no-op. Discardable result. |
registerPushToken(_:platform:) async throws | Two overloads: Data and String. platform defaults to .apns. |
consentSnapshot() -> ConsentSnapshot | Persisted state. |
setAnalyticsConsent(_:) async throws | The only call that changes the analytics decision. |
setChannelConsent(_:for:) async throws | Throws .invalidConfiguration without an identified user. |
trackEngagement(_:) async throws -> TrackingDisposition | Single event and array overloads. |
trackIdentityEvent(_:) async throws -> TrackingDisposition | Consent gated. |
trackSignUp(username:properties:), trackSignIn(username:properties:) | Wrappers over the above. |
flushEvents() async -> Bool | true when the queue drained. |
setLocale(_:) async | Switches locale and revalidates. |
sync(policy:) async throws -> SyncResult | Concurrent calls collapse onto one request. Default .revalidate. |
currentBlocks() -> [ContentBlock] | What is held in memory right now. |
fetchBlock(key:policy:) async throws -> ContentBlock? | nil on 404. Default .staleWhileRevalidate. |
getStrings(locale:policy:) async throws -> LocalizedStrings | locale defaults to the configured one. |
updates() -> AsyncStream<ContentUpdate> | Broadcast. One stream per caller, one connection overall. |
blockState(for:) async -> ContentBlockStore | Same store for the same placement. |
Configuration
| Member | Default |
|---|---|
apiKey, tenantId | Required. |
locale | .current |
baseURL | ContentFlowConfiguration.defaultBaseURL |
syncPolicy | .default |
diagnostics | .errors |
requestTimeout | 15 seconds |
environment | Derived from the key suffix. Read-only. |
localeTag | The BCP 47 tag actually sent. Read-only. |
Types you will touch
| Type | Role |
|---|---|
ContentBlock | key, instanceId, version, segment, name, screen, fields. Identifiable on instanceId. |
[ContentBlock] | element(at:) -> ContentBlock? and card(instanceId:) -> ContentBlock?, for reading one card out of a collection. |
ContentField | id, tag, value. |
ContentFieldValue | The decoded value, plus fieldType, typeDescription, displayText. The .collection([ContentBlock]) case carries a collection's cards; displayText is nil for it. |
ContentImage | rawValue, url, alt, width, height. |
FieldKey<Value> | tag plus expectedType. |
ContentFieldContainer | The protocol behind field(_:), value(for:), require(_:), and collection(_:) -> [ContentBlock]. ContentBlock is the only conformer, at every nesting level. |
LocalizedStrings | One locale's approved catalog. |
SyncResult | blocks, freshness, etag, plus block(for:). |
ContentUpdate | .blocks, .strings, .invalidated, .failed. |
Identity | deviceId, userId, segments, analyticsConsent. |
ConsentSnapshot, ConsentStatus, ConsentChannel | See consent. |
EngagementEvent, IdentityEvent, TrackingDisposition | See analytics. |
JSONValue | Loss-tolerant JSON tree. Numbers are Decimal. |
ContentFlowSlot, ContentFlowSlotModifier, ContentFlowRendererRegistry, GenericBlockView | The SwiftUI surface. |
ContentFlowCardView, ContentFlowCollection | Render one card, or every card under a collection tag, through the renderer registered for each card's own block key. See collections. |
ContentBlockStore, ContentBlockState | Per-placement observation. |
SyncPolicy, RefreshPolicy, CachePolicy, ContentFreshness, ContentFlowEnvironment | Behaviour knobs. |
ContentFlowSDK | version and name, used in the user agent. |
ContentFlowRendererRegistry also exposes renderer(for:), unregister(placement:), removeAll(), and registeredPlacements, which are useful in tests and in a debug screen that lists what your app has wired up.
Errors
ContentFlowError is the whole taxonomy. It conforms to LocalizedError, so errorDescription is safe to log.
| Case | When |
|---|---|
.invalidConfiguration(String) | Missing or malformed key or base URL, or a key the SDK cannot parse into a workspace. Not thrown for an absent tenant id: the workspace comes from the key. |
.authenticationFailed(statusCode:message:) | 401. The publishable key was not accepted. |
.authorizationFailed(statusCode:message:) | 403. Valid key, not permitted to read this resource. |
.notFound(String) | 404. |
.rateLimited(retryAfter:) | 429. Mirrors Retry-After when present. |
.serverError(statusCode:message:) | 5xx. |
.transport(String) | No HTTP response at all. |
.timedOut | No response inside requestTimeout. |
.decoding(String) | The body was not decodable into the expected model. |
.invalidEnvelope(String) | JSON, but not a recognized ContentFlow envelope. |
.cache(String) | Reading or writing the disk snapshot failed. |
.cancelled | The enclosing task was cancelled. |
.consentDenied | Part of the taxonomy, but not thrown by v1. A consent drop is reported as TrackingDisposition.droppedNoConsent instead of an error, so do not branch on this case. |
.unsupportedFieldValue(tag:expected:actual:) | The field exists but carries a different type. |
.fieldMissing(tag:) | A strict accessor asked for a field the block does not carry. |
Retries are bounded and deliberate: reads retry transport failures, timeouts, rate limits, and 5xx; writes are never replayed on a status failure, because the write may already have landed. Authentication and authorization failures end the live-update stream rather than reconnecting forever.
Diagnostics
Schema problems are reported here rather than thrown, so a content mistake never turns into missing UI with no explanation. Wire this into your logger on day one; it is the cheapest debugging you will get.
ContentFlowDiagnostics.level = .verbose ContentFlowDiagnostics.setHandler { diagnostic in logger.log("[\(diagnostic.category)] \(diagnostic.message)") }
DiagnosticsLevel is .none, .errors (the default), .warnings, .verbose. Pass nil to setHandler to restore the default print sink. Each ContentFlowDiagnostic carries level, category, message, and timestamp.
Diagnostics are emitted for: an unparseable key, a tenant mismatch, unknown field types, duplicate tags, wrong-typed accessors, double-wrapped envelopes, cache corruption, unregistered placements, event drops, and stream reconnects.
What is in v1, and what is not
The JavaScript SDK has a much larger surface than this one. Rather than stub the difference, v1 leaves it out, so you can tell at a glance whether the thing you need exists.
Included
start, stop, close, identify, registerPushToken, setAnalyticsConsent, setChannelConsent, consentSnapshot, trackEngagement (single and batch), trackIdentityEvent, trackSignUp, trackSignIn, flushEvents, setLocale, sync, fetchBlock, getStrings, currentBlocks, updates, blockState, ContentFlowSlot, and renderer registration.
Folded into a different shape
| JavaScript SDK | Swift |
|---|---|
setUserId | identify(userId:). Identity is an explicit network operation, not hidden mutable state. Pass nil for anonymous. |
updateTraits | identify(traits:). A non-empty dictionary replaces the stored trait set wholesale, it does not merge; an empty dictionary, or an omitted traits, is a no-op that leaves stored traits untouched. |
trackSignUp / trackSignIn | Convenience wrappers over trackIdentityEvent. |
parseBlock / parseBlocks | Internal. You receive decoded models. |
Deferred until a production wire contract exists
updatePushTopics, getPushTopics, updatePushPreferences, getConsentConfig, requestConsent. These are omitted rather than stubbed, because a local-only implementation would make an app believe server push delivery changed when it did not.
Deliberately not in the core SDK
| Not included | Why |
|---|---|
requestLocation, stopLocationTracking | Application responsibility, and no verified endpoint. |
getCampaigns | No verified endpoint. Blocks are the v1 content abstraction. |
submitKYC, getKYCState, updateIndustryProfile, getIndustryProfile | Domain features, not content delivery. |
pickVariant | Targeting is server authoritative. |
stripCssUrl | Web only. |
interpolate | Needs a documented syntax, escaping rules, and localization interaction before it becomes permanent API. |
No code generation. Typed field keys give enough v1 safety without a schema download, a build plugin, or a CI story. If generation arrives later it should emit key constants, typed field keys, and schema version metadata, never a network client.
Moving off a hand-written REST client
If you wrote your own client against the REST reference, you already have the hard parts right and the migration is mostly deletion. Here is what the SDK takes over.
| What you built | What replaces it |
|---|---|
| Three headers on every request | Attached inside the SDK, on every path including the stream. Set once in ContentFlowConfiguration. |
A UUID in the Keychain for X-CF-Device | ContentFlowClient.deviceId. Keychain first, UserDefaults fallback. Read it if you want to log it. |
Envelope unwrapping, and possibly a data.data workaround | Handled. One level is expected; a double wrap is unwrapped once with a warning, and arbitrary nesting is rejected. |
ETag storage and If-None-Match | Handled, verbatim, per category and locale, with atomic writes and a 304-with-no-body recovery path. |
Joining values against the fields schema | Done at decode time. You get [ContentField] and typed accessors. |
A switch on a stringly-typed field type | FieldKey<Value> with strict and lenient reads. |
| Polling on a timer | A shared stream with backoff, plus a foreground poll only when the stream is unavailable. |
| Your own event batching and retry | A persisted, capped, consent-aware queue with a reported disposition per call. |
Things to check as you cut over:
- Your placement strings. They are block keys, case sensitive, with no fuzzy matching. If your old client normalized them, that behaviour disappears.
- Your consent code path. If it sent
consent: falseby default, stop. Send nothing until the user decides, and read the three-state contract. - Your device id. The SDK generates its own. Migrating an existing id is not supported by the public API, so a switch-over means the device looks new: fresh consent state, fresh segments until the next
identify. Plan that, do not discover it. - Your base URL constant. Delete it and let the default stand.
- Your renderers. This is new work: the SDK will not draw anything until a placement is registered.
The wire details behind all of this are unchanged and stay documented on the REST API reference. Nothing on this page contradicts that page; the SDK is a client for exactly that contract.