ContentFlow JavaScript SDK 2.4.1

What's new in 2.4.1

What's new in 2.4.0

What's new in 2.3.0

What's new in 2.2.2

What's new in 2.2.1

What's new in 2.2.0

One package serves Web, React and React Native.

Install

The SDK ships as a release archive, contentflow-sdk-2.4.1.tgz, not through the npm registry.

  1. Download contentflow-sdk-2.4.1.tgz and the checksum file SHA256SUMS-2.4.1.txt (for example curl -fLO https://dev.contentflow.click/downloads/sdk/2.4.1/contentflow-sdk-2.4.1.tgz). Every version is listed on Releases and downloads.
  2. Put the archive in a folder you commit (for example vendor/), verify it, and install it:
Terminal
mkdir -p vendor && mv ~/Downloads/contentflow-sdk-2.4.1.tgz vendor/
shasum -a 256 vendor/contentflow-sdk-2.4.1.tgz   # must equal this file's hash in SHA256SUMS-2.4.1.txt
npm install ./vendor/contentflow-sdk-2.4.1.tgz

To upgrade later, download and verify the new archive the same way, run npm install ./vendor/contentflow-sdk-<new-version>.tgz, rebuild (and rebuild the native app for React Native), and read that version's Action needed notes in the changelog.

Entry points:

Use Import
Browser client: createCFClient, error classes, sensors @contentflow/sdk/web
React provider and hooks @contentflow/sdk/react
React Native provider, hooks, CFBanner, CFCampaignBanner @contentflow/sdk/react-native
Expo push helper @contentflow/sdk/expo
Full client with your own platform adapter (tests, Node scripts, custom runtimes) @contentflow/sdk/advanced
Website without a bundler @contentflow/sdk/embed (copy dist/web/embed.js)
Simple singleton facade: ContentFlow.init({ key: '<workspace-id>_test' }), browser platform only @contentflow/sdk

The root @contentflow/sdk entry exports only the ContentFlow facade and types: createCFClient, CFClient and the error classes come from /web, /react, /react-native or /advanced.

@contentflow/sdk/embed is a self-hosted ES module (there is no ContentFlow CDN). Copy node_modules/@contentflow/sdk/dist/web/embed.js into your site, then configure it through window.contentflow before the script loads:

HTML
<div data-cf-block="home_banner"></div>
<script>window.contentflow = { publicKey: '<workspace-id>_test', locale: 'en', consent: false };</script>
<script type="module" src="/assets/contentflow-embed.js"></script>

Every [data-cf-block="<key>"] element is filled with that block. Call window.ContentFlow.scan() after adding elements dynamically, and window.ContentFlow.setConsent(true) once the visitor agrees to analytics. From 2.2.2 the script tag's data-cf-key/data-cf-locale attributes are read too; in 2.2.1 they fail with missing data-cf-key, so use window.contentflow there.

There is no separate @contentflow/react-native package in this release. React is supplied by the host app. React Native also requires @react-native-async-storage/async-storage; install the version compatible with your app and rebuild its native projects. react-native-sse is optional: without an EventSource adapter, React Native uses polling. Supported React Native and Expo versions, the Metro setting React Native 0.72 to 0.78 needs, and troubleshooting are in the React Native and Expo guide.

Initialize and identify

Get the workspace's publishable SDK key in the dashboard under Developers → Your keys. The Environment toggle switches between the test key <workspace-id>_test and the live key <workspace-id>_app. Use the test key while developing (it also sees content published to Test) and the live key only in the production build; the server resolves the workspace and environment from the key, so tenantId is optional. Never put dashboard credentials, write keys or signing secrets into an app. There is no client-side flag that changes a live key into a test key.

TypeScript
import { createCFClient } from '@contentflow/sdk/web';

const client = createCFClient({
  publicKey: '<workspace-id>_test',   // <workspace-id>_app only in the production build
  // baseUrl omitted: see "Configuring the base URL". tenantId is optional (derived from the key).
  deviceId: EXISTING_INSTALLATION_ID, // preserve during migration; omit to generate/persist
  userId: YOUR_SIGNED_IN_USER_ID,     // omit for anonymous installs
  locale: 'ar',
  consent: false,                    // analytics consent, not push consent
  onEventsDropped: reportRejectedEvents,
  onEventsUncertain: reconcileUncertainDelivery,
});

try {
  const receipt = await client.identify({traits: {appLanguage: 'ar'}});
  // receipt.results?.identity reports identified, anonymous, or unchanged.
  await client.start();              // identify, cached paint, sync, strings, polling/SSE
} catch (error) {
  // Keep bundled UI fallbacks and show an integration error in your diagnostics.
}

The SDK API is served at https://api.contentflow.click/sdk/v1, the default from 2.2.1 (2.2.0 and earlier defaulted to the portal host https://app.contentflow.click/sdk/v1). Legacy bases still reach the same SDK API: the portal host https://app.contentflow.click/sdk/v1 works without a notice, and the older …/api/v1/sdk form works but logs a one-time "legacy path" notice. New apps use https://api.contentflow.click/sdk/v1. Set a base URL without a trailing operation such as /sync. New objects use their own state; maintain one client per active app/workspace/account. Await explicit identify() if startup failure must block your flow: start() keeps offline UI available and logs refresh failures.

Configuring the base URL

Prefer configuration over code, so a future host or version change needs no code change. Precedence: an explicit baseUrl > platform config > the default https://api.contentflow.click/sdk/v1.

The SDK never reads process.env (bundlers differ). Pass your build's value as baseUrl; if it's unset, the SDK falls through to the default:

TypeScript
createCFClient({ publicKey, baseUrl: import.meta.env.VITE_CONTENTFLOW_BASE_URL });        // Vite
createCFClient({ publicKey, baseUrl: process.env.NEXT_PUBLIC_CONTENTFLOW_BASE_URL });     // Next.js
createCFClient({ publicKey, baseUrl: process.env.EXPO_PUBLIC_CONTENTFLOW_BASE_URL });     // Expo

On a plain web page, set a global before the SDK loads. The SDK reads it itself:

HTML
<script>window.__CONTENTFLOW_BASE_URL__ = 'https://api.contentflow.click/sdk/v1';</script>

The global must be https; http is accepted only for localhost, 127.0.0.1 and 10.0.2.2. Any other value is ignored with a warning, and the default is used. An explicit baseUrl isn't validated. The base URL is never fetched remotely: a remote value could redirect your app's traffic and keys. A base that doesn't end in /sdk/v1 logs once: ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).

Live status

start() silently falls back to polling when there's no EventSource on the platform, and reconnects a dropped stream forever behind the scenes — none of that was visible without debug: true. Observe it directly instead:

TypeScript
client.subscribeLiveStatus((live) => {
  if (live.mode === 'poll') console.warn('not streaming:', live.reason);
});
const live = client.getLiveStatus(); // { mode, state, reason?, since, lastError? }

mode is 'stream' | 'poll' | 'off'; state is 'connecting' | 'connected' | 'reconnecting' | 'polling' | 'stopped'; reason explains it (stream_unsupported, stream_error, live_disabled, feature_disabled, backgrounded, disposed, invalid_key). subscribeLiveStatus fires immediately with the current status, then on every change; since only moves when mode/state/reason change, so "reconnecting since" survives a burst of failures. Also available as CFConfig.onLiveStatus and, for React/React Native, useLiveStatus() from @contentflow/sdk/react / @contentflow/sdk/react-native. None of this depends on debug.

Workspace feature switches

A ContentFlow platform operator can switch built-in SDK features off for one workspace or for every workspace (for example during an incident). The switches arrive on /sync as config.features, so there's no extra request, and the SDK honours them:

Switch When it's off
live_updates No SSE stream. Content refreshes on the ETag poll tick. getLiveStatus().reason is feature_disabled.
campaigns getCampaigns() resolves [] without a network request.
location Location tracking pauses and unsent points are dropped. Geofence events aren't sent.
frequency_caps The server applies no popup frequency rule and shouldShow is always true.
content_sync The server keeps the content you already have (a 304), or answers an empty list on a first install, so your built-in defaults show. Nothing changes in the SDK.
strings The server keeps the strings you already have (a 304), or answers an empty catalog, so your bundled strings show.
analytics_events Events are dropped on the device, never sent or stored; subscribeEventDrops reports them with dropReasons.feature_disabled. Identity events (trackEvent) are dropped by the server.
push_preferences Topic and preference changes are accepted but not stored. Push token registration keeps working.
geofences The server sends no fences to monitor.
profile_updates The device is still registered and erasure is still enforced, but nothing is written to the customer's profile; the device keeps its last known segments.
TypeScript
client.getFeatures(); // { liveUpdates, campaigns, location, frequencyCaps, contentSync, strings, analyticsEvents, pushPreferences, geofences, profileUpdates }
client.subscribeFeatures((f) => console.log('features', f)); // or CFConfig.onFeaturesChange

Anything missing or malformed counts as on, which is how the SDK behaved before the switches existed. A 304 keeps the last value, and the value is cached with the content so it survives a relaunch. When a switch turns back on, the SDK resumes only what your app had started (live: true, an active startLocationTracking). A switch never starts a feature, and never changes the base URL, keys, authentication, certificate pinning or consent. Location still needs analytics consent.

Error handling

Reads that used to fail silently are now typed and observable. A misconfigured baseUrl — the response doesn't look like a ContentFlow delivery API — surfaces as CFInvalidBaseUrlError (code: 'invalid_base_url') on every relevant surface at once, never re-wrapped:

TypeScript
import { createCFClient, CFInvalidBaseUrlError } from '@contentflow/sdk/web'; // also exported by /react and /react-native

const client = createCFClient({ publicKey: '<workspace-id>_test', onError: (error) => report(error) });
client.subscribeErrors((e) => report(e));            // e.code === 'invalid_base_url'
client.getLiveStatus().lastError;                     // the same instance, read back later

try {
  await client.fetchBlock('home_banner');
} catch (e) {
  if (e instanceof CFInvalidBaseUrlError) showSetupHelp(e.baseUrl);
}
Call on a wrong baseUrl
fetchBlock() rejects with CFInvalidBaseUrlError
getStrings() rejects, after cold-start fallback tiers are applied
getConsentConfig() rejects (as before)
sync() / start() resolve with last-good content, and report the error

Only this named diagnosis is reported this way — an ordinary transient outage (a 503, a timeout) keeps last-good content and is logged once per episode, not surfaced through onError, or every poll tick would fire it. sync() never throws, because it's also the poll tick. subscribeErrors does not replay past errors on subscribe; read getLiveStatus().lastError for the last one.

Rejected SDK key

The SDK treats its key as dead on an HTTP 401 (any code, or none) or on a 403 whose code (top-level code, or nested error.code) is INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH. Any other 403 is a per-route refusal and leaves the key working, for example DEVICE_NOT_IDENTIFIED, USER_MISMATCH, an IDENTITY_* code, a location-consent refusal, or a 403 with no code.

When the key dies, the client:

TypeScript
import { createCFClient, CFInvalidKeyError } from '@contentflow/sdk/web';

const client = createCFClient({
  publicKey: '<workspace-id>_test',
  onError: (e) => {
    if (e instanceof CFInvalidKeyError) alertOps(`SDK key rejected (${e.serverCode}); dropped ${e.droppedCount}`);
  },
});

The dead state lives in memory only and is never persisted. To resume, create a new client with a corrected key: call dispose() and then createCFClient(...), remount <CFProvider> with the new config (for example by changing its React key), or call ContentFlow.dispose() and then ContentFlow.init({ key }). Restarting the app also clears it. A key can't be changed on a live client.

Retries and queue limits

Every other write/read failure the SDK raises is one of: CFError (base class, has .code), CFInvalidKeyError, CFWriteFailedError, CFPartialWriteError, CFUnknownConsentPresetError, CFConsentConfigUnavailableError, CFLocalesUnavailableError, CFMalformedResponseError — match on instanceof or .code rather than the message text.

Test your integration

  1. Use the test key <workspace-id>_test (Developers → Your keys, Environment: test). It sees live content plus content published to Test, so publish the block you are testing to Test in the dashboard.
  2. Check the key and content before touching app code:
Terminal
curl -s -H 'X-CF-Key: <workspace-id>_test' 'https://api.contentflow.click/sdk/v1/sync?locale=en'
# {"success":true,"data":{"blocks":[...]}}   an empty blocks array means nothing is published for this key yet
  1. Turn on logging and watch the SDK:
TypeScript
import { createCFClient } from '@contentflow/sdk/web';

const client = createCFClient({
  publicKey: '<workspace-id>_test',
  debug: true,
  onError: (e) => console.error('ContentFlow', e),
  onEventsUncertain: (info) => console.warn('delivery uncertain', info),
});
client.subscribeEventDrops((d) => console.warn('dropped', d.source, d.dropReasons));
client.subscribe((blocks) => console.log('blocks', blocks.map((b) => b.key)));

await client.start();
console.log(client.getLiveStatus());              // { mode: 'stream' | 'poll' | 'off', ... }
await client.setConsent(true);                    // test build only; events are dropped without it
const banner = client.getBlock('home_banner');
if (banner) client.trackImpression(banner);
await client.flush();
  1. Common problems:
Symptom Cause
dropReasons.consent_not_granted, source: 'local' Analytics consent was never granted: call setConsent(true) from your consent UI
dropReasons.device_not_identified, source: 'server' Identify failed or never ran for this device; check the error log
CFInvalidBaseUrlError baseUrl is not a ContentFlow API; remove it or use https://api.contentflow.click/sdk/v1
blocks is empty Nothing is published for this key's environment, the instance targets a segment this device is not in, or the workspace id in the key is mistyped
Dashboard impression counters do not move Events sent with a _test key are accepted but never change the production block counters
TypeScript
// Use your app's recorded user choices; do not blindly grant either consent.
await client.setConsent(analyticsGranted);
await client.setChannelConsent(YOUR_SIGNED_IN_USER_ID, {push: pushGranted});
const deviceConsent = await client.setChannelConsent({push: pushGranted});

// Obtain the OS token and notification permission through APNs/FCM/HMS in your app.
await client.registerPush({token: OS_PUSH_TOKEN, platform: 'ios', provider: 'apns'});
const linkage = await client.registerPushWithReceipt({token: OS_PUSH_TOKEN, platform: 'ios', provider: 'apns'});
// Call again on token refresh. Registration does not grant push consent.

// On withdrawal, confirm the server-side channel revocation first.
await client.setChannelConsent(YOUR_SIGNED_IN_USER_ID, {push: false});
await client.unregisterPush();       // idempotent, removes only this installation's token

// On sign-out, unlink identity and remove this device's profile token together.
await client.logout();

// Right to erasure (GDPR / PDPL): erase this installation's identity.
const erased = await client.deleteIdentity(); // { deviceId, status: 'deleted' }
// The client is now disposed; create a new one (it gets a new device id).

Android uses platform: 'android', provider: 'fcm'. Huawei uses provider: 'hms' with hmsConfig.appId; Web uses provider: 'web_push'. The SDK does not create APNs/FCM/HMS credentials, ask for OS permission, deliver background messages, or register a browser service worker. Configure those in the host app. Browser subscription/provider setup is application-owned.

setChannelConsent(username, channels) writes profile consent; setChannelConsent(channels) writes device-only consent and returns the typed device/consent receipt. Device-level analytics consent and channel consent are independent. registerPushWithReceipt exposes the server linkage status while the compatibility registerPush method still resolves to void. Unregistering or logging out does not revoke channel consent. Every failed write rejects, including HTTP 200 containing success:false; keep the last confirmed setting until a write succeeds. A timeout cannot prove whether a write reached the server, so reconcile/retry idempotent consent/token operations.

Before signing a different user into the installation, inspect the receipt from setUserId(newUserId); identified, anonymous, and unchanged are distinct outcomes. On sign-out, call logout() with the same client/device. A 207 PUSH_LINKAGE_INCOMPLETE may still prove the identity is anonymous or identified; the SDK keeps that confirmed identity state and throws a partial_write error whose receipt identifies the incomplete push step. A network/500 outcome clears old personalized content and events but does not claim durable detachment. Account changes and same-user trait/segment changes invalidate every locale/namespace generation before late reads can land. Never reuse one client between server-side requests from different people.

deleteIdentity() sends DELETE /identify, which erases the device record, its linked user id and its analytics events. Analytics collection stops before the request goes out (consent is revoked locally and pending events, in memory and offline, are dropped) and stays stopped whatever the outcome. Only a confirmed complete erasure wipes local state (persisted consent, channel consent, segments, audience scope, the visitor id and the device id) and disposes the client; the facade ContentFlow.deleteIdentity() also resets itself, so call ContentFlow.init() again. A partial or unconfirmed erasure throws a write_failed error and keeps the device id, because the server can only address what is left by that id: retry deleteIdentity() with the same client.

updateIndustryProfile({ industry, profile }) requires a signed-in user and is stored as that user's domain profile (POST /profile with { userId, category: industry, data: profile }, kept under customAttributes.domainProfile.<industry>, replacing that industry's previous value). It is write-only: getIndustryProfile() is deprecated and always resolves to null without a request, because there is no read-back route, by design. submitKYC() and getKYCState() call routes the delivery API does not expose yet; do not rely on them.

