Changelog
Released archives are immutable. Public Swift API breaks require a new major version. REST v1 contract deprecations preserve existing valid shapes until an announced migration or major contract, with migration guidance and a retirement date published here before enforcement. No retirement date is currently announced.
2.4.1 — 2026-10-11
Action needed
presentPopup/autoPresentmay now show a block popup it used to skip. Withoutpopups.options.onBlockPopup, a block popup that uses the standard fields is now drawn in the built-in card instead of answering.notHandled(see Added). To keep the 2.4.0 behavior, setclient.popups.options.blockPopups = .off. Apps that setonBlockPopupsee no change.- Block deep links go through
onBlockAction.onActionstays typed toCampaignand is not called for a block card. If you setonActionto route your own links, also setpopups.options.onBlockAction: without it, a block link with an app custom scheme is not opened (http and https still are). - A custom SwiftUI host observes
popups.currentPopup.popups.currentstill holds campaigns only, so a host that draws from it never shows a block card. ObservecurrentPopup(aCFCampaignPopupContextorCFBlockPopupContext) instead.
Fixed
- A closed in-app message no longer comes back without analytics consent. In-app messages (instance ids starting
jm_, delivered underjourney_messageor the placement you chose) are removed on the server only when thedismiss,cta_clickortapevent reaches it, and that event is sent only with analytics consent, so without consent a closed message returned on every sync until it expired. The SDK now remembers, on the device, each message the user closed or tapped (tracked throughtrackDismiss,trackCtaClick,trackEngagement,trackortrackDetailed) and leaves it out ofsync()results,currentBlocks(),fetchBlock(key:),updates(),blockState(for:)/ContentFlowSlot(which moves to the next pending message) andgetPopup(screen:), with or without consent. Only instance ids and times are kept, inUserDefaults, for 8 days and at most 200; nothing is sent. The list is cleared bylogout(), a change of user,deleteIdentity()andresetFrequency(). The event itself is still sent only with analytics consent. No API change.
Added
- The built-in popup card also draws block popups. When
getPopup(screen:)offers a block popup, the app has noonBlockPopuphandler, and the block uses the standard fields (a non-empty#titleor#body; also#image,#cta_label,#cta_url, and#cta_visiblefalse or"false"to hide the button),presentPopupandautoPresentshow it in the same card as campaigns: same theme, layout, RTL, accessibility, delay, one popup at a time (across campaigns and blocks), link scheme filter and https-only image. Missing image or CTA fields leave them out. The result isCFPopupPresentation(shown: true, kind: .block, id: <instanceId>). A block without the standard fields still answers.notHandled, andonBlockPopup, when set, always wins.- Tracked once per presentation through the block events (
trackEngagement(.impression(block)), then exactly one oftrackCtaClick(block)ortrackDismiss(block)), so the frequency ledger, server counts, campaign statistics and the on-device dismissed-message list all apply. CFPopupOptions.blockPopups(CFBlockPopupMode:.builtIn, the default, or.off) andCFPopupOptions.onBlockAction: (ContentBlock, URL?) -> Bool, the block counterpart ofonAction(which stays typed toCampaignand is not called for blocks). The popup closes before either handler runs; when the handler returnsfalse, the SDK opens an allowed URL. WithoutonBlockActionthe SDK opens the block link itself, except that whenonActionis set (the app routes its own links) a block link with an app custom scheme is not opened (http and https still are): setonBlockActionto route block deep links.cta_clickis tracked either way. Both newinitparameters have defaults, so existing calls keep compiling.- New
CFPopupContextbase class withCFCampaignPopupContext(unchanged API) andCFBlockPopupContext(block).popups.currentPopupis the campaign or block a SwiftUI host is drawing;popups.currentstill holds campaigns only.renderCampaignPopupdoes not apply to blocks: they always use the built-in card (CFCampaignPopupView(context:)also accepts aCFBlockPopupContext).
- Tracked once per presentation through the block events (
2.4.0 — 2026-10-10
Action needed
ContentFlowEnvironmenthas a new case,.unknown, the environment of a created publishable key (<workspaceId>_cfpk...). The enum is public and not frozen against new cases, so aswitchover it without adefaultno longer compiles: add the case, or adefault(see Fixed).
Fixed
- Events stay in order after a 429. Event flushes now run one at a time. Before, a second flush (a
trackEngagement, a lifecycle flush or the backoff re-flush) could send newer events while a rate-limited batch was still on the wire, and that batch was then requeued behind them. A requeued batch now goes out before anything queued after it; the 429 backoff is unchanged. - One session id per session, not per event batch. The SDK now keeps a single session id, tied to the popup frequency ledger's session: it starts at cold start and rotates when the ledger starts a new session (back from the background after 30 minutes or more, and on
resetFrequency()/logout/erasure, which reset the ledger). It is sent as theX-CF-Session-Idheader on/sdk/sync,/sdk/blocks/{key}and/sdk/campaigns, and ascontext.sessionIdon every/sdk/eventsbatch (batches did not carry one before; single industry events used a fresh UUID each). EachEngagementEventis stamped with its session when accepted (EngagementEvent.sessionId), and a batch never spans two sessions, so an event queued before a rotation keeps its own session id. The id is a lower-case UUID, memory only (a cold start is a new session) and carries no PII. - A created test key (
<workspaceId>_cfpk...) is no longer treated as live. The environment of a created publishable key is stored on the server and is not in the key text, but the SDK read it from the suffix, so a test key reported.live.ContentFlowEnvironmentgains.unknown, which is what a_cfpkkey (workspace id +_cfpk+ 40 hex) now reports onclient.environment; nothing on the device assumes live for it, and its cache namespace is separate from live and test.<workspaceId>_testandpk_test_...are still.test;<workspaceId>_appandpk_live_...are still.live. The server does not report the environment yet, so a created key stays.unknown. If youswitchoverContentFlowEnvironmentwithout adefault, add the.unknowncase (see Action needed). getConsentConfig(preset:)keeps the whole preset (#1082). It returned only the id andrequired, dropping what a consent screen needs.ConsentPresetnow also hasoptional,industry,region,defaultTopics,availableTopics(ConsentPreset.Topic:id,name,nameAr,required) andlegalFramework, read from the route's real shape, pluspresetas an alias ofid. Existing initializers and stored values still work (new lists default to empty when decoding older JSON); the nestedchannels/marketing/locationare documented as legacy.
Added
- Core workspace switches on
SDKFeatures:contentSync,strings,analyticsEvents,pushPreferences,geofences,profileUpdates(wire keyscontent_sync,strings,analytics_events,push_preferences,geofences,profile_updates). ContentFlow can now 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 the SDK only reports them. The newinitparameters come after the existing ones and default totrue, so existing calls keep compiling. - SDK-drawn campaign popup.
client.presentPopup(screen:locale:)shows the in-app campaign thatgetPopup(screen:)offers in a built-in SwiftUI card (image, title, scrollable body, CTA, close button) over a dimmed backdrop: in a SwiftUI host (.contentFlowPopups(client:screen:locale:)) or, on iOS/tvOS, presented over the top view controller (.overFullScreen). A block popup goes tooptions.onBlockPopup(blocks stay app-drawn) or answers.notHandled. ReturnsCFPopupPresentation(shown,kind,id,reason:.nothingToShow,.alreadyShowing,.notHandled,.featuresOff,.noUIContext). One popup at a time.client.screenChanged(_:locale:)presents automatically afterdelayMs(0...60000) only whenoptions.autoPresentis on (off by default); a newer screen cancels a pending presentation.client.popups.options(CFPopupOptions):autoPresent,delayMs,theme(CFPopupTheme, light/dark defaults),renderCampaignPopup,onBlockPopup,onAction(returntruewhen the app handled the URL; otherwise the SDK opens http, https and app schemes, neverjavascript:,data:,file:,vbscript:,blob:orabout:; a blocked URL never reachesonAction, and the click is still tracked). The campaign image loads over https only.client.closePopup()takes the popup down with no dismiss event and frees the one-at-a-time slot;client.popups.dismissCurrent()closes it as a dismissal.- Tracked automatically, once per presentation: the impression, then exactly one of
cta_clickordismiss, through the existing consent-gated path; both feed the frequency ledger (stopOn). - RTL from the locale (ar, he/iw, fa, ur, ps, sd, yi, ckb), Dynamic Type, VoiceOver modal semantics with focus on the title, localized close label, Reduce Motion, safe areas.
- Tracking helpers matching the TypeScript SDK:
trackCampaignImpression(_:),trackCampaignClick(_:)(cta_click),trackCampaignDismiss(_:), and for blockstrackCtaClick(_:)andtrackDismiss(_:).trackCampaign(_:type:)now also accepts.ctaClick. - Popup frequency control (#1049). The server already withholds a block or in-app campaign that is capped for the user or device; the SDK now keeps a local ledger with the same rule, so caps also hold before the event batch is sent, offline, from cache and within a session. Existing integrations get it without new calls:
trackEngagement(impression, tap, cta_click, dismiss, conversion, also throughtrack/trackDetailed) andtrackCampaignupdate the ledger. Local counting works without analytics consent (nothing is sent); server-side caps need analytics consent.client.shouldShow(_:)andclient.frequencyReason(_:)for aContentBlockorCampaign(CFSuppressionReason:cappedImpressions,cappedClicks,cappedSession,cooldown,stoppedOnCtaClick/stoppedOnDismiss/stoppedOnConversion,workspaceGuard).client.getPopup(screen:locale:)→CFPopup?: at most one interruptive block or in-app campaign for the screen (campaigns for placement = screen), highest priority first, after its rule and the workspace popup limits (config.frequency.fatigue: per session, per day, minimum gap).trackCampaign(_:type:)accepts.dismiss, for "until the customer closes it" campaigns.client.resetFrequency()(meant for debug builds and QA) forgets the local counts so a "once ever" popup can be tested again.- Windows use the workspace time zone from
config.frequency.timezone(Asia/Riyadh by default): "once per day" resets at local midnight, weeks start on Sunday. A sign-in keeps what was counted anonymously on the device; the ledger is cleared bylogout()anddeleteIdentity(). A session is the client's lifetime, and 30 minutes or more in the background starts a new one. Counters and timestamps only, inUserDefaultsnext to the other per-device state. ContentBlock.frequencyandContentBlock.priority,Campaign.frequency(fromfrequency, elserules.frequencyCappingon older servers),SDKFeatures.frequencyCaps(thefrequency_capsworkspace switch: off means the SDK ignores its ledger), new typesCFPopup,CFFrequencyRule,CFFrequencyConfig,CFFrequencyPeriod,CFFrequencyStopEvent,CFSuppressionReason. All new initializer parameters have defaults, and missing or malformed rules decode asnil.
Changed
- Every request now also declares
frequency-v1inX-CF-SDK-Capabilities(erasure-partial-v1,frequency-v1), so analytics can tell which clients enforce frequency locally. Informational only: server-side caps also apply to older SDK versions.
2.3.0 — 2026-10-03
Action needed
ContentFlowErrorhas a new case,.invalidKey(statusCode:code:droppedCount:)(#1006). AswitchoverContentFlowErrorwithout adefaultno longer compiles: add the case, or adefault.- A rejected key stops the client for that key (#1006). Any HTTP 401, or a 403 with
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH, stops the stream, sync and location for good and drops every queue; later calls throw.invalidKeywithout a request. Only a new client with a corrected configuration, or an app restart, resumes (see Fixed). - License. 2.3.0 is the first release under the proprietary ContentFlow SDK License (
LICENSEin the package); earlier releases keep their terms. Read it before you upgrade (see Changed).
Fixed
- A rejected key now stops the client instead of retrying forever (#1006). Any HTTP 401, or a 403 with code
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH, puts the client in a key-dead state: the live stream stops without reconnecting, the periodic sync timer and scheduled retries are cancelled, location tracking stops, and every queue for the key (events, string-sensor events, uncertain events, location points, in-flight batches) is dropped. The newContentFlowError.invalidKey(statusCode:code:droppedCount:)is reported once throughContentFlowDiagnostics(categoryauth, with the typed error on the newContentFlowDiagnostic.errorproperty), at error level even when diagnostics are set to.none. Afterwards every call fails fast with.invalidKeywithout a request, and nothing is queued. Before, a 401 event batch went to the unbounded uncertain set and was logged only at verbose. Other 403s (DEVICE_NOT_IDENTIFIED,USER_MISMATCH, ...) do not trigger it. Resume by starting a new client with a corrected configuration, or restart the app. Write-key calls (registerStrings,disableStrings) never trigger it. Newclient.isKeyRejected. - The server error code is now read from the top-level
codeof the delivery gateway's envelope ({"code":"INVALID_SDK_KEY","error":"..."}) as well as the nestederror.code; the top-level code wins. It used to be lost whenerrorwas a string. Live-stream connection failures now read the error body too, so a stream 403 can tell a dead key from a per-route refusal. - Every retry or offline buffer is capped at 500 items and drops the oldest past that: the event queue (which now makes room instead of refusing a call with
.droppedQueueFull, unless one call alone exceeds 500), the uncertain-delivery set (previously unbounded), and the location retry buffer (previously 100, and it dropped the newest points). Location points now go out at most 100 perPOST /sdk/locationrequest, oldest first and one request right after another, because the gateway refuses bigger requests and stores nothing (a failed request keeps itself and everything after it for retry; a refused one drops only its own points). Drops are reported as oneevents/locationwarning at most once a minute with the count since the last report, instead of one log per overflow. - Only transport failures, timeouts, 429 and 5xx are retried. A 429 on
/sdk/eventsnow requeues the batch and re-flushes after exponential backoff with jitter (1 s base, 5 min cap), never sooner thanRetry-After;trackEngagementreturns.queuedfor it. Any other 4xx on/sdk/eventsdrops the batch (it was not applied and cannot succeed) instead of holding it as uncertain, and a non-retryable 4xx on/sdk/locationdrops the points instead of keeping them. Transport failures and 5xx on event writes still go to the uncertain set, as before.Retry-Afternow accepts an HTTP date as well as seconds, is clamped to 1 h, and is the minimum delay for read retries rather than the exact one. - Geofence events are sent in order. Each location fix was handed to the tracker in its own unordered task, so a later fix could be handled before an earlier one: a geofence exit could be lost, or sent before its enter. Fixes are now handled strictly in the order the system delivered them, each one (with its geofence report) before the next.
- Location batches were sent again at every flush (#929). Gateways before #930 answer a stored
POST /sdk/locationwithsuccess: trueand nodata; the SDK treated that as a failure, kept the batch and re-sent it, so the server stored the same points repeatedly. A 2xx withsuccess: trueon the location route now counts as delivered with or withoutdata, andsendLocationBatchandreportGeofenceEventno longer throw for it. Other routes still requiredata. - The location tracker no longer retries a batch the server refused (
DEVICE_NOT_IDENTIFIED,LOCATION_CONSENT_REQUIRED, or any 2xxsuccess: false). It drops the batch with alocationwarning, because the same points can't succeed until identity or consent changes. Transport failures, 5xx and 429 still keep the batch for the next flush. - A diagnostics level the app set was overwritten (#910):
ContentFlow.startand every new client resetContentFlowDiagnostics.levelfrom the configuration. A level the app sets now wins, before or after start, and survives a later start with a different configuration. Without one, the configuration'sdiagnosticsstill applies. NewContentFlowDiagnostics.resetLevel()hands control back to the configuration.
Added
- On-device geofences (#989).
startGeofencing(_:)/stopGeofencing()andgeofencingStatus. TheCoreLocationGeofenceMonitorinContentFlowLocationis installed byenableDeviceLocation(); install a custom one withsetGeofenceRegionMonitor(_:).- The SDK fetches the workspace's nearest fences from
GET /sdk/v1/geofences(ETag,X-CF-Device) and registers them withCLLocationManagerregion monitoring. That covers at most 20 regions, minus the ones your app monitors, with one boundary region that re-selects as the device moves. No continuous GPS is used. - Each crossing is reported through
POST /sdk/v1/locationasgeofenceEventswithsource: "device". Refusals are dropped and transport failures are retried. - Opt-in only. It needs analytics consent, location channel consent, the workspace
locationswitch and "Always" permission, which the app requests. When any of these goes away, all SDK regions are unregistered. - Every fence is registered for entry and exit, and both crossings are always reported, whatever its enter/exit triggers: the server needs the exit to close a stay. A dwell is reported only for fences with the dwell trigger.
- Dwell is computed from the persisted enter time at the next wake. It is stamped with the time it was reached (enter +
dwellMs, never later than now), and skipped if that is more than 24 hours old. Events carry the OS crossing time, never adwellTime. - The fence request asks for exactly the free slots and reads
truncated. Withtruncated: truethe SDK registers the nearest free slots − 1 plus a boundary region; withfalseit registers everything. An older server without the field gets the full-answer rule, and the SDK switches to a one-extra probe until the field returns. - A 502 or 503 from
/geofenceskeeps the registered regions. An empty list unregisters them. Query coordinates are rounded to 3 decimals and are sent as a pair or not at all. - iOS keeps regions across launches, so the SDK removes every
cf.geo.*region left by an earlier launch, once per process before its first registration and on every teardown (stop, consent revoke, switch off). That includes astopGeofencing()or a closed gate in a fresh process that never started geofencing (#1010). The app's regions are untouched. - Fences carry no
name(removed fromGET /sdk/v1/geofencesin #1021). Anamesent by an older server is ignored (#1010). - App-defined fences (
registerGeofence) whose ids the dashboard doesn't have no longer reach analytics from device reports. - Until the server route ships, a 404 leaves geofencing quietly idle.
- Workspace feature switches (#907): the SDK reads
config.featuresfrom/syncand exposes it asclient.features(SDKFeatures). Withlive_updatesoff the stream is not opened (polling continues), withcampaignsoffgetCampaignsreturns an empty list without a request, and withlocationoff tracking pauses and unsent points are dropped, resuming when switched back on. Missing or malformed values count as on, a 304 keeps the last value, and a switch never changes the base URL, keys, authentication, pinning or consent. - New
disableStrings(_:writeKey:): stop tracking these localization keys; their text is still served. It sendsPOST /api/v1/strings/disablewithX-CF-Write-Key(API root derived from the base URL, likeregisterStrings) and returnsStringDisableResult(disabled,alreadyDisabled,unknown). Their sensors turn off, so the SDK stops sending string events for them once the next strings refresh drops them fromsmartKeys. Turn tracking back on in the portal (Sensors → Enable); there is no SDK call for it, andregisterStringsnever changes tracking. Takes 1 to 500 non-empty keys of at most 200 characters (duplicates dropped) and throwsContentFlowError.invalidConfigurationbefore sending anything otherwise; request failures throw, likeregisterStrings. Write key only: call it from a server or CI tool, never from a shipped app. Needs the management API's/strings/disableroute.
Changed
- Every request, the live-updates stream included, now sends
X-CF-SDK-Capabilities: erasure-partial-v1(it was empty).deleteIdentity()already throws and keeps the device id and local identity on a partial erasure (a 207 withsuccess: false), so the gateway now relays the 207 to this SDK instead of a 502 (#968). The SDK still never declaressponsored-surface-v1. CFSecureStorageno longer falls back to an XOR-obfuscated copy inUserDefaultswhen the Keychain write fails (#910). The write fails instead:setSecurereturnsfalseand nothing is stored. Obfuscated copies written by earlier versions are no longer read;removeSecure(forKey:)andclearAll()still delete them. ThedeviceId:initializer argument is ignored.- License: the SDK is now licensed under the ContentFlow SDK License (proprietary). Earlier releases' license terms are unchanged.
Deprecated
CFRequestSigner(#910). No ContentFlow server verifiesX-CF-Signature, a signing secret shipped in an app can be extracted, and the SDK never used it. Remove it; requests are authenticated by the publishable key. It still compiles and will be removed in the next major version.CFSecureStorage(#910). The SDK never used it. It still compiles and will be removed in the next major version. The README now states what the SDK stores on the device: it doesn't encrypt or sign anything.- Deprecated
getIndustryProfile(). It now returnsnilwithout making a request. It used to callGET /sdk/industry-profile, which the gateway never had, so every call failed. Industry data is write-only from the SDK: send it withupdateIndustryProfile(_:), and ContentFlow uses it server-side. Remove calls to it; it will be removed in the next major version (#932).
2.2.2 — 2026-10-01
Action needed
- None. 2.2.1 apps upgrade without code changes.
Added
- Every request, the live-updates stream included, sends
X-CF-SDK-Version: swift/2.2.2and an emptyX-CF-SDK-Capabilities(#903). The SDK declares no capabilities, so it never receives sponsored content.User-Agentis unchanged.
2.2.1 — 2026-09-30
Action needed
- If you enable certificate pinning and don't set
baseUrl: the default host changed from app.contentflow.click to api.contentflow.click, which has a different key. Add the api-host pins or setbaseUrlexplicitly before upgrading. Current pins:app.contentflow.clicksha256/A3O5BGFGDQZm4wNh1QpbA4QYeI0vT6D5tCSOy65IrhI=,api.contentflow.clicksha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8=(both certificates valid until 2026-12-11). - A base URL that doesn't end in
/sdk/v1now logs one notice per client (never blocking):ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).
Changed
ContentFlowConfiguration.defaultBaseURLishttps://api.contentflow.click/sdk/v1(washttps://app.contentflow.click/sdk/v1). An explicitbaseURL:is unchanged; thebaseURL:parameter is nowURL?(existing callers compile unchanged).
Added
ContentFlowBaseURLinInfo.plistconfigures the base URL without code. Precedence: an explicitbaseURL:>Info.plist> the default. The Info.plist value must be https (http only for localhost, 127.0.0.1, 10.0.2.2); an invalid value is ignored with an error diagnostic.
2.2.0 — 2026-09-26
Breaking / action needed
- Device location lives in the separate opt-in product
ContentFlowLocation. Add it to your target and callawait client.enableDeviceLocation(); the coreContentFlowproduct no longer links CoreLocation's permission APIs. - Location is gated on analytics consent: without it nothing is collected or sent, and revoking consent stops collection and discards unsent points.
updateIndustryProfilenow throwsinvalidConfigurationwithout a signed-in user (the gateway requires one).
Changes
- Fixed routes.
sendLocationBatchandreportGeofenceEventnow post toPOST /sdk/location(the only location route the gateway mounts), sendinglocationsandgeofenceEventsin the shape its handler reads. They previously posted to/sdk/location/batchand/sdk/geofence-event, which do not exist, so every location and geofence call failed. Coordinates are sent in their shortest decimal form (24.7136) instead of binary noise (24.71360000000000512). - Fixed route.
updateIndustryProfilenow posts{userId, category, data}toPOST /sdk/profileinstead of the non-existent/sdk/industry-profile. It is a domain-profile update: the data lands in the user'scustomAttributes.domainProfile.<industry>and is usable in segments, butgetIndustryProfile()does not read it back. It now throwsinvalidConfigurationwithout a signed-in user, because the gateway requires one. - New:
getLocales()/getCachedLocales(), the workspace's locale catalog (GET /sdk/locales), revalidated with its own ETag. An empty list is a valid answer; a failure throws and never invents locales. - New:
deleteIdentity(), GDPR/PDPL erasure of this installation on the server (DELETE /sdk/identify). Location collection stops and unsent points are discarded first. Only after the server confirms, the SDK also forgets identity, traits, segments, pending and uncertain events, personalised cache and the analytics consent decision (back to "not decided"). A failed erasure leaves identity, consent and cache as they were. - New: device location, in a separate opt-in product
ContentFlowLocation(await client.enableDeviceLocation()), so the coreContentFlowproduct never links CoreLocation's permission APIs (App Store ITMS-90683). Core API:requestLocationPermission(always:),locationAuthorization(),locationAccuracyAuthorization(),requestLocation(_:),startLocationTracking(_:),stopLocationTracking(),registerGeofence(_:),unregisterGeofence(id:), andsetLocationProvider(_:)with the publicLocationProvidingprotocol for custom sources. The SDK never prompts on its own. Tracked fixes are filtered (accuracy, movement, mocked) and batched toPOST /sdk/location; circle and polygon geofences report enter, exit and dwell. TypedLocationError. Continuous tracking is unavailable on tvOS. Approximate-only permission is reported (.reduced), and a warning is logged when accuracy filtering drops fixes.- Location is gated on analytics consent (no separate location-tracking consent). Revoking analytics consent stops collection and discards unsent points.
deleteIdentity()stops collection and discards unsent points before the erasure request, and waits for any upload already on the wire, so no location reaches the server after erasure.- Logout and switching users send the outgoing user's points while that user is still current and never retry them under the next user.
- New:
registerStrings(_:writeKey:), a server-side tool that registers source strings for translation throughPOST /api/v1/strings/syncwith a write key (defaultValue→sourceText,context→description). Never ship a write key in an app. - Identity events:
trackIdentityand identity events insidetrack([CFEvent])/trackDetailedalready reach/sdk/track-event; now covered by tests.
2.1.0 — 2026-09-24
Only the
baseUrlhost is pinned. Renew server certificates with the same key (e.g.certbot --reuse-key), or ship a backup pin for the new key in an app release before rotating it. Block images rendered by the SDK's generic block view load through SwiftUIAsyncImageand are not pinned.Certificate pinning now works and is part of the SDK.
ContentFlowConfiguration.certificatePins(off by default) pins every SDK connection, including the live-updates stream. Pins aresha256/<base64>SubjectPublicKeyInfo hashes, so they survive certificate renewal. The system trust evaluation (chain, validity, hostname) must pass first, then a key in the validated chain must match a pin. At least two distinct pins are required, and an invalid pin list makes every call fail withinvalidConfigurationinstead of connecting unpinned.Breaking for anyone using
CFCertificatePinnerdirectly (the SDK itself never used it before):init(fingerprints:enabled:)is replaced by the throwinginit(pins:), hex certificate fingerprints are no longer accepted, andisEnabled,validateCertificate(_:),validateTrust(_:),isValidFingerprint(_:)and the no-opsessionConfiguration(base:)are removed. New:evaluate(_:host:),spkiPin(for:),isValidPin(_:)andminimumPins.
1.2.0 — 2026-09-19
- Adds a complete public client lifecycle, identity and consent flow, locale switching, dynamic blocks, localized strings, campaign evaluation, string Sensors analytics, and push registration/unregistration.
- Keeps block and string validators separate by resource and locale. Cache namespaces include the gateway URL, tenant, complete publishable key, environment, stable device, hashed user scope, and schema version.
- Adds explicit legacy device-id import with validation and conflict detection, serialized identity writes, typed identity/linkage results, signed-out device-scoped channel consent receipts, and logout with partial push-clear reporting.
- Treats HTTP 2xx
success:falseas an error. The documented partial identity/logout shapes commit only the explicitly applied identity transition, then throw the typed partial result. - Batches engagement events and validates aggregate
accepted/droppedaccounting. Typed receipts include category and drop-reason counts.trackDetailedpreserves known mixed-category outcomes alongside uncertain ids, refusals, and per-event failures. Partial acceptance consumes the complete batch; incomplete or ambiguous writes move to a separate in-memory set and are never replayed automatically. - Adds the shared runtime contract v3 fixture suite, an environment-only executable example, a buildable SwiftUI iOS sample, and a migration guide for apps moving from a hand-written client.
The event endpoint acknowledges gateway acceptance or queueing. It does not prove durable storage, and it currently provides no event-id deduplication.
1.0.0
- Initial Swift/SwiftUI dynamic blocks, localization, consent, and engagement client.