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

  1. Start here
  2. Install
  3. Quick start (moved to Get started)
  4. Test your integration
  5. JavaScript SDK: entry points: Script tag · React hooks · React Native setup · Advanced
  6. 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
  7. Content and blocks · Show in-app messages
  8. Localization and string sensors
  9. Events
  10. Consent
  11. Push notifications
  12. Location and IP geolocation · On-device geofences
  13. Workspace feature switches
  14. Server-side: registering blocks (write key)
  15. Security
  16. REST API reference
  17. Example apps
  18. Appendix: migrating from 1.x
  19. 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).

There are two separate kinds of consent:

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:

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):

HTML
<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.

TSX
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

Terminal
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
TSX
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.

TypeScript
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) ✅ ✅ ✅ ✅

Platform notes:

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.

TypeScript
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');

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:

TypeScript
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

TypeScript
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 coverage

The 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:

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:

TypeScript
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:

Platform support: Swift (client.commerce(…), engagement, identity, …, trackEngagement(.tap(block))), Kotlin (client.commerce(…), …, trackEngagement(type, blockKey, instanceId)), Flutter (cf.commerce(…), trackEngagement(type:, blockKey:)).


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.

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:


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.

TypeScript
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 token

Expo: use the helper, which takes expo-notifications from your app and registers the native APNs/FCM token (Expo relay tokens are rejected):

TSX
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:


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.

TypeScript
const here = await cf.requestLocation({ purpose: 'Show nearby branches' });
const ip = await cf.getIPGeolocation();   // coarse country/city from the IP; no permission prompt

For 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:

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:


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:

Rules that hold on every SDK:


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:

Terminal
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.

Terminal
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 / syncBlocks helpers yet. They send each definition nested under a definition object, which the server does not read, and registerBlock sends a one-entry manifest to POST /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. Their dryRun option is not honoured either. Use the HTTP routes above until a fixed SDK release.

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

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

TypeScript
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

TypeScript
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-out

Appendix: 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.

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.