Release notes
What changed in each ContentFlow SDK release, what you need to do when you upgrade, and how to upgrade. Downloads and checksums are on Releases and downloads; each SDK's full changelog is linked from there.
Upgrading to a new version
The upgrade steps for every platform (swap the archive, rebuild, check the version, roll back) are in Install and upgrade the SDK. Before you upgrade, read the Action needed notes below for every version you skip. For 2.2.1 those are the certificate pins and the legacy base URL; for 2.2.2, Kotlin's deprecated no-op options and Flutter's identify before setChannelConsent; for 2.3.0, the stop on a rejected key, the license, Swift's new ContentFlowError.invalidKey case, Flutter's deleteIdentity throwing on a partial erasure, and the location requirements of on-device geofencing; for 2.4.0, the new X-CF-Session-Id header behind a JavaScript proxy, the new environment value in Swift and Kotlin, Kotlin's new ContentFlowClient members, and the explicit location grant for geofencing in Kotlin and Flutter.
What's new in 2.4.1
Action needed:
- All SDKs: block popups now draw by default.
presentPopupandscreenChangedwithautoPresentdraw a block popup that uses the standard fields (a non-empty#titleor#body, such as the Standard popup a campaign's Popup channel delivers) in the built-in card, where 2.4.0 returnednot_handled. An app that setsonBlockPopup(orrenderBlockon the React and React Native host) sees no change. To keep the 2.4.0 behaviour, turnblockPopupsoff:popups.blockPopups: 'off'(JavaScript),client.popups.options.blockPopups = .off(Swift),PopupConfig(blockPopups = BlockPopupMode.Off)(Kotlin),CFPopupOptions(blockPopups: CFBlockPopupMode.off)(Flutter). - JavaScript: a custom
CFPopupPresenternow also receives blocks. Checkview.kindbefore readingview.campaign. - Kotlin and Swift: a block card's link goes to the new
onBlockAction, notonAction(which stays typed to campaigns). If you setonActionto route your own links, setonBlockActiontoo: without it a block link with an app custom scheme is not opened (http and https still are). - Flutter:
popups.onActioncan receive a block. For a block card its first argument is aCFBlockActionContext(item.block). - Swift: a custom SwiftUI host observes
popups.currentPopup(campaign or block);popups.currentstill holds campaigns only.
Added (all SDKs): block popups in the built-in card: fields #title, #body, #image (https only), #cta_label, #cta_url and #cta_visible (false, or the text "false", hides the button), with the same theme, layout, right-to-left, accessibility, delay and link rules as campaigns, and tracked once per presentation through the block events.
Fixed: a closed in-app message no longer comes back on every sync when analytics consent is not granted: the SDK remembers, on the device, each message the user closed or tapped (8 days, at most 200, cleared on logout, a user change, deleteIdentity() and resetFrequency()). Flutter: every pending in-app message is kept, not one per key.
Changed: JavaScript: the package's sourcemaps no longer embed the TypeScript sources; the package is about half the size.
What's new in 2.4.0
Action needed:
- JavaScript behind your own proxy: every
/sync,/blocks/{key}and/campaignsread now sendsX-CF-Session-Id. If the proxy has a CORS allowlist, allow it. The ContentFlow gateway already does. - Swift and Kotlin:
ContentFlowEnvironmenthas a new value,.unknown/Unknown, which a created publishable key (<workspaceId>_cfpk...) now reports onclient.environmentinstead of live. Aswitchorwhenover it without a default no longer compiles. - Kotlin:
ContentFlowClienthas new members without a default (trackCampaign,frequencyReason,getPopup,resetFrequency); code that implements the interface itself, such as a test fake, must add them. - Kotlin and Flutter: on-device geofencing needs an explicit location grant (
setChannelConsent(ConsentChannel.Location, ConsentStatus.Granted)in Kotlin;setChannelConsent(userId, CFChannelConsent(locationTracking: true))orrequestConsent(location: true)in Flutter). Location consent that was never asked no longer counts as granted, as in JavaScript and Swift.
Added (all SDKs):
- Popup frequency control: a local ledger with the same rule as the server, so caps also hold before the event batch is sent, offline, from cache and within a session, and without analytics consent (nothing leaves the device).
shouldShow(item),frequencyReason(item),getPopup(screen)(at most one popup block or in-app campaign for a screen, after its rule and the workspace popup limits;useCFPopup(screen)in React and React Native) andresetFrequency(). Windows use the workspace time zone (Asia/Riyadh by default). Thefrequency_capsworkspace switch is now honoured on the device. - SDK-drawn campaign popup:
presentPopup(screen),screenChanged(screen)andclosePopup(), with a built-in card (web shadow-root dialog, React and React NativeCFPopupHost, Swift.contentFlowPopups, Kotlin ComposeContentFlowPopupHost, FlutterContentFlowPopupHost/ContentFlowPopupObserver).autoPresentis off by default, so upgrading shows no new UI;delayMsis 0 to 60000, default 0. The SDK tracks the impression and exactly one ofcta_clickordismiss. Links withjavascript:,data:,file:,vbscript:,blob:orabout:are never opened (Kotlin also refusesintent:andcontent:); images load over https only; Flutter opens nothing without youronAction. - Core workspace switches:
content_sync,strings,analytics_events,push_preferences,geofencesandprofile_updates(contentSync,strings,analyticsEvents,pushPreferences,geofences,profileUpdateson the features object). ContentFlow can turn these off for one workspace or for every workspace during an incident; the server answers with a response the SDK already treats as success. Withanalytics_eventsoff, JavaScript and Kotlin also drop events on the device (dropReasons.feature_disabled/TrackingDisposition.Rejected). - Campaign and block tracking helpers:
trackCampaignDismissandtrackCampaignCtaClick/trackCampaignClick, blocktrackCtaClickandtrackDismisswhere they were missing. Kotlin gainstrackCampaign(campaign, EngagementType)and FluttertrackCampaignImpression,trackCampaignTap,trackCampaignConversion: before 2.4.0 those two SDKs recorded no campaign events.
Fixed: one session id per session in every SDK, sent as X-CF-Session-Id on content reads and as context.sessionId on event batches; it rotates on a cold start, after 30 minutes or more in the background, and on logout(), deleteIdentity() and resetFrequency(), and events keep the session they were queued in. Swift sent no session id with event batches before. Swift keeps events in order after a 429: event flushes run one at a time, so a rate-limited batch is no longer requeued behind newer events. Swift and Kotlin no longer treat a created _cfpk test key as live. Swift, Kotlin and Flutter: getConsentConfig(preset) returns the whole preset (topics, legal framework and the rest), not only the id and required. Kotlin saves a channel grant only once the server confirms it, and keeps a revoke made before identify() until it can be sent.
Changed: every SDK now declares X-CF-SDK-Capabilities: erasure-partial-v1,frequency-v1. It is informational; server-side caps also apply to SDKs before 2.4.0. JavaScript: ScheduleMode is now 'always' | 'payday' | 'ramadan' | 'custom', and BlockSchedule gains tz and precision (type-only).
What's new in 2.3.0
Action needed:
- All SDKs: a rejected key stops the SDK for that key. An HTTP 401, or a 403 with
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH, stops polling, the live stream and location, drops every queue, and makes later calls fail fast with a typed error (CFInvalidKeyError,ContentFlowError.invalidKey,ContentFlowError.InvalidKey,CFInvalidKeyException). Other 403s, such asDEVICE_NOT_IDENTIFIED, don't. The state is in memory only: create a new client with a corrected key, or restart the app, to resume. - All SDKs: license. 2.3.0 is the first release under the proprietary ContentFlow SDK License (
LICENSEin each package). Earlier releases keep their terms. - Swift:
ContentFlowErrorhas a new case,.invalidKey(statusCode:code:droppedCount:). Aswitchover it without adefaultno longer compiles. - Kotlin: to use on-device geofencing, declare
ACCESS_BACKGROUND_LOCATIONin your app's manifest, request it on Android 10+ and complete Play's background location declaration. The:geofencingmodule declares only fine and coarse location. A 403TENANT_KEY_MISMATCHis nowContentFlowError.InvalidKey(anAuthenticationFailed), no longerAuthorizationFailed. - Flutter:
deleteIdentity()throwsCFRequestExceptionon a partial erasure and keeps the device id and local state; catch it and calldeleteIdentity()again to finish. It used to wipe them as if the erasure had completed. - JavaScript: on-device geofencing needs an explicit location grant,
setChannelConsent({ locationTracking: true }); unset location consent keeps it idle.
Fixed (all SDKs): retries cover only transport failures, 429 and 5xx, with exponential backoff and jitter (1 s up to 5 minutes), and Retry-After (seconds or an HTTP date, capped at 1 hour) is the minimum wait; other 4xx responses are dropped. Every retry and offline queue holds at most 500 items and drops the oldest, reported at most once a minute. Location is sent at most 100 points per request, oldest first, so a backlog is no longer refused whole. Swift also handles location fixes in order, no longer re-sends delivered location batches, drops refused ones, and keeps an app-set diagnostics level. Flutter reconnects live updates after a drop.
Added (all SDKs):
- On-device geofences: the workspace's nearest fences are registered with the OS (iOS region monitoring, Android
GeofencingClient, Expoexpo-location, aCFGeofenceMonitorin Flutter) and crossings are reported withsource: "device". Opt-in; web is not supported. - Workspace feature switches:
live_updates,campaignsandlocationfromconfig.featuresare honoured on the device. disableStrings(keys, writeKey): stop tracking localization keys from a server or CI job (write key only).- JavaScript: optional
nameandemailonidentify()andCFConfig, andrevokeAnalyticsLocally().
Changed: every SDK now declares X-CF-SDK-Capabilities: erasure-partial-v1. deleteIdentity() keeps the device id on a partial erasure in every SDK, so the gateway now answers it with 207 and the remaining parts instead of 502. No SDK declares sponsored-surface-v1 unless a JS app opts in with sponsored: true. Swift: CFSecureStorage no longer falls back to an XOR-obfuscated copy in UserDefaults.
Deprecated: getIndustryProfile() in every SDK (returns null without a request); Swift: CFRequestSigner and CFSecureStorage.
What's new in 2.2.2
Action needed:
- Kotlin: remove
enableRequestSigning/signingSecretKey,offlineandenableSecureStoragefrom your configuration. They are deprecated no-ops. - Flutter: call
identifybeforesetChannelConsent; without it the call throws aStateErrorand sends nothing. - JavaScript behind your own proxy: every request now also sends
X-CF-SDK-Capabilities. If the proxy has a CORS allowlist, allow it.
Fixed: Flutter sync() returns blocks from the live gateway, and Flutter setChannelConsent is accepted. The JS root entry exports consentPathsToConfig and the error classes, and @contentflow/sdk/embed reads its data-cf-* attributes. Kotlin's ContentFlowDiagnostics.setLevel(...) is no longer overwritten by start().
Added: every SDK sends X-CF-SDK-Version: <platform>/<version> (js, react-native, swift, kotlin or flutter, e.g. kotlin/2.2.2) and X-CF-SDK-Capabilities on every request. Capabilities are empty unless a JS app opts in to sponsored content with sponsored: true.
Deprecated (Kotlin): request signing, the offline event queue and secure storage; they never had an effect.
What's new in 2.2.1
Action needed:
- Pinning: if you enable certificate pinning and don't set
baseUrl, the default host changed fromapp.contentflow.clicktoapi.contentflow.click, which has a different key. Add the api-host pins (see Certificate Pinning) or setbaseUrlexplicitly before upgrading. - Legacy bases: a base that doesn't end in
/sdk/v1now logs a one-time "legacy path" notice. Update it tohttps://api.contentflow.click/sdk/v1.
Changed: all four SDKs default to https://api.contentflow.click/sdk/v1. Only apps that don't set baseUrl are affected, and the old hosts keep working.
Added: the base URL can be configured without a code change (Info.plist, AndroidManifest meta-data, a JVM system property or environment variable, --dart-define, or a page global on the web). See Configuring the base URL.
What's new in 2.2.0
All four SDKs now share the same core capabilities — keys, consent, content, events, push and location — with platform-appropriate APIs. This is not parity of every capability: certificate pinning and request signing are not available in JavaScript, and location support differs by platform. See Platform support for the current exceptions, and each SDK's CHANGELOG.md for the full list.
All four SDKs
deleteIdentity(): GDPR/PDPL erasure of this installation (DELETE /identify). Location collection stops and unsent events and points are discarded before the request. Local identity, cache and consent are cleared only after the server confirms.registerStrings(...)with a write key: registers translation keys and source text (POST /api/v1/strings/sync). Server/CI only; never ship a write key in an app.- Device location is gated on analytics consent. Revoking consent stops collection and discards unsent points. On logout or a user switch, points collected for the outgoing user are sent as that user or dropped, never attributed to the next user.
- Every call reaches a real gateway route.
JS
- Device location works:
navigator.geolocationon the web. React Native apps pass a geolocation module (e.g.@react-native-community/geolocation) asgeolocation. enableSecureStoragenow actually obfuscates analytics consent, channel consent and segments at rest. This is obfuscation, not encryption. Existing values migrate on first read, and the device ID never changes.
Swift
getLocales().- Device location and geofences live in a separate opt-in product,
ContentFlowLocation(await client.enableDeviceLocation()), so apps that don't use location never link CoreLocation's permission APIs. Approximate-only permission is reported.
Kotlin
client.extensionsmakes these reachable: locales, string sensors, push topics and preferences, consent presets and full consent, IP geolocation, location batches, and block/string registration.- New:
logout(),unregisterPushToken()andgetCampaigns(). - Logging out or switching users clears the content cache, so the previous user's targeted blocks are never shown to the next.
- Device location and geofences live in a separate opt-in module,
:location(ContentFlow.client.enableDeviceLocation(context)). It uses Android'sLocationManagerwith no Google Play Services, and its manifest is the only place location permissions are declared. The app requests the permission, never the SDK. locationPermission()reports precise, approximate, denied or unavailable.LocationProviding/setLocationProvider(...)accept custom sources (e.g. Play Services).
Flutter
- New:
logout(),unregisterPush(),getLocales(),trackSignUp()/trackSignIn()and alocalegetter. - Logging out clears cached blocks.
Upgrading from 2.1.0
Breaking / action needed
- Kotlin:
ContentFlowClienthas new members, so custom implementations (e.g. test fakes) must add them.TrackingDispositionhas a newRejected(reason)case, so exhaustivewhenexpressions need a branch.
- Swift: device location is in the separate
ContentFlowLocationproduct.updateIndustryProfilerequires a signed-in user. - JS:
- Device location requires analytics consent.
- React Native needs a geolocation module.
- A confirmed
deleteIdentity()disposes the client.
- Flutter:
- Sign-up/sign-in failures now throw.
- String-sensor calls send only for keys marked as sensors.
- Location isn't sent without analytics consent.
Broken in 2.1.0, fixed in 2.2.0. If you worked around any of these, remove the workaround:
- Flutter:
- Live updates never emitted.
getStringsreturned the response envelope.setLocaleignored its argument.- Campaigns used the wrong HTTP method.
- Sign-up/sign-in events were rejected.
- String sensors and location posted to routes that don't exist.
- Kotlin: sign-up/sign-in events were rejected by the gateway but reported as queued. Several extension APIs were unreachable.
- JS:
requestLocation()always timed out.- Location batches and geofence events were sent in a shape the gateway ignores.
updateIndustryProfilecalled a route that doesn't exist.
- Swift: location batches and geofence events posted to routes that don't exist.
All versions, in brief
v2.4.1 (2026-10-11)
- Block popups that use the standard fields (such as the Standard popup) drawn in the built-in card, with
blockPopupsto turn it off;onBlockAction(Kotlin, Swift); a closed in-app message stays hidden without analytics consent.
v2.4.0 (2026-10-10)
- Popup frequency control on the device (
getPopup,shouldShow,frequencyReason,resetFrequency); SDK-drawn campaign popup (presentPopup,screenChanged,closePopup); one session id per session inX-CF-Session-Id; campaign tracking in Kotlin and Flutter; core workspace switches; every SDK declaresfrequency-v1. See What's new in 2.4.0.
v2.3.0 (2026-10-03)
- A rejected key stops the SDK; bounded queues, backoff and
Retry-After; on-device geofences; workspace feature switches;disableStrings; every SDK declareserasure-partial-v1; proprietary SDK License. See What's new in 2.3.0.
v2.2.2 (2026-10-01)
- Every SDK sends
X-CF-SDK-VersionandX-CF-SDK-Capabilities; Flutter blocks and channel consent work against the live gateway; Kotlin deprecates its no-op options. See What's new in 2.2.2.
v2.2.1 (2026-09-30)
- Default base URL is
https://api.contentflow.click/sdk/v1in all four SDKs; the base URL can be configured without code; legacy bases log a one-time notice. See What's new in 2.2.1.
v2.2.0 (2026-09-26)
- All four SDKs share the same core capabilities, with platform exceptions listed in Platform support. See What's new in 2.2.0 and Upgrading from 2.1.0.
v2.1.0 (2026-09-25)
- Breaking (Flutter): requires Dart 3.5+ and Flutter 3.16+, with
connectivity_plus^7 andgeolocator^14. With pinning on, a bad pin list throws at init. - Breaking (Kotlin):
ContentFlow.offlineQueueandContentFlow.secureStorageare internal.CFCertificatePinneris no longer public.CommerceEvent.bookingdefaults toCompleted(same wire value). - Fixed: the Kotlin and Flutter SDKs compile and are tested in CI. Their 2.0.1 archives did not compile.
- Fixed (Kotlin): block and string registration use the real write-key routes, and strings are no longer saved with empty text.
- Security: certificate pinning reworked in Flutter, Kotlin and Swift. It is still off by default and uses
sha256/<base64>public-key pins that survive renewal. At least 2 pins are required, the normal chain and hostname validation always applies, and the pin is checked before any request is sent. Only thebaseUrlhost is pinned. See Certificate Pinning. - JS: no API changes. The version moves to 2.1.0 to stay aligned with the other SDKs.
v2.0.1 (2026-09-24)
- JS:
- Unload flush headers are read at flush time, which fixes a stale device ID.
- The 64 KB keepalive limit counts UTF-8 bytes rather than characters.
- Stored consent no longer overrides consent set explicitly in config.
setChannelConsentsends only the channels that changed.
- Swift:
- The cache is cleared when the same user's traits change.
- Uncertain events are purged when the identity switches.
- The reported version is fixed.
v2.0.0 (2026-09-23)
- Universal event categories (
commerce,engagement,identity,growth,account,system,location, plus built-in content and consent), incategory:actionformat. - IP geolocation, block registration (cards as code), string sensors and a location endpoint.
- Offline event queue, rate limiting, segment and consent persistence, and a keepalive flush on page unload.
- The 1.x industry helpers were removed; see the migration appendix.