A server can suppress a recipient without another app launch once it has received a revocation through a supported server/dashboard channel. It cannot infer a choice made only on an offline device. Push-provider credentials, dispatch-time revocation and on-device receipt require deployment/device verification outside SDK unit tests.

GET /consent/config/{preset} (relative to the SDK base) answers the shape a consent screen for one regulated industry should offer — which channels are required versus optional, the default messaging topics, and the legal framework it's built against. There are exactly seven valid ids, and no default: banking_ksa, insurance_ksa, healthcare_ksa, travel_ksa, retail_ksa, food_delivery_ksa, fintech_ksa.

TypeScript
import { consentPathsToConfig } from '@contentflow/sdk/web'; // or /react, /react-native

const preset = await client.getConsentConfig('banking_ksa');
// { preset: 'banking_ksa', industry: 'banking', region: 'SA',
//   required: ['channels.push'], optional: ['marketing.promotional'],
//   defaultTopics: [...], availableTopics: [{id, name, nameAr?, required}, ...],
//   legalFramework: 'SAMA + PDPL' }

// Render preset.required / preset.optional in your consent screen, then write ONLY what the user chose:
const chosen = [...preset.required, ...preset.optional].filter((path) => userAccepted(path));
await client.requestConsent({ preset: preset.preset, ...consentPathsToConfig(chosen) });

