ContentFlow SDK Guide
Version: 2.4.1 · Platforms: Web, React, React Native, Expo, iOS (Swift), Android/JVM (Kotlin), Flutter
ContentFlow lets your marketing team change in-app content (banners, cards, copy, campaigns) without an app release, and measure how people interact with it. The SDK fetches that content, tracks engagement, and records consent.
Contents
- Start here
- Install
- Quick start (moved to Get started)
- Test your integration
- JavaScript SDK: entry points: Script tag · React hooks · React Native setup · Advanced
- Platform support · Upgrading to a new version · 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 · Upgrading from 2.1.0
- Content and blocks · Show in-app messages
- Localization and string sensors
- Events
- Consent
- Push notifications
- Location and IP geolocation · On-device geofences
- Workspace feature switches
- Server-side: registering blocks (write key)
- Security
- REST API reference
- Example apps
- Appendix: migrating from 1.x
- Appendix: changelog
Start here
Pick your package
| You are building | Use |
|---|---|
| A website (plain JS/TS) | createCFClient from @contentflow/sdk/web |
| A React web app | CFProvider + hooks from @contentflow/sdk/react |
| A React Native / Expo app | CFProvider + hooks from @contentflow/sdk/react-native (push helper: @contentflow/sdk/expo) |
| An iOS app | Swift SDK (contentflow-sdk-swift-2.4.1) |
| An Android or JVM app | Kotlin SDK (contentflow-sdk-kotlin) |
| A Flutter app | Flutter SDK (contentflow-sdk-flutter) |
All JavaScript entry points (/web, /embed, /react, /react-native, /expo, /advanced) are compared in JavaScript SDK: entry points. This guide uses createCFClient (and the React providers, which wrap the same client) for all JavaScript examples. The package also exports a simpler ContentFlow.init(...) facade from @contentflow/sdk. It always uses the browser platform and exposes fewer options. Use it in a browser or a Node script, never in React Native.
Keys
| Key | Where it goes | What it can do |
|---|---|---|
Publishable SDK key (<workspace-id>_app, or <workspace-id>_test for your test environment) |
Inside your app | Read content, track events, record consent and push tokens for this installation |
Write key (wk_live_…, sent as X-CF-Write-Key) |
Server or CI only | Register and update block definitions and source strings. Never ship it in an app. |
Read key (rk_live_…, sent as X-CF-Read-Key) |
Server only | Read back what ContentFlow knows about a user or device, such as its segment names (REST guide) |
The key decides the workspace and the environment (a _test key is your test environment). You don't send a tenant ID separately. Get your keys from Developers → Your keys in the ContentFlow dashboard (the Environment toggle switches between test and live). Never put portal credentials or write keys in an app.
Base URL
The SDK API is served at https://api.contentflow.click/sdk/v1. app.contentflow.click is the portal; its /sdk/v1 and /api/v1/sdk paths (and https://api.contentflow.click/api/v1/sdk) reach the same SDK API and keep working for existing apps, but new integrations use the api host.
From 2.2.1, all four SDKs default to https://api.contentflow.click/sdk/v1. SDK 2.2.0 and earlier defaulted to the portal host (https://app.contentflow.click/sdk/v1 in JS and Swift, https://app.contentflow.click/api/v1/sdk in Flutter) or to https://api.contentflow.click/api/v1 (Kotlin). Those bases still work when set explicitly. A base that ends in /sdk/v1 works silently; the others log a one-time "legacy path" notice. Pass the base only, without a trailing operation such as /sync.
Configuring the base URL
Prefer configuring the base URL outside your code, so a future host or version change needs no code change. Precedence: an explicit baseUrl in code > platform config > the default https://api.contentflow.click/sdk/v1.
| Platform | Configure with |
|---|---|
| Web (plain page) | <script>window.__CONTENTFLOW_BASE_URL__ = 'https://api.contentflow.click/sdk/v1';</script> before the SDK loads |
| Vite | createCFClient({ publicKey, baseUrl: import.meta.env.VITE_CONTENTFLOW_BASE_URL }) |
| Next.js | baseUrl: process.env.NEXT_PUBLIC_CONTENTFLOW_BASE_URL |
| Expo / React Native | baseUrl: process.env.EXPO_PUBLIC_CONTENTFLOW_BASE_URL |
| iOS (Swift) | Info.plist: <key>ContentFlowBaseURL</key><string>https://api.contentflow.click/sdk/v1</string> |
| Android (Kotlin) | AndroidManifest.xml, inside <application>: <meta-data android:name="click.contentflow.BASE_URL" android:value="https://api.contentflow.click/sdk/v1"/> |
| JVM (Kotlin) | system property -Dcontentflow.baseUrl=…, or the environment variable CONTENTFLOW_BASE_URL |
| Flutter | flutter run --dart-define=CONTENTFLOW_BASE_URL=https://api.contentflow.click/sdk/v1 |
The JS SDK reads only the page global itself; the build-env values above are passed by your app (an unset value falls through to the default). A configured value must be https; http is accepted only for localhost, 127.0.0.1 and 10.0.2.2 (the Android emulator). An invalid value is ignored with a logged error, and the default is used. An explicit baseUrl in code is not newly validated. The base URL is never fetched remotely: a remote value could redirect your app's traffic and keys, and would bypass certificate pinning.
A base that doesn't end in /sdk/v1 still works, but the SDK logs this once per client: ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).
Consent, before anything else
There are two separate kinds of consent:
- Analytics consent (one yes/no per installation) gates every event. Until it is granted, the SDK tracks nothing. Events are not queued for later, and a later grant does not replay them. Content delivery works without it.
- Channel consent (push, SMS, WhatsApp, email, marketing, location) says whether you may message this person on that channel. It never turns analytics on.
Always set both from a choice the user actually made. Registering a push token does not grant push consent. See Identity and consent.
Install
Download, verification and install steps for every platform are on Get started, with one tab per platform.
Quick start
The first-block quick start for Web, React, React Native, Swift, Kotlin, Flutter and REST is on Get started. Each sample starts the client, sets analytics consent from the person's choice, reads a block and tracks an impression. The rest of this guide covers each area in depth.
Test your integration
Before you ship, test with the _test key and something published to Test: check the key with curl https://api.contentflow.click/sdk/v1/sync -H "X-CF-Key: <workspace-id>_test", turn on SDK logging (JavaScript debug: true, Swift diagnostics: .verbose, Kotlin DiagnosticsLevel.Verbose, Flutter debug: true), and confirm that one event is accepted. The steps, the per-platform end-to-end check, reading dropReasons and common failures are in Testing your integration.
A rejected key
All four SDKs (2.3.0 and later) treat a rejected key as permanent for that key, instead of retrying it forever.
What counts as a rejected key: any HTTP 401 (codes INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY, or none), or a 403 whose code is INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH. The code is read from the top-level code of the envelope, or from error.code. Every other 403 is a refusal of one request, not of the key: DEVICE_NOT_IDENTIFIED, USER_MISMATCH, the identity and location-consent refusals, and a 403 with no code. Write-key calls (registerStrings, disableStrings, block registration) never count.
What the SDK does, once:
- stops retrying, closes the live stream and does not reconnect, and stops periodic sync;
- drops everything queued for the key (events, string-sensor events, location points, uncertain and in-flight batches) and reports the dropped count in one invalid-key error, logged at error level even when debug logging is off;
- afterwards sends nothing for that key: calls that return data fail fast with the same error (
sync()in JavaScript, Kotlin and Flutter keeps serving cached content), and tracked events are dropped without a further report.
Resume by creating a new client with a corrected key or configuration, or by restarting the app. The state is never stored on the device.
| SDK | Invalid-key error | Where it arrives |
|---|---|---|
| JavaScript | CFInvalidKeyError (code: 'invalid_sdk_key', serverCode, status, droppedCount) |
onError, subscribeErrors, getLiveStatus().lastError (reason: 'invalid_key') |
| Swift | ContentFlowError.invalidKey(statusCode:code:droppedCount:) |
ContentFlowDiagnostics, category auth, on ContentFlowDiagnostic.error; client.isKeyRejected |
| Kotlin | ContentFlowError.InvalidKey(status, code, droppedCount), a subclass of AuthenticationFailed |
diagnostics invalid_sdk_key at error level |
| Flutter | CFInvalidKeyException (a CFRequestException, with code, statusCode, droppedCount) |
onError, errors; cf.invalidKeyError |
Retries and queue limits (all SDKs): every retry or offline queue holds at most 500 items and drops the oldest past that, with the drops reported at most once a minute. Only transport failures, 429 and 5xx are retried, with exponential backoff and jitter (1 s base, 5 min cap), never sooner than Retry-After (seconds or an HTTP date, capped at 1 h). Any other 4xx drops the request. Identity and consent refusals are dropped, as before. A timed-out or 5xx event batch is still reported as uncertain and not replayed by default in JavaScript and Swift (it may have arrived; JS eventRetryPolicy: 'at-least-once' replays it).
Live stream: the JavaScript SDK (web and React Native) cannot read the status of a failed EventSource connection, so before each reconnect it makes a conditional /sync: a rejected key stops the stream; anything else reconnects with the usual backoff. Swift, Kotlin and Flutter read the stream's status directly. Flutter now reconnects a dropped stream with backoff and jitter; before 2.3.0 it did not reconnect at all.
JavaScript SDK: entry points
One package, @contentflow/sdk, serves every JavaScript platform. Import from the entry point that matches your app. Each is a separate bundle, so a web app never loads React Native code:
| Import | Use it for | Main exports |
|---|---|---|
@contentflow/sdk/web |
Websites and single-page apps in plain JS/TS | createCFClient, bindStringSensors |
@contentflow/sdk/embed |
Sites without a build step: fills data-cf-block elements automatically |
window.ContentFlow (scan, refresh, setLocale, setConsent, client) |
@contentflow/sdk/react |
React web apps | CFProvider, useCF, useCFBlock, useCFBlocks, useCFCampaigns, useCFString, useCFTranslations, useCFTheme, useLiveStatus |
@contentflow/sdk/react-native |
React Native and Expo apps | The same hooks, plus CFBanner, CFCampaignBanner, createRNPlatform |
@contentflow/sdk/expo |
Expo push registration | registerExpoPush, unregisterExpoPush (see Push notifications) |
@contentflow/sdk/advanced |
Custom platforms, tests, low-level control | CFClient, createWebPlatform, PlatformAdapter and all types |
@contentflow/sdk |
Legacy web-only facade | ContentFlow.init(...); prefer /web |
All of them take the same configuration (publicKey, baseUrl, locale, consent, …): see Configuring the base URL and Consent, before anything else.
Script tag (/embed)
For a site without a bundler. There is no ContentFlow CDN, so you host the file yourself: copy dist/web/embed.js from the SDK archive (contentflow-sdk-2.4.1.tgz, under package/) into your site, for example as /assets/contentflow-embed.js.
The file is an ES module, so load it with type="module" and configure it through window.contentflow, set before the script loads (a module script can't read its own data-* attributes):
<div data-cf-block="home_banner"></div>
<script>
window.contentflow = {
publicKey: '<workspace-id>_app', // publishable key, safe in the browser
locale: 'en',
consent: false, // set true only from your consent UI
};
</script>
<script type="module" src="/assets/contentflow-embed.js"></script>Every [data-cf-block="<key>"] element is filled with the live block for that key and re-rendered on live updates. Impressions fire when a banner is at least half visible, and CTA clicks are tracked. After you add elements dynamically, call window.ContentFlow.scan(). Change consent or language with window.ContentFlow.setConsent(true) and window.ContentFlow.setLocale('ar'). For anything beyond banners, use /web.
React hooks
Every hook reads the client that CFProvider creates (see the React tab in Get started). Hooks that return content re-render on live updates.
import type { ReactNode } from 'react';
import {
useCF, useCFBlocks, useCFCampaigns, useCFString, useCFTranslations, useCFTheme, useLiveStatus,
} from '@contentflow/sdk/react';
// All enabled blocks for one key, e.g. several cards on a carousel
function Offers() {
const offers = useCFBlocks('home_offers');
return <ul>{offers.map((o) => <li key={o.instanceId}>{String(o.get('title') ?? '')}</li>)}</ul>;
}
// Campaigns for a placement, highest priority first; one impression each is tracked
function Hero() {
const cf = useCF();
const [top] = useCFCampaigns('home_hero');
if (!top) return null;
return <a href={top.actionUrl} onClick={() => cf.trackCampaignTap(top)}>{top.title}</a>;
}
// One string (fallback used only when the key is absent), or the whole namespace
function Greeting() {
const title = useCFString('home.title', 'Welcome');
const strings = useCFTranslations('home');
return <h1 title={strings['home.subtitle'] ?? ''}>{title}</h1>;
}
// Colours from a block, with fallbacks, as typed { hex, rgba, raw } values
function Themed({ children }: { children: ReactNode }) {
const { theme } = useCFTheme('brand_theme', { primary: '#brand_primary' }, { fallback: { primary: '#5b57e0' } });
return <div style={{ color: theme.colors.primary?.hex }}>{children}</div>;
}
// Live-update status: 'stream', 'poll' or 'off', and why
function LiveBadge() {
const live = useLiveStatus();
return <small>{live.mode === 'stream' ? 'Live' : `Updates every refresh (${live.reason ?? live.state})`}</small>;
}useCFBlock(key, { trackImpression: false }) and useCFCampaigns(placement, { trackImpression: false }) turn off the automatic impression when you track visibility yourself.
React Native setup
npm install ./vendor/contentflow-sdk-2.4.1.tgz @react-native-async-storage/async-storage
npm install react-native-sse # optional: live updates by streaming
npm install @react-native-community/geolocation # optional: device location@react-native-async-storage/async-storageis required: it stores the device ID, consent and cached content. Rebuild the native app after installing it.react-native-sseis optional. Pass it aseventSource. Without it, live updates fall back to polling.- For device location, pass a module with the
navigator.geolocationshape asgeolocation. React Native core no longer ships one. Declare the location permission in your app (Info.plistNSLocationWhenInUseUsageDescription, AndroidACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION). The OS prompt appears only when you callrequestLocationorstartLocationTracking, and location is collected only with analytics consent.
import EventSource from 'react-native-sse';
import Geolocation from '@react-native-community/geolocation';
import { CFProvider, CFBanner, CFCampaignBanner, useCF } from '@contentflow/sdk/react-native';
export function App() {
return (
<CFProvider
config={{ publicKey: CONTENTFLOW_PUBLISHABLE_KEY, locale: 'ar' }}
eventSource={EventSource}
geolocation={Geolocation}
>
<Home />
</CFProvider>
);
}
function Home() {
const cf = useCF();
return (
<>
{/* Default card for a block; tap opens your own handler */}
<CFBanner blockKey="home_banner" onPress={(block) => openLink(String(block.get('cta_url') ?? ''))} />
{/* Highest-priority campaign for a placement, with your own renderer */}
<CFCampaignBanner placement="home_hero" render={(c) => <Hero title={c.title} />} onPress={(c) => openLink(c.actionUrl)} />
<NearbyButton onPress={() => void cf.requestLocation({ purpose: 'Show nearby branches' })} />
</>
);
}CFBanner and CFCampaignBanner accept render (your own component), onPress, fallback (shown while nothing is live) and style. CFBanner also takes trackImpression. For push with Expo, see Push notifications.
Advanced (custom platforms)
Most apps should use /web, /react or /react-native. Use @contentflow/sdk/advanced when you need the client without a framework, or on a runtime those don't cover: a custom WebView bridge, a test harness, or a Node script (Node 18+, which has fetch). You construct CFClient yourself with a PlatformAdapter: storage, an ID generator, and optionally live updates (eventSource), app lifecycle (appState) and geolocation.
import { CFClient, type PlatformAdapter } from '@contentflow/sdk/advanced';
import { randomUUID } from 'node:crypto';
const memory = new Map<string, string>();
const platform: PlatformAdapter = {
storage: {
getItem: (k) => memory.get(k) ?? null,
setItem: (k, v) => { memory.set(k, v); },
removeItem: (k) => { memory.delete(k); },
},
uuid: () => randomUUID(),
// no eventSource: live updates poll
};
const client = new CFClient({ publicKey: CONTENTFLOW_PUBLISHABLE_KEY }, platform);
await client.start();
const banner = client.getBlock('home_banner');
client.dispose();In a browser, createWebPlatform() from the same entry point gives you the standard web adapter to start from. Never put a write key in client code; the write-key tools are server/CI only.
Platform support
The four SDKs share the same core capabilities, with platform-appropriate APIs, but not every capability: certificate pinning and request signing are not available in JavaScript, and location support differs by platform. This table is the current list.
| Feature | JS | Swift | Kotlin | Flutter |
|---|---|---|---|---|
| Blocks, offline cache | ✅ | ✅ | ✅ | ✅ |
| Live updates | ✅ | ✅ | ✅ | ✅ |
| Localized strings | ✅ | ✅ | ✅ | ✅ |
Locale catalog (getLocales) |
✅ | ✅ | ✅ | ✅ |
| String sensors | ✅ | ✅ | ✅ | ✅ |
Category events (commerce, engagement, …) |
✅ | ✅ | ✅ | ✅ |
| Sign-up / sign-in events | ✅ | ✅ | ✅ | ✅ |
| Analytics + channel consent | ✅ | ✅ | ✅ | ✅ |
| Consent presets | ✅ | ✅ | ✅ | ✅ |
| Campaigns | ✅ | ✅ | ✅ | ✅ |
| Push token registration | ✅ | ✅ | ✅ | ✅ |
| Push unregister, logout | ✅ | ✅ | ✅ | ✅ |
GDPR erasure (deleteIdentity) |
✅ | ✅ | ✅ | ✅ |
| Device location, geofences | ✅ | ✅ | ✅ | ✅ |
| IP geolocation | ✅ | ✅ | ✅ | ✅ |
| Block registration (write key, server-side) | ⚠️ | ⚠️ | ⚠️ | ⚠️ |
| String registration (write key, server-side) | ✅ | ✅ | ✅ | ✅ |
| Certificate pinning | — | ✅ | ✅ | ✅ |
Popup frequency on the device (getPopup, shouldShow) |
✅ | ✅ | ✅ | ✅ |
SDK-drawn campaign popup (presentPopup) |
✅ | ✅ | ✅ | ✅ |
SDK-drawn block popup with the standard fields (blockPopups) |
✅ | ✅ | ✅ | ✅ |
- ✅ is available in 2.2.0 and later, except the popup rows: frequency and the campaign popup are new in 2.4.0, the block popup in 2.4.1.
- — is not offered. The JS SDK can't pin, because
fetchcan't enforce it. - ⚠️ exists but must not be used (2.4.1 and earlier): the SDK helpers archive every other block type. Use the HTTP routes; see Server-side: registering blocks.
Platform notes:
- Flutter: use 2.2.2 or later. In 2.2.1, block content and channel consent did not work against the live SDK API.
- Kotlin: the extended features (locales, string sensors, consent presets, IP geolocation, block/string registration) are on
client.extensions. Device location needs the opt-in:locationmodule and runs in the foreground only; there's no background tracking yet. - Swift: device location needs the opt-in
ContentFlowLocationproduct. - React Native: device location needs a geolocation module passed to the SDK.
Not available in any SDK: KYC submit/state (submitKYC, getKYCState). The public SDK API has no route for them. Industry data is write-only: getIndustryProfile is deprecated in every SDK and always returns null without a request.
Upgrading to a new version
How to swap an SDK archive for a newer one, and the action items for each version you skip, are in the release notes. See Release notes.
What's new in 2.4.1
In the release notes. See Release notes.
What's new in 2.4.0
In the release notes. See Release notes.
What's new in 2.3.0
Moved to the release notes. See Release notes.
What's new in 2.2.2
Moved to the release notes. See Release notes.
What's new in 2.2.1
Moved to the release notes. See Release notes.
What's new in 2.2.0
Moved to the release notes. See Release notes.
Upgrading from 2.1.0
Moved to the release notes. See Release notes.
Content and blocks
A block is a piece of content your marketing team edits in the portal. Its key (for example home_banner) is the placement in your app. Each block carries field values addressed by tag (#title, #image, #cta). A block may also carry a collection: an ordered list of whole blocks, such as cards in a carousel.
const blocks = await cf.sync(); // authoritative snapshot
const banner = cf.getBlock('home_banner'); // first enabled block for the key
const title = banner?.get('title') ?? 'Welcome'; // '#title' and 'title' both work
const cards = banner?.getCollection('cards') ?? [];
const campaigns = await cf.getCampaigns('home_banner');- The server decides targeting, schedules and A/B variants. The SDK never assigns an A/B variant itself. (JavaScript's
variant(key)only picks one of several delivered instances of a key per device; see Blocks: A/B tests.) - The server also applies each block's and in-app campaign's popup frequency rule (for example "Once per day") and the workspace popup limits, and leaves out what this user or device has reached. From 2.4.0, the SDK keeps a local count too and adds
getPopup(screen),shouldShow,frequencyReasonandresetFrequency, and it can draw an in-app campaign popup itself withpresentPopup(screen)(see Popups in your app). From 2.4.1 the same card also draws a block popup that has a#titleor#body(plus optional#image,#cta_label,#cta_url,#cta_visible) when the app sets noonBlockPopup; theblockPopupsoption turns that off. A block's button link goes toonAction(JavaScript, Flutter) oronBlockAction(Kotlin, Swift), with the rules in Block popups in the built-in card. See Popup frequency and limits. - A failed refresh keeps the last good content. Content is stale-while-revalidate and survives restarts.
fetchBlock(key, { locale, deviceId })reads one block for another locale or installation (a preview, a "see it in Arabic" toggle) without changing the app's locale or identity.- Track only what was really shown:
trackImpression(block)when visible,trackTap(block)on tap,trackConversion(block)on the goal. - A wrong
baseUrlis reported asCFInvalidBaseUrlErrorthroughcf.subscribeErrors(...)andcf.getLiveStatus().lastError.fetchBlock()rejects with it. cf.subscribeLiveStatus(...)tells you whether you are streaming (stream) or polling (poll), and why.
Platform support: all four SDKs. Swift and Kotlin render through ContentFlowSlot plus a renderer you register per placement. Flutter reads blocks with getBlock(key).
Show in-app messages
An in-app campaign, and an in-app step in a journey, reaches the app as an ordinary block with the key journey_message (or the placement the marketer chose), delivered to that one person on /sync. Your app decides where and how to draw it, for example as a card at the top of the home screen or in a message inbox. See Push or in-app? for when to use it instead of push.
What arrives on each message block:
| Field | Value |
|---|---|
key |
journey_message, or the placement chosen for the campaign or journey step |
instanceId |
jm_<id>, unique per message. The jm_ prefix, not the key, is what marks a message. Send it back with every event. |
#title |
The title, or "" when there is none |
#body |
The message text |
#image |
An image URL, only when the message has one |
#cta_label, #cta_url |
The button, only when the message has a web link (http:// or https://; other links are left out). The label is the journey step's button label, or Open. |
#cta_visible |
false when there is no button |
source, campaignId, journeyId |
campaign with its campaignId, or journey with its journeyId (JavaScript: getRawBlock(instanceId)) |
How it behaves:
- Several at once. A person gets up to 20 live messages, newest first, after the ordinary blocks. Read them as a list; the one-block accessors return only one of them.
- On the next sync, not instantly. A new message shows on the app's next sync: at start, when the app returns to the foreground, on the poll timer, or when you call
sync(). Live updates don't announce new messages, and Swift and Kotlin poll only while the live stream is down, so callsync()at a natural point (opening the home screen or an inbox) if you need it sooner.fetchBlock('journey_message')does not return messages. - Report the button and the close. When the person taps the button, send
cta_click; when they close the message, senddismiss. Either one, with the message'sinstanceId, stops ContentFlow delivering it on later syncs. Atapon the message also removes it. - One click per message. For a campaign message,
impressionandconversionadd to the campaign's statistics, and the firstcta_clickortapfrom that person counts as one click; sending both, or sending one twice, still counts once.dismissis never a click. - Hidden on the device straight away. From 2.4.1, the SDK hides a message as soon as you report
dismiss,cta_clickortapfor it, with or without analytics consent: it keeps only the message'sinstanceIdon the device for 8 days and forgets it when the user changes, on logout,deleteIdentity()andresetFrequency(). The event itself is still sent only with analytics consent, and only an event that reaches ContentFlow stops later deliveries and counts in the statistics. With SDK 2.4.0, hide the message in your own UI and skip dismissedinstanceIds yourself. - Expires at the campaign's end time, or 7 days after it was sent when the campaign or journey step has none.
- Addressed to a person. The app must be signed in as that user (
identifywith their user id); an anonymous profile gets it on its own install.
const seen = new Set<string>();
function renderMessages() {
const messages = cf.getBlocks('journey_message'); // every live message, newest first
renderInbox(messages); // your UI: title, body, imageUrl, ctaVisible / ctaLabel
for (const msg of messages) {
if (seen.has(msg.instanceId)) continue;
seen.add(msg.instanceId);
cf.trackImpression(msg); // once per message, when it is on screen
}
}
cf.subscribe(renderMessages);
function onButton(msg: CFBlock) {
cf.trackCtaClick(msg); // counts the click once and removes it from later syncs
hideMessage(msg.instanceId);
openLink(String(msg.get('cta_url') ?? '')); // check against your allow-list first
}
function onClose(msg: CFBlock) {
cf.trackDismiss(msg); // removes it from later syncs
hideMessage(msg.instanceId);
}Reading the list on each platform:
| SDK | All messages | Notes |
|---|---|---|
| JavaScript, React, React Native | cf.getBlocks('journey_message'), useCFBlocks('journey_message') |
getBlock returns the newest |
| Swift | await client.currentBlocks().filter { $0.key == "journey_message" }, or try await client.sync().blocks |
ContentFlowSlot and blockState(for:) show one (the newest). #cta_url decodes as an unknown field type (url). |
| Kotlin | client.sync().blocks.filter { it.key == "journey_message" }, or ContentUpdate.Synced on client.updates |
observeBlock shows one (the newest from 2.4.1; the oldest in 2.4.0). #cta_url decodes as ContentFieldValue.Unknown. |
| Flutter | cf.getBlocks('journey_message') (from 2.4.1) |
getBlock returns one message (the oldest). In 2.4.0 the SDK keeps one block per key, so use (await cf.sync(force: true)).where((b) => b.key == 'journey_message'). |
Each SDK README has a full example under Show in-app messages.
Localization and string sensors
await cf.setLocale('ar'); // re-syncs content and strings for Arabic
await cf.getStrings('home'); // load a namespace
const label = cf.t('welcome', 'Welcome'); // fallback only if the key is absent
const { locales } = await cf.getLocales(); // the workspace's languages, with coverageThe server resolves the locale and returns the source text until a translation is approved; the SDK never invents one. String sensors record when a key's text is seen (t() in JS, trackString, bindStringSensors on the web) and used, with analytics consent. The whole flow, from creating a key to switching its sensor on and off, is in Localization and string keys.
Platform support:
- JS: full.
- Swift:
getStrings(locale:),strings.string(_:fallback:),trackSensorImpression(key:source:),trackSensorInteraction(key:),getLocales(). - Kotlin:
getStrings(locale)andstrings.value(key, fallback). Onclient.extensions:trackStringImpression(key, screen),trackStringInteraction(key, screen)andgetAvailableLocales(). - Flutter:
getStrings(),setLocale(),getLocales(),trackStringImpression/trackStringInteraction.
Managing keys from code (write key, server/CI only)
Two calls manage the workspace's keys from a server or CI job. Both need the write key, which must never ship in an app.
| Call | Route | What it does |
|---|---|---|
registerStrings(...) |
POST /api/v1/strings/sync |
Creates keys or updates their source text. Never changes whether a key is tracked. |
disableStrings(keys) |
POST /api/v1/strings/disable |
Stops tracking the keys. Their text is still served; only their sensors turn off. |
disableStrings takes 1 to 500 non-empty keys of at most 200 characters per call (every SDK drops duplicates and rejects anything else before sending) and answers which keys were turned off now (disabled), were already off (alreadyDisabled) or don't exist (unknown). Tracking is turned back on only in the portal (Sensors → Enable sensors). When each change reaches apps, and what happens to events for a disabled key: Localization and string keys: sensors.
| SDK | Call |
|---|---|
| JS | ContentFlow.disableStrings(keys, { writeKey }) or client.disableStrings(...) → { disabled, alreadyDisabled, unknown } |
| Swift | try await client.disableStrings(keys, writeKey:) → StringDisableResult |
| Kotlin | client.extensions.disableStrings(keys, writeKey) → StringDisableResult |
| Flutter | await cf.disableStrings(keys, writeKey: ...) → CFStringDisableResult |
Events
Every event name is category:action. Send them with the category helper on your platform, or with track in JS:
cf.track('engagement:search', {}, { query: 'flights to Dubai', resultsCount: 15, screen: 'home' });
cf.track('growth:redeem', {}, { points: 500 });| Category | Actions |
|---|---|
commerce |
purchase, transfer, payment, booking, order, refund, subscribe, cancel, top_up, withdraw |
engagement |
click, view, search, share, scroll, swipe, play, pause, download, favorite, rate, review |
identity |
sign_out, delete, verify, password_reset, password_change, profile_update (sign-up/sign-in: see below) |
growth |
earn, redeem, tier_change, expire, invite, invite_accepted, referral_convert |
account |
view, update, verify, close, lock, unlock, statement, limit_change |
system |
session_start, session_end, app_open, app_background, push_delivered, push_opened, push_dismissed, experiment_exposure, error, crash |
location |
geofence_enter, geofence_exit, geofence_dwell, store_visit, zone_enter, zone_exit, location_update, location_permission |
| content (built in) | impression, tap, cta_click, dismiss, conversion: use trackImpression/trackTap/trackCtaClick/trackDismiss/trackConversion |
Put extra fields in the properties object. Common typed fields are amount, currency, reference, status, bookingType, plan, contentId, query and screen.
Sign-up and sign-in are identity events with their own endpoint. Use cf.trackSignUp({ username, fullName, email }) and cf.trackSignIn({ username }). Passing sign_up/sign_in to track throws.
Delivery:
- Events are batched (10 per batch or every 15 s by default, at most 100 per batch).
cf.subscribeEventDrops(...)reports every drop, whether the SDK refused an event (source: 'local', e.g. no consent yet) or the server dropped it (source: 'server').- A timeout can't prove whether a batch arrived. By default that is reported through
onEventsUncertainand not retried.eventRetryPolicy: 'at-least-once'retries and may duplicate. - Call
cf.flush()at meaningful app boundaries.
Platform support: Swift (client.commerce(…), engagement, identity, …, trackEngagement(.tap(block))), Kotlin (client.commerce(…), …, trackEngagement(type, blockKey, instanceId)), Flutter (cf.commerce(…), trackEngagement(type:, blockKey:)).
Consent
Analytics consent (setConsent, Swift and Kotlin setAnalyticsConsent) gates every event; channel consent (setChannelConsent) says whether you may message the person on push, SMS, WhatsApp, email, location or marketing. Identify, both kinds of consent, sign-out and their errors are explained once, for every platform, in Identity and consent.
Erasure (right to be forgotten)
deleteIdentity() erases this installation's data on the server (DELETE /identify) and clears local state only after the server confirms a complete erasure. Per-platform samples and the partial-erasure (207) rules: Identity and consent: Erasure.
Consent presets
A preset (banking_ksa, insurance_ksa, healthcare_ksa, travel_ksa, retail_ksa, food_delivery_ksa, fintech_ksa) describes what a consent screen for a regulated industry should offer. The presets, samples and rules are in Identity and consent: Consent presets.
Platform support:
- JS: full.
- Swift:
setAnalyticsConsent,setChannelConsent,getConsentConfig(preset:),requestConsent(options:). - Kotlin:
setAnalyticsConsent,setChannelConsent. Presets onclient.extensions:getConsentConfig(preset),requestConsent(options). - Flutter:
setConsent,setChannelConsent,getConsentConfig,requestConsent.
Push notifications
Your app obtains the OS push token and notification permission (APNs, FCM, HMS). The SDK only links that token to the installation. Registering a token does not grant push consent. Set channel consent separately.
const linkage = await cf.registerPushWithReceipt({ token: osPushToken, platform: 'ios', provider: 'apns' });
// Android: platform 'android', provider 'fcm'. Huawei: provider 'hms' with hmsConfig: { appId }.
// Call again whenever the OS refreshes the token.
await cf.unregisterPush(); // this installation's token only; idempotent
await cf.logout(); // on sign-out: unlink the user and remove this device's tokenExpo: use the helper, which takes expo-notifications from your app and registers the native APNs/FCM token (Expo relay tokens are rejected):
import { Button } from 'react-native';
import * as Notifications from 'expo-notifications';
import { useCF } from '@contentflow/sdk/react-native';
import { registerExpoPush } from '@contentflow/sdk/expo';
function EnablePushButton() {
const cf = useCF(); // the client CFProvider created
return (
<Button
title="Enable notifications"
onPress={async () => {
const result = await registerExpoPush(cf, Notifications, { requestPermission: true });
if (result.status === 'registered') {
await cf.setChannelConsent({ push: true }); // the user just opted in to push
} else {
// 'permission_denied', 'permission_not_requested', 'unsupported_token' or 'failed'
console.warn('push not registered:', result.status);
}
}}
/>
);
}Native SDKs:
- Swift:
try await client.registerPushToken(deviceToken, platform: .apns)andunregisterPushToken(). - Kotlin:
client.registerPushToken(token, PushPlatform.Fcm),unregisterPushToken(),logout(). - Flutter:
cf.registerPush(CFPushConfig(token: token, platform: CFPushPlatform.android, provider: CFPushProvider.fcm)),unregisterPush(),logout().
Location and IP geolocation
Location is opt-in. Your app asks for OS permission; the SDK never prompts on its own.
Nothing is collected or sent without analytics consent. Revoking it stops collection and discards unsent points.
const here = await cf.requestLocation({ purpose: 'Show nearby branches' });
const ip = await cf.getIPGeolocation(); // coarse country/city from the IP; no permission promptFor continuous tracking and geofences, see startLocationTracking(config) and registerGeofence(config) in the TypeScript types (LocationTrackingConfig, GeofenceConfig). App-defined fences (registerGeofence with ids the dashboard doesn't have) no longer reach analytics from device reports; the server records only workspace fences. Use on-device geofences for those.
Platform support:
- JS and Flutter:
requestLocation,startLocationTracking,registerGeofence. - Swift: the same, through the opt-in
ContentFlowLocationproduct. - Kotlin: the same, through the opt-in
:locationmodule (enableDeviceLocation(context)). IP geolocation is viaclient.extensions. No background tracking yet.
On-device geofences
New in 2.3.0. The SDK registers the workspace's geofences with the OS geofencing service, so the OS wakes the app on an enter, exit or dwell and the SDK reports it straight away, with no continuous GPS. The server keeps evaluating uploaded location as a fallback and dedupes, so one stay fires once whichever side sees it first.
| SDK | Start / stop | OS side |
|---|---|---|
| Swift | enableDeviceLocation() (ContentFlowLocation), then await client.startGeofencing() / await client.stopGeofencing(); client.geofencingStatus |
CLLocationManager region monitoring |
| Kotlin | client.startGeofencing(context) from the new :geofencing module (click.contentflow.geofencing), client.stopGeofencing(); core startGeofencing(monitor) for your own monitor |
GeofencingClient (Play services) with a broadcast receiver |
| React Native / Expo | defineContentFlowGeofenceTask(TaskManager) at the app entry, then startExpoGeofencing(client, Location, { platform: Platform.OS }) from @contentflow/sdk/expo; or client.startGeofencing({ monitor }) with your own |
expo-location geofencing + expo-task-manager (optional peers) |
| Flutter | cf.startGeofencing(monitor) / cf.stopGeofencing() |
A CFGeofenceMonitor you implement over the geofencing plugin you use (the SDK is pure Dart) |
| Web | Not supported | None: browsers have no OS geofencing, so fences are evaluated on the server from uploaded location only |
Rules that hold on every SDK:
- Opt-in, never automatic. Geofencing runs only while the app has called start, analytics consent is granted, location consent is granted (
locationTracking/.locationchannel consent), and the workspacelocationswitch is on. When any of them turns off, every region the SDK registered is removed and unsent transitions are dropped. The app's request is remembered, so geofencing resumes when they are all back on; stop ends it. The server also sends no fences without location consent, and that is what enforces it: the server's/locationconsent refusal is keyed on analytics consent. Swift and JavaScript need an explicit location grant recorded on the device (setChannelConsent(.granted, for: .location)/setChannelConsent({ locationTracking: true })); unset counts as not granted. Kotlin and Flutter stop on a local revocation and otherwise rely on the server's answer. - The app asks for permission; the SDK never prompts. iOS:
NSLocationWhenInUseUsageDescriptionandNSLocationAlwaysAndWhenInUseUsageDescriptioninInfo.plist, and "Always" authorization. Android:ACCESS_FINE_LOCATIONand, on Android 10+,ACCESS_BACKGROUND_LOCATION. The Kotlin:geofencingmodule declares only FINE and COARSE; the app declaresACCESS_BACKGROUND_LOCATIONin its own manifest and completes Play's background-location declaration, so no app gets that declaration from a dependency. Without it, geofencing stays idle. - Fences.
GET /sdk/v1/geofences?lat=&lng=&max=(nearest first,If-None-Match/ ETag) on start, on foreground oncerefreshAfterSechas passed, and after a significant move.lat/lngis a cached coarse position rounded to 3 decimals (about 100 m), sent together or not at all, never a fresh fix. A 502 or 503 keeps the regions already registered and retries later; a 200 withfences: []removes them all. - Limits. iOS monitors at most 20 regions per app, Android at most 100, shared with regions the app registers itself. The SDK registers the nearest fences that fit (
min(maxRegions, OS limit − the app's regions)). If some are left out, one slot holds a boundary region around the device; leaving it refetches and re-selects around the new position. SDKs ask for exactly the free slots (max, at most 100) and use the response'struncatedflag: when it is true, one slot goes to the boundary. Against an older server without the flag they fall back to asking for one fence more than fits. The boundary refetch skipsIf-None-Match; sending it would also be correct, because the server's ETag covers the list for the rounded position. - Regions outlive the app process. The first start in each process removes every SDK-owned region an earlier launch left, and so does every teardown, even when the gate was closed at launch; the app's own regions are never touched. To stop in a launch that never started geofencing, give the SDK a way to reach the OS: Swift
stopGeofencing()(creates its monitor), KotlinstopGeofencing(context), JSstopGeofencing({ monitor })/stopExpoGeofencing(client, Location), FlutterstopGeofencing(monitor: …). - Fences carry no
name(id, region, exact, triggers only). - Polygons arrive as their bounding circle and are registered as that circle; the server confirms the exact shape when the event is reported.
- Enter and exit are always reported for every monitored fence, whatever its trigger flags: the flags decide what fires on the server, which needs the exit to close a stay. Dwell is reported only when the fence has dwell on.
- Reporting. Each transition goes to
POST /sdk/v1/locationasgeofenceEvents: [{ fenceId, event, timestamp, lat, lng, accuracy?, source: "device" }].timestampis the OS transition time (the server rejects reports more than 24 h old or 5 min in the future);dwellTimeis not sent. A refusal (device not identified, no consent) is dropped; a network error, 429 or 5xx is kept (up to 50) and retried at the next wake. - Dwell. Android uses the OS dwell (
setLoiteringDelay). iOS, Expo and Flutter monitors without OS dwell record the enter time and report the dwell at the first wake afterdwellMswhile still inside, timestamped enter time +dwellMs(never later than now). A dwell more than 24 hours old is skipped, since the server would reject it. - Server route. Until the SDK API has the
/geofencesroute, it answers 404 and geofencing stays idle quietly.
Workspace feature switches
A ContentFlow platform operator can switch four built-in SDK features off for a workspace. The switches ride the existing /sync response as config.features (no extra request), and the server enforces them for every SDK version. From 2.3.0, all four SDKs also honour live_updates, campaigns and location on the device, so they stop doing work the server would discard; from 2.4.0 they also honour frequency_caps:
| Switch | When it's off |
|---|---|
live_updates |
No live-update stream is opened (an open one is closed). Content refreshes on the normal ETag poll. |
campaigns |
The campaigns call returns an empty list without a network request. No error. |
location |
Location tracking pauses and unsent points are dropped, as the server would drop them. |
frequency_caps |
The server applies no popup frequency rule: blocks and in-app campaigns a customer has reached the limit on are served again, and nothing is counted. From 2.4.0, the SDK also ignores its local frequency ledger: shouldShow is always true and frequencyReason null. See Popup frequency and limits. |
Read the effective values:
- JS:
client.getFeatures(),client.subscribeFeatures(listener)orCFConfig.onFeaturesChange.getLiveStatus().reasonisfeature_disabledwhilelive_updatesis off. - Swift:
await client.features. Location you send yourself withsendLocationBatchorreportGeofenceEventis not blocked on the device; the server drops it. - Kotlin:
client.features, orclient.featuresFlowto observe changes. Whilelocationis off,submitLocationBatch,sendLocationBatchandreportGeofenceEventanswer locally with nothing accepted. - Flutter:
cf.features, or thecf.onFeaturesChangestream. Whilelocationis off,submitLocationBatchandreportGeofenceEventsend nothing.
Rules that hold on every SDK:
- SDK 2.3.0 reads only
live_updates,campaignsandlocation; 2.4.0 and later also readfrequency_caps. Only boolean values are read. Anything missing, unknown or malformed counts as on, which is how the SDKs behaved before the switches existed. - A
304 Not Modifiedkeeps the last value, and the value is kept with the cached content, so it survives a relaunch. - A switch only pauses what your app started. When it turns back on, the SDK resumes live updates only if your app enabled them, and location only if your app had started tracking.
- A switch never changes the base URL, keys, authentication, certificate pinning or consent. Location still needs analytics consent.
Server-side: registering blocks (write key)
"Cards as code": your build or CI declares which blocks exist and which fields they take, and marketers fill in the content in the portal. This needs the write key. Run it from a server or CI job, never from a shipped app. The JS SDK warns if you call it in a browser or React Native.
Register one block type with POST /cards/register. It upserts that one definition by key and leaves every other block type alone, so it is the safe call for a first try and for one-off changes:
curl -X POST https://api.contentflow.click/sdk/v1/cards/register \
-H "Content-Type: application/json" \
-H "X-CF-Write-Key: $CONTENTFLOW_WRITE_KEY" \
-d '{
"key": "home_banner", "name": "Home Banner", "screen": "home",
"fields": [
{ "tag": "#title", "type": "text", "label": "Title" },
{ "tag": "#image", "type": "image", "label": "Image" }
]
}'
# 201 {"success":true,"data":{"registered":"home_banner","definitionCreated":true,"instanceId":"…","instanceCreated":true}}It also seeds one blank draft instance unless you send "seedInstance": false.
Declare the complete set with POST /cards/sync, from CI. The manifest is the whole intended state of the workspace: each entry is a definition in the same flat shape as above (key, name, screen, fields, optional instances), and every active block type the manifest does not name is archived, whatever prune says. Send your full set on every run, never a partial list. An empty blocks array is refused unless you send "prune": true, which then archives everything.
curl -X POST https://api.contentflow.click/sdk/v1/cards/sync \
-H "Content-Type: application/json" \
-H "X-CF-Write-Key: $CONTENTFLOW_WRITE_KEY" \
-d @all-blocks.json # {"blocks":[{"key":"home_banner","name":"Home Banner","fields":[…]}, …every other type…]}
# 200 {"success":true,"data":{"active":[…],"created":[…],"archived":[…],"archiveRefused":[],"instancesSeeded":0,"errors":[]}}Read archived in the response: it names every block type this run took down. The server has no dry-run mode, so a dryRun flag in the body is ignored and the sync is applied.
Known issue in 2.4.1 and earlier (all four SDKs): don't use the SDK
registerBlock/syncBlockshelpers yet. They send each definition nested under adefinitionobject, which the server does not read, andregisterBlocksends a one-entry manifest toPOST /cards/sync. The result is that the block type is saved with its key as its name and without the fields you declared, and every other active block type in the workspace is archived. TheirdryRunoption is not honoured either. Use the HTTP routes above until a fixed SDK release.
- Field types: see Blocks and fields.
- Instances (
{ externalId, values, segment, priority, status }) can be included per entry to seed content; they are idempotent byexternalId.statusisdraft,test,liveorpaused.
Registering strings works the same way in every SDK: registerStrings(...) with a write key (POST /api/v1/strings/sync), server/CI only. disableStrings(keys) (POST /api/v1/strings/disable, same write key) stops tracking keys while their text is still served; see Managing keys from code.
Security
Keys, the base URL rules, certificate pinning, request signing and the pre-release checklist are covered for every platform in Security. This section keeps the JavaScript specifics.
What the JS SDK does and doesn't do
- Certificate pinning and request signing are not available in JavaScript.
fetchcannot enforce them, so settingenableCertificatePinningorenableRequestSigningthrows at init. Pin in a native wrapper, or send traffic through a trusted server proxy. - Storage is not encrypted. The device ID, user ID and cached content are kept in the platform's normal storage (e.g.
localStorage, AsyncStorage).enableSecureStoragehas no effect in the JS SDK. Supply appropriate host storage if you need more. - Offline events. With
enableOfflineQueue(default on), batches handed off while the device is already known to be offline are saved and retried with backoff. They expire after 7 days, andmaxOfflineQueueSizeandmaxRetriescap them. Other pending events are memory-only and can be lost if the process dies. This is not a durable audit log. - Rate limiting.
rateLimitPerSecond(default 10) configures a client-side limiter, but in this release no request goes through it, so it throttles nothing;getRateLimiterStatus()reports its state. The server's limits apply regardless (see Rate limits).
These options (eventBatchSize, eventFlushMs, enableOfflineQueue, maxOfflineQueueSize, maxRetries, rateLimitPerSecond) are set on createCFClient / CFProvider config. The ContentFlow.init facade fixes most of them.
Certificate Pinning
Pinning is off by default; Swift, Kotlin and Flutter support it with at least two sha256/<base64> SPKI pins, and JavaScript cannot pin. How to enable it per platform, the current pins and key rotation are in Security: Certificate pinning.
Checklist
Publishable key in the app, write and read keys only on servers, a _test key while developing, consent from real user choices, and logout() on sign-out. The full pre-release checklist, which key each caller uses and the base URL rules are in Security.
REST API reference
The SDKs wrap this API. You only need it for platforms without an SDK, or for servers.
Base URL: https://api.contentflow.click/sdk/v1 (the stream endpoint is …/stream). https://api.contentflow.click/api/v1/sdk, and the same paths on the portal host app.contentflow.click, are equivalent compatibility bases for existing apps.
Headers:
| Header | When |
|---|---|
X-CF-Key (or X-API-Key) |
Every request. The publishable key; it determines the workspace and environment. |
X-CF-Device |
Device-scoped calls (identify, events, consent, push). Your stable installation ID. |
X-CF-Write-Key |
Block registration only (/cards/*). |
Responses use the envelope { "success": true, "data": … }. A success: false body is a failure even with HTTP 200.
| Method | Path | Purpose |
|---|---|---|
| POST | /identify |
Link the installation to a user and traits; set analytics consent |
| DELETE | /identify |
Erase this installation's identity (right to erasure) |
| POST | /logout |
Unlink the user and remove this device's push token |
| POST · DELETE | /register-push |
Register / remove this installation's push token |
| POST | /profile |
Update the identified user's profile |
| GET | /sync |
All blocks for this installation (ETag / 304 supported) |
| GET | /blocks/{key} |
One block |
| GET | /strings |
Localized strings (?locale=, namespace) |
| GET | /locales |
The workspace's locales and coverage |
| GET | /content/{id} |
Redirect to a content file |
| POST | /campaigns |
Campaigns for a placement |
| POST | /events |
Batch of engagement / category events |
| POST | /track-event |
Identity events (sign-up, sign-in) |
| POST | /consent |
Channel consent |
| GET | /consent/config/{preset} |
Consent preset |
| POST | /consent/full |
Channels, topics and preferences in one write |
| POST · GET | /push/topics |
Set / read push topics |
| PATCH | /push/preferences |
Push preferences |
| GET | /geo/ip |
IP geolocation |
| POST | /location |
Location updates and geofence events |
| GET | /stream |
Server-sent events for live updates |
| POST | /cards/sync · /cards/register |
Block registration (write key) |
Example apps
Two sketches of a full integration with createCFClient. The app names are illustrative.
Retail bank app
const cf = createCFClient({ publicKey: CONTENTFLOW_PUBLISHABLE_KEY, locale: 'ar' });
await cf.start();
async function onSignIn(user: { id: string }, analyticsChoice: boolean) {
await cf.setUserId(user.id);
await cf.setConsent(analyticsChoice);
await cf.trackSignIn({ username: user.id });
}
// Home screen offer
const offer = cf.getBlock('home_offer');
if (offer) cf.trackImpression(offer);
// Money movement
cf.track('commerce:transfer', {}, { amount: 5000, currency: 'SAR', transferType: 'internal', status: 'completed' });
cf.track('account:statement', {}, { screen: 'accounts' });
// Push, after the OS gave you a token and the user allowed push
await cf.registerPushWithReceipt({ token: fcmToken, platform: 'android', provider: 'fcm' });
await cf.setChannelConsent(currentUserId, { push: true });Food delivery app
const cf = createCFClient({ publicKey: CONTENTFLOW_PUBLISHABLE_KEY, locale: 'en' });
await cf.start();
const promos = cf.getBlocks('home_promos'); // every enabled block for the key
promos.forEach((promo) => cf.trackImpression(promo));
cf.track('engagement:search', {}, { query: 'shawarma', resultsCount: 24, screen: 'search' });
cf.track('commerce:order', {}, { amount: 75, currency: 'SAR', reference: 'FD-123', itemCount: 3, deliveryTime: 30 });
cf.track('growth:redeem', {}, { points: 200 });
await cf.logout(); // on sign-outAppendix: migrating from 1.x
The 1.x industry helpers (trackTransfer, trackFlightSearch, trackProductView, trackOrder, …) were removed in 2.0. Send the equivalent category event instead, and put anything without a typed field under custom properties:
| 1.x | 2.x |
|---|---|
trackTransfer({ amount, currency, type }) |
cf.track('commerce:transfer', {}, { amount, currency, transferType: type }) |
trackFlightSearch({ origin, destination, results }) |
cf.track('engagement:search', {}, { query: `${origin}-${destination}`, resultsCount: results }) |
trackProductView({ sku, price }) |
cf.track('engagement:view', {}, { contentType: 'product', contentId: sku, price }) |
trackOrder({ orderId, total, deliveryTime }) |
cf.track('commerce:order', {}, { reference: orderId, amount: total, currency: 'SAR', deliveryTime }) |
Field renames: sku/productId → contentId, total/price → amount, and the old type becomes transferType, bookingType and so on. Test with debug: true before release.
Appendix: changelog
Every version in brief, and the full per-SDK changelogs, are in the release notes.