Changelog
Unreleased
2.4.1 — 2026-10-11
Action needed
presentPopup/screenChangedwithautoPresentnow draw a block popup that they used to skip. When a block wins the popup slot and you have not setpopups.onBlockPopup(React / React Native / Expo<CFPopupHost>:renderBlockoronBlockPopup), a block with a non-empty#titleor#bodyis shown with the built-in card instead of returningnot_handled. To keep the 2.4.0 behaviour, setpopups.blockPopups: 'off'(or<CFPopupHost blockPopups="off" />). If you handleonAction, it can now receive a block (see Added). A customCFPopupPresenternow also receives blocks: checkview.kindbefore readingview.campaign.
Fixed
- An in-app message (instance id starting
jm_, delivered underjourney_messageor the placement you chose) that the person closed came back on every sync when analytics consent was not granted, because thedismiss/cta_clickevent that removes it is only sent with consent. The SDK now remembers on the device which messages gottrackDismiss,trackCtaClickortrackTap(with or without consent; the event itself is still sent only with consent) and leaves them out ofgetBlock,getBlocks,getAll,variant,subscribe, thesync()result,fetchBlockand the React hooks. It stores instance ids and times only, keeps them for 8 days (at most 200), and clears them on a user change,logout(),deleteIdentity()andresetFrequency(). A block counts as an in-app message by itsjm_instance id, whatever its key; other blocks are unaffected.
Added
- The built-in popup card also draws block popups that use the standard fields:
#title,#body,#image(https only),#cta_label,#cta_url;#cta_visiblefalse (a toggle, or the text"false") hides the button. Same theme, layout, RTL, accessibility, delay, one-popup-at-a-time and CTA link rules as in-app campaigns; the result is{ shown: true, kind: 'block', id: <instanceId> }. Tracking goes through the block path (trackImpression, then exactly one oftrackCtaClickortrackDismiss), so the frequency ledger, the dismissed in-app message list and the instance's statistics all apply.onBlockPopup/renderBlock, when set, always wins; a block without#title/#bodyis stillnot_handled.renderCampaignPopup/renderCampaignnever applies to blocks. popups.blockPopups('builtIn'default, or'off') and theblockPopupsprop on<CFPopupHost>.popups.onAction(item, url)/ theonActionprop:itemis the campaign, or the block when the CTA came from a built-in block card (tell them apart with'campaignId' in item). Existing(campaign, url) => …handlers still compile.- Types
CFBlockPopupsMode,CFBlockPopupView,CFPopupView;blockPopupViewin@contentflow/sdk/advanced. A customCFPopupPresenternow receivesCFPopupView: checkview.kind === 'block'before readingview.campaign.
Changed
- The package's sourcemaps no longer embed the original TypeScript sources. Stack traces still map to file and line; the package is about half the size (2.0 MB packed, was 3.9 MB).
2.4.0 — 2026-10-10
Action needed
- If you route SDK traffic through your own proxy with a CORS allowlist, allow the new request header
X-CF-Session-Id, now sent on/sync,/blocks/{key}and/campaignsreads (see Changed). The ContentFlow gateway already allows it.
Added
- Core workspace switches on
CFSdkFeatures: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. Withanalytics_eventsoff the SDK also drops events on the device (never sent or stored), reported tosubscribeEventDropswithdropReasons.feature_disabled. - 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 flushes, offline, from cache and within a session. Existing integrations get it without new calls:
trackImpression,trackTap/trackCtaClick,trackDismiss,trackConversionand the campaigntrack*calls update the ledger.client.shouldShow(blockOrCampaign)andclient.frequencyReason(...)(capped_impressions | capped_clicks | capped_session | cooldown | stopped_on_<event> | workspace_guard).client.getPopup(screen)/ContentFlow.getPopup(screen): at most one interruptive block or in-app campaign for the screen, highest priority first, after its rule and the workspace popup limits (config.frequency.fatigue: per session, per day, minimum gap). React and React Native:useCFPopup(screen)→{ popup, dismiss }, tracking the impression and the dismissal. A background sync does not count a second impression for the popup on screen, and refreshes its content. The popup stays while it is still delivered, or when it is withheld only because its own frequency rule now caps it (a "show once" popup after its impression); a popup paused, disabled, untargeted or expired in the portal comes down on the next sync.trackCampaignDismiss(campaign)for "until the customer closes it" campaigns;ContentFlow.trackCtaClick/trackDismisson the singleton.resetFrequency()(meant for debug builds; not blocked in release) 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. A login keeps what was counted anonymously on the device; the ledger is cleared onlogout()anddeleteIdentity(). It stores counters and timestamps only, in the same storage as the other per-device state (secure storage when enabled). CFCampaign.frequency,CFSdkFeatures.frequencyCaps(thefrequency_capsworkspace switch: off means the SDK ignores its ledger), new typesCFPopup,CFFrequencyRule,CFFrequencyConfig,CFSuppressionReason.
- SDK-drawn campaign popup. In-app campaigns (the
campaignresult ofgetPopup) can now be shown with a built-in UI; blocks stay app-drawn. Nothing changes for an app that does not opt in.client.presentPopup(screen, { placement?, locale? })/ContentFlow.presentPopup(screen)→{ shown, kind, id?, reason? }(none | already_showing | not_handled | features_off | no_ui_context). A campaign gets the built-in popup (orpopups.renderCampaignPopup); a block goes topopups.onBlockPopup(block), which draws and tracks it as before. One popup at a time.client.screenChanged(screen): withpopups.autoPresent(default false) presents that screen's popup afterpopups.delayMs(0 to 60000, default 0); a newer screen cancels a presentation that has not gone up yet.CFConfig.popups:{ autoPresent, delayMs, theme, renderCampaignPopup, onBlockPopup, onAction }.onAction(campaign, url)returning true means the app handled the CTA (deep links); otherwise the SDK opens it. Onlyhttp,httpsand app schemes are opened (javascript:,data:,file:,vbscript:,blob:andabout:are refused; thecta_clickis still tracked). The campaign image is loaded only fromhttpson the web popup and the React Native popup; anything else is dropped.- Tracking is automatic, once per presentation:
impressionwhen it shows, then exactly one ofcta_click(CTA) ordismiss(close button, backdrop, Escape, Android back). Consent gating is unchanged. - Web (
@contentflow/sdk/web,@contentflow/sdk): a DOM popup in its own shadow root (text viatextContentonly,role="dialog", focus trap, Escape,prefers-reduced-motion,prefers-color-scheme), installed bycreateCFClient/ContentFlow.init;createDomPopupPresenter()for a client built by hand. - React:
<CFPopupHost screen autoPresent renderBlock onAction />. React Native / Expo:<CFPopupHost>from@contentflow/sdk/react-nativeon RNModal(Android back,accessibilityViewIsModal, RTL from the locale orI18nManager, no new dependencies). - Right-to-left locales (ar, he, iw, fa, ur, ps, sd, yi, ckb) lay the popup out RTL with an Arabic close label for
ar. trackCampaignCtaClick(campaign)(campaigncta_click, same payload as the other campaign events),isPopupShowing(),closePopup(),setPopupPresenter(); helpersisAllowedActionUrl,isRtlLocale.
Changed
- One session id per session, sent as
X-CF-Session-Idon content reads (/sync,/blocks/{key},/campaigns) and ascontext.sessionIdon every event batch, so the server can scope once-per-session popup rules and session funnels. The id used to be fixed for the whole client lifetime; it now belongs to the popup frequency ledger and rotates exactly when the ledger starts a new session (cold start, 30 minutes in the background,logout(),deleteIdentity(),resetFrequency()). Events keep the session they were queued in (a batch spanning a rotation is sent as one request per session, the page-unload keepalive flush too). If a later session's request must be retried, only that request and the ones not yet sent are retried; a session's events the server already accepted are never sent twice. The id is a random UUID (sess-<base36>wherecrypto.randomUUIDis unavailable), memory only, with no PII; web, React Native and Expo share the same background detection. ScheduleModeis now'always' | 'payday' | 'ramadan' | 'custom', the modes the server accepts and enforces (#955);BlockSchedulegainstzandprecision. The old'scheduled'and'off'were never valid on the server. Type-only change.- 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
- A rejected key stops the SDK for that key (#1006). An HTTP 401, or a 403 with
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH, now stops polling, the live stream and every queue for good, and drops what was queued; later calls reject withCFInvalidKeyError. Only a new client with a corrected key, or an app restart, resumes. HandleCFInvalidKeyErrorinonError/subscribeErrors()if you want to report it (see Fixed). - On-device geofencing needs an explicit location grant (#1010).
startGeofencingstays idle until the app callssetChannelConsent({ locationTracking: true }); unset location consent is not enough (see Added). - 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 SDK key now stops the SDK instead of retrying forever (#1006). The key is dead on an HTTP 401 (any code, or none) or on a 403 with
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH, read from the top-levelcodeor the nestederror.code. Other 403s, such asDEVICE_NOT_IDENTIFIED,USER_MISMATCH,IDENTITY_*and location refusals, don't count. When the key dies, the SDK reports one newCFInvalidKeyError(code: 'invalid_sdk_key', withstatus,serverCodeanddroppedCount) throughonError,subscribeErrors()andgetLiveStatus().lastError, and logs it withconsole.erroreven withoutdebug. It drops queued events, string-sensor events, the offline outbox and location points. It stops the poll tick and the SSE stream; live status becomesstoppedwith reasoninvalid_key. The SDK then sends no more requests with that key:sync()andstart()resolve with last-good content, and calls that return a result reject with the same error. The state is held in memory only. Create a new client with a corrected key, or restart the app, to resume. Previously/syncbacked off to 5 minutes and kept polling, event batches were dropped one by one, and the stream reconnected forever.CFInvalidKeyErroris exported from every entry. - The SSE stream checks the key with the conditional
/syncbefore every reconnect, on web and React Native, because anEventSourcecan't see a 401 (#1006). If the key is dead, the stream stops. If the check fails with a transport error or a 5xx, the stream reconnects with the existing backoff. - Every send queue now holds at most 500 items: the event queue, the offline outbox and the location queue. A larger
maxOfflineQueueSizeis capped at 500. When a queue is full, the oldest item is dropped. Capacity drops are reported at most once a minute with the count since the last report, instead of one report and warning per dropped event (#1006). The location queue is sent in time-ordered chunks of at most 100 points per request, the gateway's per-request limit, so a large backlog after an outage is no longer refused whole; a chunk that fails with a transport error, 429 or 5xx is retried with everything after it, while chunks already delivered aren't sent again. - Retries now cover only transport failures, 429 and 5xx, with exponential backoff and jitter (from 1 s, capped at 5 minutes) (#1006). The SDK now honours
Retry-After, as seconds or an HTTP date and capped at 1 hour, as the minimum wait. Other 4xx responses are dropped and not retried. Location batches that fail with a transport error, 429 or 5xx are now retried; they used to be dropped. Event batches keep the existingeventRetryPolicyrule: by default, a transport failure or 5xx is reported throughonEventsUncertainand isn't replayed.
Added
- On-device geofences (#989):
client.startGeofencing({ monitor })/stopGeofencing()/isGeofencingfetch the workspace's nearest fences fromGET /geofences(ETag-conditional), register them with the OS through aGeofenceRegionMonitor, and report OS transitions onPOST /locationasgeofenceEventswithsource: "device".@contentflow/sdk/expoaddsdefineContentFlowGeofenceTask,startExpoGeofencing,stopExpoGeofencingandcreateExpoGeofenceMonitoronexpo-location+expo-task-manager(new optional peer dependencies, injected likeexpo-notifications). Opt-in only; runs while analytics consent is granted, location consent is not revoked and thelocationswitch is on, and removes every SDK-owned region when one turns off. Nearest-N within the OS limit (iOS 20, Android 100, minusreservedAppRegions), asking for exactly the free slots and using the server'struncatedflag (falling back to one more than fits against a server without it), with a boundary region for re-selection after a move; enter and exit are always monitored and reported whatever the trigger flags (the server needs the exit to close a stay), dwell only when on; querylat/lngrounded to 3 decimals; a 502/503 keeps the registered regions and a 200 with no fences removes them; polygons are registered as their bounding circle; refusals are dropped and transport errors kept (max 50); dwell is computed from the enter time at the next wake, timestamped enter time +dwellMs, and skipped when more than 24 h old. A 404 from a gateway without the route leaves geofencing idle without a base-URL diagnosis. Web is not supported (server-side evaluation only). TheContentFlowsingleton gainsstartGeofencing/stopGeofencing. OS registrations outlive the process, so the first start in each process clears every SDK-owned region before registering and every teardown clears them, andstopExpoGeofencing(client, Location)stops the task's regions even when this launch never started geofencing. - On-device geofencing follow-ups (#1010): geofencing now needs an explicit location grant (
setChannelConsent({ locationTracking: true })), the same as Swift; unset location consent keeps it idle.stopGeofencing({ monitor })(andstopExpoGeofencing(client, Location)) removes SDK regions an earlier launch left even when this launch never started geofencing.OnDeviceGeofencehas nonamefield (the server no longer sends one, #1021). - New
ContentFlow.disableStrings(keys, { writeKey })(also onCFClient): stop tracking these localization keys; their text is still served. It sendsPOST /api/v1/strings/disablewithX-CF-Write-Key(API root derived frombaseUrl, likeregisterStrings) and resolves to{ disabled, alreadyDisabled, unknown }(newStringDisableResulttype). 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 throws before sending anything otherwise; it throws on a 400/401 or any other failure, likeregisterStrings. Write key only: call it from a server or CI job, never from a shipped app. Needs the management API's/strings/disableroute. - Workspace feature switches (#907): the SDK reads
config.featuresfrom/syncand honours thelive_updates,campaignsandlocationswitches. Off means no SSE stream (live status reasonfeature_disabled),getCampaigns()resolving[]without a request, and location tracking paused with unsent points dropped. Missing or malformed values count as on; a 304 keeps the last value, which is also cached with the content. NewgetFeatures(),subscribeFeatures(),CFConfig.onFeaturesChangeand theCFSdkFeaturestype. A switch only pauses what the app started and never touches the base URL, keys, authentication, pinning or consent. identify()andCFConfigaccept optional top-levelnameandemailfor a signed-in user. They are sent onPOST /identifyonly when set, so the user is readable in the audience list; omit them for anonymous installs or without analytics consent. NewCFClient.revokeAnalyticsLocally()stops sending events and drops everything unsent (event queue, offline queue, location points) without any network call, unlikesetConsent(false), which sends an/identify; calldispose()afterwards.
Changed
- App-defined fences (
registerGeofencewith ids the dashboard doesn't have) no longer reach analytics from device reports; the server records only workspace fences (#989). - Every request now sends
X-CF-SDK-Capabilities: erasure-partial-v1(withsponsored: true,erasure-partial-v1,sponsored-surface-v1).deleteIdentity()already throwsCFWriteFailedErrorand keeps the device id on a partial erasure, so the gateway now relays its 207 to this SDK instead of a 502 (#968).resolveCapabilitiesandcapabilityHeaderValue(from@contentflow/sdk/advanced) include it. The SDK still never declaressponsored-surface-v1unless the app opts in. - License: the SDK is now licensed under the ContentFlow SDK License (proprietary). Earlier releases' license terms are unchanged.
Deprecated
- Deprecated
getIndustryProfile(). It now resolves tonullwithout making a request, and logs one deprecation warning per client. It used to callGET /industry-profile, which the gateway never had, so it always returnednullanyway. Industry data is write-only from the SDK: send it withupdateIndustryProfile(), and ContentFlow uses it server-side. Remove calls togetIndustryProfile(); it will be removed in the next major version (#932).
2.2.2 — 2026-10-01
Action needed
- Every request now also sends
X-CF-SDK-Capabilities(empty unless you setsponsored: true). If you route SDK traffic through your own proxy with a CORS allowlist, allow that header; the ContentFlow gateway already does.
Fixed
- The root entry
@contentflow/sdknow exportsconsentPathsToConfigand the error classes (CFError,CFInvalidBaseUrlError,CFWriteFailedError,CFPartialWriteError,CFUnknownConsentPresetError,CFConsentConfigUnavailableError,CFLocalesUnavailableError,CFMalformedResponseError), plus the typesCFErrorCode,ConsentConfig,ConsentPreset,ConsentPresetTopicandConsentRequestOptions. The README importsconsentPathsToConfigfrom the root, which failed to resolve. Additive only; nothing was removed. Use the error classes from the entry you import the client from: each entry is a separate bundle with its own copy, soinstanceofonly matches classes from the same entry. @contentflow/sdk/embednow reads itsdata-cf-*attributes (data-cf-key,data-cf-base,data-cf-locale,data-cf-consent,data-cf-debug) when loaded as<script type="module" src="…/embed.js" …>. The built file is an ES module, wheredocument.currentScriptis always null, so the attributes were never read and onlywindow.contentflowworked. The embed now finds its own tag by matching its module URL against the page's module scripts. Precedence per field:window.contentflow> data attributes > defaults, so existingwindow.contentflowsetups behave exactly as before. A classic<script src>(withouttype="module") still cannot load the file.
Added
- Every request sends
X-CF-SDK-Version: <platform>/<version>:js/2.2.2from the root,/web,/react,/embedand/advancedentries,react-native/2.2.2from the React Native adapter (#903). It was the bare version (2.2.1). The platform comes from the newPlatformAdapter.sdkPlatform; a custom adapter that doesn't set it reportsjs. - Every request sends
X-CF-SDK-Capabilities: empty by default,sponsored-surface-v1only when the app opts in withsponsored: true, exactly as before (#903). The header used to be omitted when empty.
Changed
capabilityHeaderValue(from@contentflow/sdk/advanced) returns''instead ofundefinedwhen nothing is declared.
Docs
- JSDoc examples in
@contentflow/sdk/web,/reactand/react-native, and thecontentflow-pullCLI help, use<workspace-id>_testplaceholders instead of key-shaped values, and name where keys live in the portal: Developers → Your keys. The unreferencedexamples/web.htmlandexamples/react-native.tsx(esm.sh imports, hard-coded consent) are removed (#897).
2.2.1 — 2026-09-30
Action needed
- The default base URL changed from
https://app.contentflow.click/sdk/v1tohttps://api.contentflow.click/sdk/v1. Only apps that don't setbaseUrlare affected; the old host keeps working. (The JS SDK can't pin certificates, so there is no pinning action.) - 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
- Default
baseUrlishttps://api.contentflow.click/sdk/v1(the SDK host; app.contentflow.click is the portal). - The React Native example pins
expo~57.0.26.
Added
- Base URL configuration without code. Precedence: an explicit
baseUrl>globalThis.__CONTENTFLOW_BASE_URL__(a page global) > the default. The global must be https (http only for localhost, 127.0.0.1, 10.0.2.2); an invalid value is ignored with a warning. The SDK never readsprocess.env; pass Vite, Next.js or Expo build values asbaseUrl(see the README).
2.2.0 — 2026-09-26
Breaking / action needed
- Device location is now gated on analytics consent: without it nothing is collected or uploaded, and revoking consent stops tracking and discards unsent points. Restart tracking after consent is granted again.
- React Native: device location needs a geolocation module passed to the SDK (
<CFProvider geolocation={Geolocation}>orcreateRNPlatform({ geolocation }), e.g.@react-native-community/geolocation); without onerequestLocation()rejects immediately. - A confirmed
deleteIdentity()disposes the client; create a new one afterwards (theContentFlowfacade resets itself).
Changes
- Add
deleteIdentity()(client andContentFlowfacade): GDPR / PDPL erasure throughDELETE /identify. Analytics collection stops first (consent revoked locally, pending and offline events dropped). A confirmed complete erasure wipes persisted identity state, the visitor id and the device id, then disposes the client; create a new one afterwards (the facade resets itself). A partial or unconfirmed erasure throwswrite_failedand keeps the device id for a retry. New typeDeleteIdentityReceipt. enableSecureStoragenow does something. It was on by default butCFSecureStoragewas never used. Analytics consent, channel consent and segment membership are now stored XOR-obfuscated (not encrypted) undercf_sec_keys. Existing plain values migrate on first read, and switching the flag off migrates them back; the old copy is removed only after the new write succeeds, and the device id is never moved or regenerated. The device id stays plain (it is the obfuscation key's root). Runtimes without SHA-256 keep plain storage.- Fix: location data was silently dropped. Location batches sent
pointsand geofence events sent top-level fields; the gateway'sPOST /locationreadslocations[]andgeofenceEvents[], so it accepted the requests and stored nothing. Both now match the gateway handler exactly. - Fix:
updateIndustryProfile404'd. It now sendsPOST /profilewith{ userId, category, data }(a domain-profile update, stored undercustomAttributes.domainProfile.<industry>) and requires a signed-in user. - Add
registerStrings(strings, { writeKey })(client and facade): strings as code, server/CI only,POST /api/v1/strings/sync(API root derived frombaseUrl) withX-CF-Write-Key, same payload as the Swift, Kotlin and Flutter SDKs. New typesStringRegistration,StringRegistrationOptions,StringRegistrationResult. - Fix: device location never worked.
requestLocation()waited on a callback nothing called (always timed out) andstartLocationTracking()never watched. The platform adapter now supplies geolocation:navigator.geolocationon the web, and on React Native anavigator.geolocation-shaped module passed asgeolocation(e.g.@react-native-community/geolocation). The permission prompt appears only on those explicit calls. Without an adapter,requestLocation()rejects immediately. NewPlatformAdapter.geolocation,fromNavigatorGeolocationand related types. - Location privacy. Location is gated on analytics consent: nothing is collected or uploaded without it, and every upload re-checks it. Revoking consent,
deleteIdentity()anddispose()stop the platform watch and discard unsent points without flushing;deleteIdentity()does this before sendingDELETE /identifyand waits out any upload already in flight, so no location request follows an erasure. On an identity change (setUserId,logout,identifywith a new user) points collected for the outgoing user are sent as that user first, or dropped, and are never retried under the incoming one. - Documented as not yet available (no delivery-API route):
submitKYC,getKYCState,getIndustryProfile.
2.1.0 — 2026-09-24
- No JavaScript SDK API or behaviour changes. The version moves to 2.1.0 so all four ContentFlow SDKs (JS, Swift, Kotlin, Flutter) release together; the Kotlin and Flutter SDKs have breaking changes in this release (see their changelogs).
- The React Native example pins
expo~57.0.25. - The JS SDK does not do certificate pinning:
enableCertificatePinning(andenableRequestSigning) still throw at init, becausefetchcannot enforce them. Pin in a native wrapper or a trusted server proxy.
1.2.0 — 2026-09-19
- Add device-scoped, idempotent push token removal with
unregisterPush(). - Add durable
logout(), device-only channel consent receipts, push linkage receipts, HMS configuration forwarding, and typed partial-write receipts for proven identity changes. - Serialize identity and consent writes by public API invocation order. Analytics revocation now disables collection and purges pending events immediately, while grants require an explicit server confirmation and cannot override a newer revocation.
- Reject nested
success:falseresponses and malformed successful bodies; identify failures now reject the promise. Read outages preserve last-good content. - Isolate persisted caches by endpoint, workspace, publishable key, installation, user and audience generation; same-user targeting changes invalidate every locale/namespace and late response, while logout clears the old outbox.
- Bound event batches to 100 and await an already-running flush. Preserve aggregate
dropReasons, require exact accepted+dropped accounting, and report unaccounted events without replaying accepted items. Report uncertain transport/server delivery; automatic replay requires expliciteventRetryPolicy: 'at-least-once'and may duplicate metrics. - Recover from unavailable storage, report offline eviction/expiry/retry exhaustion, and check consent on every offline delivery.
- Pause polling, SSE reconnects and periodic event flushes in the background; force blocks and strings reconciliation before foreground live work resumes.
- Send canonical locale query and
Accept-Language, keep sync/string validators separate, remove stale validators when a response provides none, and evaluate locale-keyed campaigns through public/campaignswith exact/neutral/English resolution. - Export campaign hooks for React and fix campaign user identity / industry event shape.
- Add installable archive samples for Web, React and React Native.
- Add observable live delivery status:
client.getLiveStatus(),client.subscribeLiveStatus(),CFConfig.onLiveStatus, and the React/React NativeuseLiveStatus()hook. Reportsmode(stream/poll/off),state, areason(e.g.stream_unsupported,live_disabled,backgrounded),since, and a stickylastError— previously a stream falling back to polling, or failing to stream at all, was visible only withdebug: true. - Add scoped overrides to
fetchBlock(key, { locale?, deviceId? }): a one-off read of another locale or another install's content without callingsetLocale()(which re-syncs the whole app). An override is never written into the block cache, the sync list, or the active locale/device identity; a call with nooptionsis unchanged. - Pre-consent analytics refusals are now reported, not silent. Every
track*/trackStringcall made beforesetConsent(true)produces a drop report (source: 'local',reason: 'consent_not_granted') through the existingclient.subscribeEventDrops()/CFConfig.onEventsDroppedmachinery. The underlying policy is unchanged: pre-consent events are still never collected, queued, retained, or replayed once consent arrives. - Add
EventDropInfo.source/EventsDroppedInfo.source('local' | 'server') on every drop report, so a caller can tell the SDK's own refusals (queue full, identity changed, pre-consent) from a server-side drop. Reports from older SDKs carry nosource; its absence means nothing either way. - Add caller-visible read failures:
client.subscribeErrors(),CFConfig.onError, andclient.getLiveStatus().lastErrornow report a misconfiguredbaseUrlas a typedCFInvalidBaseUrlError(code: 'invalid_base_url') — the same error instance on all three surfaces.fetchBlock()andgetStrings()now reject with it instead of resolvingundefined/last-good strings (see Migration notes below);sync()/start()still resolve with last-good content and only report it. An ordinary transient outage (503, timeout) is unaffected and fires none of this. - Realign
ConsentPresetto whatGET /sdk/consent/config/:presetactually sends:preset,industry,region,required/optional(dotted consent paths, e.g.channels.push,marketing.promotional,location),defaultTopics,availableTopics,legalFramework. The previous declared shape (id/name/nestedchannels/marketing/locationbooleans) never matched the wire, so those reads were silentlyundefined;id/nameare kept as deprecated aliases ofpreset. AddconsentPathsToConfig(paths, value?)to turn a preset's dotted paths into the flatConsentConfigthe write methods take. Reading a preset never grants consent. getConsentConfig()now throws typed errors instead of a genericError:CFUnknownConsentPresetError(unknown preset id, carriesavailablePresets),CFConsentConfigUnavailableError(service failure),CFMalformedResponseError(200 with a body that doesn't match the schema).requestConsent({ preset })propagates them unchanged. Valid preset ids:banking_ksa,insurance_ksa,healthcare_ksa,travel_ksa,retail_ksa,food_delivery_ksa,fintech_ksa— there is nodefault.- Add
client.getLocales()/client.getCachedLocales()— the workspace's published-locale catalog ({ locales, sourceLocale, version }), ETag-revalidated with its own validator, independent of the sync/strings validators. Every failure is typed (CFLocalesUnavailableError,CFMalformedResponseError); the SDK never synthesizesen/arwhen the route is unavailable. An emptylocaleslist is a valid answer. - Add a theming primitive:
parseCFColor(),getBlockColor(),getBlockColors(),themeFromBlock(), plus the'color'CFFieldTypeand the React/React NativeuseCFTheme()hook (built onuseCFBlock, so it follows the same live-update/cache semantics and does not itself fire an impression event). Turns a block's color field values into typedCFColorobjects (hex,rgba,raw); this is a color reader, not a UI or design-token framework. - Add
@contentflow/sdk/expo:registerExpoPush()/registerExpoPushOrThrow()/unregisterExpoPush(), a dependency-injected helper for Expo apps usingexpo-notifications. Registers only the native device token (getDevicePushTokenAsync()); an Expo relay token (ExponentPushToken[...]) or a webPushSubscriptiontoken is rejected asunsupported_tokenrather than forwarded, since ContentFlow delivers through APNs/FCM/HMS directly. Does not touch consent — getting OS notification permission is not the same as ContentFlow push consent.
Migration: handle identify() rejection, use onEventsUncertain, and retain app fallbacks. Legacy unscoped caches are intentionally ignored because their workspace ownership is unknown. enableRequestSigning and enableCertificatePinning now reject at initialization: the JavaScript fetch transport never implemented those guarantees. Use a trusted proxy or native host integration when required. No public registry publication is implied by this source release.
enableOfflineQueue persists batches handed off while connectivity is already known offline. Ordinary pending, periodic and online 429 retry batches are memory-only and may be lost on process death; 1.2.0 does not claim a fully durable behavioral outbox.
Migration notes (1.2.0 behaviour changes)
fetchBlock()/getStrings()now reject on a misconfiguredbaseUrl. They used to resolve withundefined/ last-good strings. BecausesetLocale()awaitsgetStrings(), it now propagates that rejection too. Every other failure mode is unchanged. Background callers (poll tick, SSE refresh,start(), the ReactuseCFTranslationshook) swallow this by design — usesubscribeErrors()/onErrorto observe it there.getConsentConfig()now throws typed errors instead of a genericError('Failed to fetch consent preset: …'). CatchCFUnknownConsentPresetError/CFConsentConfigUnavailableError/CFMalformedResponseError(or match on.code) instead of parsing the message string.ConsentPresetgained real fields (preset/industry/region/optional/defaultTopics/availableTopics/legalFramework);id/nameare now deprecated aliases ofpresetrather than the primary fields — update code that reads.id/.nameas the canonical identifier.- Drop reports now carry
source. A listener that branched ondropReasonsalone still works; one that assumed every drop was server-reported should checksource !== 'server'before treating a report as a backend signal. Pre-consent tracking now produces a drop report where it previously produced silence — a listener counting drops will see new, expected entries for any call made before consent is granted.