required/optional are dotted paths into the consent state (channels.push, marketing.transactional, marketing.serviceUpdates, marketing.promotional, marketing.thirdPartyOffers, location) — the same paths client.hasConsent(path) takes, not booleans or channel names. consentPathsToConfig(paths, value?) turns a list of them into the nested ConsentConfig ({ channels, marketing, location }) that requestConsent takes; an unrecognised path is ignored rather than guessed at. It is not the input for setConsent (a boolean) or setChannelConsent (flat { push, sms, … }).

Reading a preset never grants, revokes, or otherwise changes anyone's consent — that only happens through an explicit write call. id/name are deprecated aliases of preset, kept so 1.1.0 callers keep working. An unknown preset id throws CFUnknownConsentPresetError (carries preset and availablePresets); a service failure throws CFConsentConfigUnavailableError; a malformed 200 body throws CFMalformedResponseError.

Content, campaigns and localization

TypeScript
const blocks = await client.sync();
const welcome = client.getBlock('welcome');
const title = welcome?.get('title') ?? 'Welcome';
const cards = welcome?.getCollection('cards') ?? [];
const campaigns = await client.getCampaigns('home_banner');
await client.getStrings('home');
const label = client.t('welcome', 'Welcome');
await client.setLocale('ar');

The server resolves locale using ?locale= and Accept-Language. The SDK sends both. ?lang= and X-CF-Locale are not the SDK contract; unsupported aliases may be warned about by the server. Campaign evaluation uses public POST /campaigns; locale-keyed campaign content resolves exact locale, then neutral language (ar-SA to ar), then the document's English fallback. Sync and strings have independent validators; strings additionally isolate namespaces. A valid payload without an ETag is processed and clears the previous validator. HTTP 304 uses only the matching cached payload. Failed/malformed reads retain last-good content. Switching locale clears the previous locale's view so it cannot masquerade as the requested language during an outage. When Arabic is absent or withheld, the server can return workspace source strings or block base source fields, including English. An app-supplied string fallback applies only when the delivered key is absent; it does not replace server-supplied source text. The SDK never invents a translation.

Block values are keyed by tags such as #title. get('title') normalizes the prefix. Arbitrary values and collection cards remain available to app-owned renderers. Campaign placement and segmentation are evaluated by the server.

Re-render when the dashboard publishes a change (delivered over SSE, or by polling):

TypeScript
const unsubscribe = client.subscribe(() => {
  const banner = client.getBlock('home_banner');
  if (banner) renderBanner(banner);   // your renderer
});

React

TSX
import { CFProvider, useCFBlock, useCFString } from '@contentflow/sdk/react';

const config = { publicKey: '<workspace-id>_test', locale: 'en', consent: false };

export function App() {
  return <CFProvider config={config}><Home /></CFProvider>;   // starts and disposes the client for you
}

function Home() {
  const banner = useCFBlock('home_banner');   // re-renders on live updates; fires one impression
  const title = useCFString('home.title', 'Welcome');
  return <h1>{banner ? String(banner.get('title') ?? title) : title}</h1>;
}

useCFBlock(key, { trackImpression: false }) turns the automatic impression off. Other hooks: useCF (the client), useCFBlocks, useCFCampaigns, useCFTranslations, useCFTheme, useLiveStatus.

React Native and Expo

TSX
import EventSource from 'react-native-sse';                     // optional: live stream instead of polling
import Geolocation from '@react-native-community/geolocation';  // optional: only for device location
import { CFProvider, CFBanner } from '@contentflow/sdk/react-native';

export function App() {
  return (
    <CFProvider config={{ publicKey: '<workspace-id>_test', locale: 'ar' }} eventSource={EventSource} geolocation={Geolocation}>
      <CFBanner blockKey="home_banner" onPress={(block) => openLink(String(block.get('cta_url') ?? ''))} />
    </CFProvider>
  );
}

React Native also exports CFCampaignBanner and the same hooks as React. For version support and Metro setup, see the React Native and Expo guide.

fetchBlock scoped overrides

fetchBlock(key, { locale?, deviceId? }) reads one block for another locale or another install — a preview screen, a "see it in Arabic" toggle, a support tool — without the global side effects of setLocale() (which re-syncs the whole app and re-renders every screen):

TypeScript
const arabic = await client.fetchBlock('home_banner', { locale: 'ar' });
const asUser = await client.fetchBlock('home_banner', { deviceId: supportTicket.deviceId });

An override is sent for that one request only: it never assigns this.locale, the device id, the audience scope or the identity, and is never written into the block cache, the sync list, or the persisted cache — the next ordinary request carries the install's own identity again. Called with no options, the call is byte-for-byte what it always was.

Locales

client.getLocales() lists every locale the workspace has configured, so a client can build a language picker without hardcoding one:

TypeScript
const { locales, sourceLocale } = await client.getLocales();
const offerable = locales.filter((l) => l.coverage >= 80);
client.getCachedLocales(); // last successfully-read catalog, or undefined if never read

Each entry carries code, name, native, rtl, isSource, and coverage (a locale can be configured at 0% coverage — it's a promise the workspace intends to serve that language, not that any string in it is translated yet). It's ETag-revalidated with its own validator, independent of the sync/strings validators, and deliberately survives identity changes — which languages a workspace publishes doesn't depend on who's logged in. An empty locales list is a valid answer. A server with no /locales route, or a LOCALE_CATALOG_UNAVAILABLE failure, throws CFLocalesUnavailableError — the SDK never invents en/ar in its place.

Show in-app messages

In-app campaign sends and journey in-app steps arrive on sync() as blocks with the key journey_message (or the placement you chose for the campaign or journey), one per message (instanceId jm_<id>; the jm_ prefix, not the key, is what marks a message), newest first, up to 20. Each carries #title, #body, and, when set, #image and a #cta_label / #cta_url button (web links only; #cta_visible is false without one). They show on the next sync (start, foreground, the pollMs timer or sync()); live updates don't announce them. fetchBlock('journey_message') doesn't return them.

TypeScript
const seen = new Set<string>();
client.subscribe(() => {
  const messages = client.getBlocks('journey_message');   // getBlock() would return only the newest
  renderInbox(messages);                                   // title, body, imageUrl, ctaVisible, ctaLabel
  for (const m of messages) if (!seen.has(m.instanceId)) { seen.add(m.instanceId); client.trackImpression(m); }
});

function onButton(m: CFBlock) {
  client.trackCtaClick(m);   // counts the click once and stops later syncs delivering it
  openLink(String(m.get('cta_url') ?? ''));
}
const onClose = (m: CFBlock) => client.trackDismiss(m);   // stops later syncs delivering it

trackDismiss, trackCtaClick and trackTap on a message hide it on the device straight away: getBlocks, getBlock, subscribe, sync() and useCFBlocks leave it out from then on, with or without analytics consent (the event itself is still sent only with consent). The SDK keeps those instance ids for 8 days and forgets them when the user changes, on logout(), deleteIdentity() and resetFrequency(). Messages expire at the campaign's end, or after 7 days. client.getRawBlock(m.instanceId)?.source is campaign or journey. In React, useCFBlocks('journey_message') returns the same list. Details: SDK guide: Show in-app messages.

Session id

Each session has one random id (a UUID, or sess-<base36> where crypto.randomUUID is unavailable; memory only, no personal data). The SDK sends it as X-CF-Session-Id on sync, single-block and campaign reads and as context.sessionId on every event batch, so the server's once-per-session popup rules and your session funnels see the same session. A new session starts at cold start, after 30 minutes in the background (web, React Native and Expo alike), and on logout(), deleteIdentity() and resetFrequency(): the same moment the popup frequency ledger starts a new session. Events keep the session they were queued in.

New in 2.4.0. Server-side caps also apply to older SDK versions. Full guide: Popup frequency and limits.

How often a popup shows is set by the marketer on the block instance or in-app campaign (presets such as "Once per day", plus workspace-wide popup limits), and enforced by the server and by a local ledger in the SDK. Ask for the one popup to show on a screen and report what the customer did with it:

TSX
// React: useCFPopup from @contentflow/sdk/react
const { popup, dismiss } = useCFPopup('home'); // impression tracked once shown
TypeScript
const popup = await client.getPopup('home'); // { kind: 'block', block } | { kind: 'campaign', campaign } | null
client.trackImpression(block);         // or trackCampaignImpression(campaign)
client.trackDismiss(block);            // or trackCampaignDismiss(campaign)
client.shouldShow(block);              // one item, for non-modal surfaces
client.frequencyReason(block);         // 'capped_impressions' | 'cooldown' | 'workspace_guard' | ... | null
client.resetFrequency();               // for debug builds: forget local counts

The SDK sends the frequency-v1 capability. Custom frequency rules and changing the workspace popup limits (defaults: 1 per session, 3 per day, 30 minutes apart) need a Pro or Enterprise plan. With the workspace frequency_caps switch off, shouldShow is always true. Full guide: Popup frequency and limits.

Built-in campaign popup

New in 2.4.0. In-app campaigns can be drawn by the SDK. New in 2.4.1: block popups that use the standard fields are drawn by the same card.

TypeScript
// Web: createCFClient / ContentFlow.init install the built-in popup.
const cf = createCFClient({
  publicKey: '<workspace-id>_app',
  tenantId: '<workspace-id>',
  popups: {
    autoPresent: true,          // default false: nothing shows unless you opt in
    delayMs: 500,               // 0..60000
    theme: { accentColor: '#0a7d4f', cornerRadius: 12 },
    onAction: (item, url) => { router.open(url); return true; }, // campaign or block; true: handled, else the SDK opens it
    onBlockPopup: (block) => showMyModal(block),   // optional: draw (and track) blocks yourself
    blockPopups: 'builtIn',                        // default; 'off' never draws blocks (2.4.0 behaviour)
  },
});
cf.screenChanged('home');                       // presents after delayMs, cancelled by a newer screen
const result = await cf.presentPopup('cards');  // or present explicitly: { shown, kind, id?, reason? }
TSX
// React (@contentflow/sdk/react) and React Native / Expo (@contentflow/sdk/react-native)
<CFPopupHost screen={routeName} autoPresent onAction={(c, url) => openDeepLink(url)} renderBlock={(block, close) => <MyModal block={block} onClose={close} />} />

One popup at a time. The SDK tracks the impression and then exactly one of cta_click or dismiss (close button, backdrop, Escape, Android back). CTA URLs open only for http, https and app schemes (javascript:, data:, file:, vbscript:, blob: and about: are refused, and the cta_click is still tracked). Campaign images load only from https. Arabic and other right-to-left locales lay out RTL. Draw campaigns yourself with popups.renderCampaignPopup(campaign, { cta, dismiss }) (or the renderCampaign prop) and keep the automatic tracking.

Block popups. When a block wins the slot and you have no onBlockPopup (or renderBlock on <CFPopupHost>), the SDK draws it with the same card if it has a non-empty #title or #body. It reads #title, #body, #image (https only), #cta_label and #cta_url; #cta_visible: false hides the button. The result is { shown: true, kind: 'block', id: <instanceId> }, and the SDK tracks it on the block path (trackImpression, then trackCtaClick or trackDismiss), so frequency rules and closed in-app messages work as for app-drawn blocks. onAction gets the block (tell it apart with 'campaignId' in item). Your onBlockPopup / renderBlock always wins, a block without #title / #body is not_handled, and renderCampaignPopup never applies to blocks. Set popups.blockPopups: 'off' (or blockPopups="off") to keep blocks app-drawn only. A custom CFPopupPresenter now also receives blocks: check view.kind before reading campaign fields.

Block CTA links (from 2.4.1). A block drawn in the built-in card follows the campaign link rules: only http, https and app custom schemes are passed on, and the cta_click is always tracked. The handler that gets the link differs per platform: JavaScript onAction(item, url) (item is the block), Flutter onAction(item, url) (item is a CFBlockActionContext, item.block), Kotlin and Swift onBlockAction(block, url) (onAction stays campaign-only there). Return true when handled; otherwise the SDK does what it does for a campaign. One extra rule on Kotlin and Swift: if you set onAction but not onBlockAction, a block link with an app custom scheme (myapp://...) is not opened by the system, because your app routes its own scheme; http and https links still open. Set onBlockAction to route block deep links.

Sensors and analytics

TypeScript
if (welcome) {
  client.trackImpression(welcome);                              // only when it is actually visible
  client.trackTap(welcome);
  client.trackCtaClick(welcome, { destination: 'offers' });
  client.trackConversion(welcome, { orderId: 'ORD-123' });
}
if (campaigns[0]) client.trackCampaignImpression(campaigns[0]); // only after rendering a real campaign

// Business events are category:action names; pass {} when no block is involved
client.track('commerce:purchase', {}, { amount: 99, currency: 'SAR', reference: 'ORD-123' });

// Identity events have their own endpoint
await client.trackSignIn({ username: YOUR_SIGNED_IN_USER_ID });

await client.flush();

track(type, block, custom?, extra?) takes the event name, the block it belongs to ({} for none), and your properties. trackSignUp({ username, fullName?, email?, phone? }) and trackSignIn({ username }) identify the device as that user and record the event; passing sign_up/sign_in to track throws.

Use real rendered objects and guard missing content in application code. Automatic string usage signals are gated by delivered smartKeys; Web also supports bindStringSensors for viewport/interaction bindings. Native host visibility is application-owned; React hooks report usage/mount, not proof that the user saw a pixel. Analytics require explicit recorded analytics consent.

Before analytics consent is granted, the SDK does not collect, queue, retain or later replay events. Nothing is held back to be sent once consent arrives: a consent grant is not retroactive, and an event the user had not yet agreed to is not one the SDK gets to keep.

That refusal used to be a bare, silent return — an integrator seeing no analytics couldn't tell a missing setConsent(true) from a wrong key or a wrong baseUrl. Every pre-consent refusal is now reported through the existing drop machinery with a discriminator:

TypeScript
client.subscribeEventDrops((info) => {
  // { accepted: 0, dropped: 1, total: 1, allDropped: true,
  //   eventTypes: ['tap'], dropReasons: { consent_not_granted: 1 }, source: 'local' }
});

EventDropInfo.source / EventsDroppedInfo.source is 'local' | 'server': 'local' also marks the SDK's other own-side drops (queue full, identity changed before delivery, consent revoked); a server-reported drop says 'server'. Reports from older SDKs carry no source — its absence means nothing either way. Covers every entry point: track, trackImpression, trackTap, trackConversion, trackCtaClick, trackDismiss, the trackCampaign* family, the industry helpers, and trackString. Drops for disposed and an in-flight identity change stay silent on purpose (the identity transition already emits its own report).

Identity and consent writes are sent in public API invocation order. An analytics revocation takes effect locally before the request: it stops new collection and purges pending in-memory and persisted known-offline events even when the device is offline or the server write later fails. Treat that failure as a server reconciliation requirement while keeping local collection disabled. An analytics grant becomes active only when the identify response explicitly confirms consent: true; a server consent: false echo remains authoritative, and an older in-flight grant cannot restore consent after a newer revocation.

Batches are bounded to 100 by eventBatchSize. A partial aggregate ACK reports accepted, dropped, and arbitrary dropReasons keys, then consumes the batch because the response cannot identify which items are safe to retry. If accepted + dropped does not equal the submitted count, onEventsUncertain receives unaccounted; the whole ACK is still consumed to avoid replaying accepted items. device_not_identified means the server could not establish device state; it does not prove the durable profile is absent. A production HTTP 202 currently means acceptance into the event pipeline, not confirmed durable storage. Local impression deduplication is per client session; no server exactly-once guarantee exists. Custom analytics names preserve case but empty/control-character names and sign-up/sign-in names routed through /events are rejected; identity events use trackEvent/trackSignUp/trackSignIn.

Default eventRetryPolicy: 'report' reports timeout/network/5xx uncertainty with onEventsUncertain and does not automatically replay the batch. Retain that diagnostic in your app's reconciliation flow. eventRetryPolicy: 'at-least-once' explicitly permits retry and possible duplicate counters; use it only with that tradeoff accepted. Explicit 429 refusals remain queued in memory for this client session.

With enableOfflineQueue: true, batches handed off while connectivity is already known offline are written to the scoped storage outbox before they are consumed from the behavioral queue; storage denial falls back to memory with a warning. Ordinary pending sub-batches, periodic-flush batches and online 429 retry batches remain memory-only and can be lost on process death. This SDK does not claim a fully durable event outbox. Capacity, expiry and retry-limit removals are reported through onEventsDropped. Browser termination may prevent a final network flush; call flush() at meaningful app boundaries. This is not a financial audit log.

When the platform reports background state, the client closes SSE, stops polling and pauses periodic event flushes after requesting one boundary flush. Foreground state forces one blocks + strings reconciliation before polling and SSE resume. dispose() aborts outstanding requests, stops polling/stream/lifecycle work, and requests a final best-effort event flush. Await flush() first when you must observe the completed attempt. Do not enqueue more events after disposal.

Device location

TypeScript
const fix = await client.requestLocation({ mode: 'balanced' });      // one fix
client.startLocationTracking({ mode: 'balanced', intervalMs: 30_000, distanceFilterMeters: 50, batchSize: 10, maxBatchAgeMs: 60_000 });
await client.stopLocationTracking();

The OS permission prompt appears only when you call requestLocation or startLocationTracking, never on init; ask for it from a screen that explains why. Web uses navigator.geolocation (HTTPS only). React Native core has no geolocation API, so pass one with the same shape, e.g. <CFProvider config={config} geolocation={Geolocation}> with import Geolocation from '@react-native-community/geolocation' (it handles the platform permission request), or createRNPlatform({ geolocation }). A global navigator.geolocation polyfill is also picked up. high_accuracy requests GPS; other modes let the platform choose. Fixes are batched to POST /location. Location is gated on analytics consent: without it nothing is collected or uploaded. Revoking consent, deleteIdentity() and dispose() stop tracking and discard unsent points (erasure waits for any in-flight upload before it is sent); restart tracking after consent is granted again. When the signed-in user changes, points collected for the previous user are sent as that user first, never under the new one.

App-defined fences (registerGeofence with ids the ContentFlow dashboard doesn't have) are evaluated on the device, but their reports no longer reach analytics: the server only records events for fences the workspace defines. For dashboard fences, use on-device geofences.

On-device geofences

New in 2.3.0. The workspace's geofences are registered with the OS geofencing service, so the OS wakes the app on an enter, exit or dwell and the SDK reports it at once. No continuous GPS, so no battery drain from tracking. The server also evaluates uploaded location; it dedupes, so one stay fires once whichever side sees it first.

Where it works

Setup (Expo)

TypeScript
// App entry, at module top level: Expo requires tasks to be defined there.
import * as TaskManager from 'expo-task-manager';
import { defineContentFlowGeofenceTask } from '@contentflow/sdk/expo';
defineContentFlowGeofenceTask(TaskManager);
TypeScript
// Later, after consent and after the app has the OS "Always" location permission:
import * as Location from 'expo-location';
import { Platform } from 'react-native';
import { startExpoGeofencing, stopExpoGeofencing } from '@contentflow/sdk/expo';

const result = await startExpoGeofencing(client, Location, { platform: Platform.OS });
// { status: 'started' } | { status: 'task_not_defined', taskName } | { status: 'failed', error }
await stopExpoGeofencing(client, Location); // with Location: also stops regions an earlier launch left

With your own monitor: await client.startGeofencing({ monitor, reservedAppRegions }) and await client.stopGeofencing() (pass { monitor } in a launch that never started geofencing, so the SDK can reach regions an earlier launch left); client.isGeofencing says whether regions may be registered right now.

Permissions the app must request (the SDK never prompts):

Without background permission, geofencing stays idle and is retried on the next foreground.

Consent and switches. Geofencing runs only while all of these hold: the app called startGeofencing, analytics consent is granted, location consent is explicitly granted (setChannelConsent({ locationTracking: true }), the device record, which is kept across launches; a revocation stops it immediately; unset counts as not granted), and the workspace location switch is on. When one of them turns off, every SDK-owned region is removed and unsent transitions are dropped; when it turns back on, geofencing resumes, because the app had asked for it. The server is authoritative for location consent too: it returns no fences without it (that is what enforces location consent; the server's /location consent refusal is keyed on analytics consent). stopGeofencing(), deleteIdentity() and dispose() remove the regions. OS registrations outlive the app process, so the first startGeofencing in each process clears every SDK-owned region before registering (a custom monitor's clearRegions must remove SDK regions the OS still holds from an earlier launch, not only the ones it registered itself), and every teardown clears them too, even when this process registered nothing.

How it behaves

Strings as code (write key, server/CI only)

TypeScript
const result = await client.registerStrings(
  [{ key: 'home.title', defaultValue: 'Welcome', context: 'Home screen heading' }],
  { writeKey: process.env.CF_WRITE_KEY! },
); // { registered, updated, skipped }

Registers localization source strings, like registerBlock does for blocks. Also on the facade as ContentFlow.registerStrings. It posts { strings: [{ key, sourceText, description? }] } to /api/v1/strings/sync with X-CF-Write-Key; the API root is derived from baseUrl (…/sdk/v1 or …/api/v1/sdk → …/api/v1), the same as the Swift, Kotlin and Flutter SDKs. It throws on any failure so a CI step fails. Never ship a write key in an app; the SDK warns if this runs in a browser or React Native.

Stop tracking keys you no longer want sensor data for:

TypeScript
const { disabled, alreadyDisabled, unknown } = await client.disableStrings(
  ['legacy.promo_title', 'legacy.promo_body'],
  { writeKey: process.env.CF_WRITE_KEY! },
);

disableStrings stops tracking these keys; their text is still served to apps. It turns their sensors off, so the SDK stops sending string events for them (they drop out of smartKeys on the next strings refresh) and the server rejects any that still arrive. Also on the facade as ContentFlow.disableStrings. It posts { keys } to /api/v1/strings/disable with X-CF-Write-Key (same API root as registerStrings) and resolves to { disabled, alreadyDisabled, unknown } (StringDisableResult). It takes 1 to 500 non-empty keys of at most 200 characters; duplicates are dropped, and anything else throws before a request is sent. Like registerStrings, it needs the write key and throws on any failure. Tracking is turned back on in the portal (Sensors → Enable sensors); there is no SDK call for it, and registerStrings never changes whether a key is tracked.

Stored data and secure storage

The SDK persists the device id, visitor id, analytics consent, channel consent, segment membership and content caches in the platform storage (localStorage on the web, AsyncStorage on React Native). With enableSecureStorage (the default), analytics consent, channel consent and segment membership are stored XOR-obfuscated under cf_sec_-prefixed keys. This is obfuscation against casual inspection, not encryption: the key is derived from the device id, which itself stays in plain storage because it is the key's root. Storage key names still embed the workspace, device and user ids, and content caches are not obfuscated. For real protection, supply a platform storage adapter backed by the OS keychain/keystore.

Values move automatically: an install upgraded from a build that stored them plainly, or one that switches enableSecureStorage off or on, reads the value from wherever it is, rewrites it in the current form, and removes the old copy only after the new write succeeded. The device id is never moved or regenerated by this. Runtimes without a SHA-256 implementation (crypto.subtle) keep using plain storage and log a warning.

Theming

A typed color-field reader and a small theme helper built on the existing block model — not a UI framework, no components, no CSS-in-JS, no design-token system. Turns a block's color values into typed CFColor objects (hex, rgba, raw):

TSX
import { useCFTheme } from '@contentflow/sdk/react'; // or '@contentflow/sdk/react-native'

function AppShell() {
  const { theme, loading } = useCFTheme(
    'brand_theme',
    { primary: '#brand_primary', accent: '#brand_accent' },
    { fallback: { primary: '#5b57e0', accent: '#f0654a' } },
  );
  if (loading) return null;
  return <div style={{ '--cf-primary': theme.colors.primary?.hex } as React.CSSProperties}>{/* ... */}</div>;
}

Core functions are pure and synchronous: parseCFColor(value) parses one color value (hex #rgb/#rgba/#rrggbb/#rrggbbaa, rgb()/rgba() in comma or modern space form, or a defensively-unwrapped {hex: '...'}/{value: '...'} object) and never throws — anything it can't parse returns undefined. getBlockColor(block, tag) reads one color field by tag; getBlockColors(block) returns every color on the block whose tag ends with _color/_colour or starts with #color_. themeFromBlock(block, mapping, fallback?) maps named theme slots to block tags, falling back to a supplied hex per slot; block may be undefined and it still returns a stable shape. useCFTheme is built directly on useCFBlock, so it follows the same live-update/cache semantics, and does not itself fire an impression event (reading a theme isn't the same as rendering the block's content).

Expo push (@contentflow/sdk/expo)

Optional helper for Expo/React Native apps using expo-notifications. Dependency-injected — this subpath never imports expo-notifications itself, so it builds and typechecks with nothing installed; you pass your own copy of the module in.

TypeScript
import * as Notifications from 'expo-notifications';
import { registerExpoPush, unregisterExpoPush } from '@contentflow/sdk/expo';

const result = await registerExpoPush(client, Notifications, { requestPermission: true });
switch (result.status) {
  case 'registered': /* result.provider, result.receipt */ break;
  case 'permission_denied': /* result.canAskAgain */ break;
  case 'permission_not_requested': break;
  case 'unsupported_token': break; // e.g. web, or an Expo relay token
  case 'failed': /* result.error */ break;
}

await unregisterExpoPush(client); // later — sign-out, notifications disabled, etc.

Expo's push stack produces two token kinds; only one is usable by ContentFlow:

Expo push token Native device token
From Notifications.getExpoPushTokenAsync() Notifications.getDevicePushTokenAsync()
Shape { type: 'expo', data: 'ExponentPushToken[...]' } { type: 'ios' | 'android', data: '<APNs/FCM token>' }
Delivered by Expo's own push relay service APNs (iOS) / FCM (Android) directly
Accepted by ContentFlow No Yes

registerExpoPush only ever calls getDevicePushTokenAsync(); it never calls getExpoPushTokenAsync(), and rejects an ExponentPushToken[...]-shaped token as unsupported_token if one turns up, since ContentFlow's backend delivers through APNs/FCM/HMS directly, not Expo's relay. A web (PushSubscription-shaped) token is also rejected — that's the separate web_push provider. HMS (Huawei) devices aren't reachable through Expo at all: there's no Expo API for a Huawei Push Kit token, so register those with client.registerPushWithReceipt({ provider: 'hms', hmsConfig: { appId }, ... }) directly.

Getting expo-notifications permission only means the OS will let your app show notifications — it says nothing about whether ContentFlow is allowed to message this person over the push channel. That's a separate, explicit decision made with the consent APIs (client.setChannelConsent({ push: true }), requestConsent, or a consent preset); this helper never calls a consent API. Prefer exceptions at the call site? Use registerExpoPushOrThrow, which returns only the 'registered' case and throws on everything else.

Samples and validation

The release's sample archive contains examples/web-app, examples/react-app and examples/react-native-app. Build the SDK first (npm ci && npm run build in contentflow-sdk/), then run npm install and npm run build in the sample. npm start starts Web/React on localhost; the React Native sample uses Expo. Samples start disconnected, contain no credentials and require explicit connection with your test workspace. Local tests never contact a tenant.

Build from source: npm ci, npm run typecheck, npm test. Contract tests consume the sibling sdk-release/fixtures/runtime-contract.json included in the source/sample bundle. The release manifest records exactly which native builds and runtime tests were performed; a Metro bundle is not an APNs/FCM delivery test.

Compatibility and limits

See the changelog and the release-wide runtime contract. This release validates the customer runtime path: identity, consent, blocks, strings, campaign delivery, analytics/sensors, token registration/removal and lifecycle behavior. Live refresh uses SSE where the adapter supplies it, with polling as fallback; neither is a durable message stream. Cached content is stale-while-revalidate and the delivery wire carries no cache expiry deadline, so the SDK cannot enforce one offline. Existing advanced location, industry-profile, KYC and sponsored helpers are not a claim of end-to-end backend/provider certification; KYC and the profile reads have no delivery-API route yet. Device location uses navigator.geolocation on the web; on React Native pass a module with that shape to CFProvider (geolocation={Geolocation} from @react-native-community/geolocation), since React Native core no longer ships one. Without it, requestLocation() rejects immediately and startLocationTracking() logs a warning. APNs/FCM/HMS delivery and device receipt still require vendor/device testing. The optional JavaScript HMAC/pinning flags now fail explicitly because fetch never implemented those guarantees. Default storage is not encrypted (see Stored data and secure storage); provide appropriate host storage when needed. Keep app UI fallbacks.

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