ContentFlow SDK for Flutter

Version: 2.4.1

What's new in 2.4.1

See the changelog.

What's new in 2.4.0

See the changelog.

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

Build dynamic, personalized in-app experiences without app releases.

Fixed in 2.2.2

Two 2.2.1 bugs are fixed; upgrade to 2.2.2 to get them.

Known issue: registerBlock / syncBlocks

Don't use them. They send the definition nested under definition, which the server does not read, through POST /cards/sync, which archives every other active block type in the workspace. Register block types with POST https://api.contentflow.click/sdk/v1/cards/register from CI (see the SDK guide).

Features

Feature Status
Content Sync (with ETag caching) ✅
Block Observers (real-time updates) ✅
Event Tracking (batched) ✅
Identity Events (sign-up/sign-in) ✅
User Identity & Traits ✅
Push Token Registration ✅
Push Topics & Preferences ✅
Analytics Consent ✅
Channel Consent (granular) ✅
Full Consent Flow (presets) ✅
Location Tracking ✅
Geofencing (circle/polygon) ✅
Campaign Fetch ✅
Translation Strings ✅
Offline-first Cache ✅
Background Sync ✅
KYC Verification ✅
Industry-Specific Tracking ✅
SSE Live Updates ✅
Offline Event Queue ✅
Request Signing (HMAC-SHA256) ✅
Secure Storage ✅
Rate Limiting ✅
Certificate Pinning ✅

Installation

Download contentflow-sdk-flutter-2.4.1.zip and the checksum file SHA256SUMS-2.4.1.txt (for example curl -fLO https://dev.contentflow.click/downloads/sdk/2.4.1/contentflow-sdk-flutter-2.4.1.zip); every version is listed on Releases and downloads. Then verify and extract it inside your app:

Terminal
mkdir -p vendor
shasum -a 256 contentflow-sdk-flutter-2.4.1.zip          # must equal this file's hash in SHA256SUMS-2.4.1.txt
unzip contentflow-sdk-flutter-2.4.1.zip -d vendor/       # creates vendor/contentflow-sdk-flutter/
YAML
# pubspec.yaml
dependencies:
  contentflow_sdk:
    path: vendor/contentflow-sdk-flutter
Terminal
flutter pub get

Requires Dart 3.5+ and Flutter 3.16+. The SDK depends on connectivity_plus ^7 and geolocator ^14, so an app pinned to older major versions of those packages must upgrade them.

A demo app is in example/. The archive contains only its Dart code, so create the platform folders first (cd example && flutter create --platforms=android,ios .), then run it with your test key and workspace id from Developers → Your keys: flutter run --dart-define=CF_PUBLIC_KEY='<workspace-id>_test' --dart-define=CF_TENANT_ID='<workspace-id>'. No key is embedded in the example; see example/README.md.

Upgrading

  1. Download and verify the new zip as above, and replace vendor/contentflow-sdk-flutter with it.
  2. Run flutter pub get.
  3. From 2.2.0 or earlier: if you set baseUrl: 'https://app.contentflow.click/api/v1/sdk', change it to https://api.contentflow.click/sdk/v1 (or remove it). If certificate pinning is on, add the api.contentflow.click pin first (see Certificate Pinning).
  4. From 2.1: trackSignUp/trackSignIn now throw on failure; wrap them in try/catch.
  5. Read the changelog for everything else, then repeat Testing your integration.

Quick Start

Dart
import 'package:contentflow_sdk/contentflow_sdk.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Keys: dashboard, Developers → Your keys. Use <workspace-id>_test while developing
  // (it also sees content published to Test) and <workspace-id>_app only in release builds.
  await ContentFlow.create(
    ContentFlowConfig(
      publicKey: const String.fromEnvironment('CF_KEY', defaultValue: '<workspace-id>_test'),
      locale: 'ar',
      // consent defaults to false: call ContentFlow.instance.setConsent(true) after the user agrees.
    ),
  );

  runApp(MyApp());
}

ContentFlow.create identifies the device anonymously and loads cached content.

Call order

  1. ContentFlow.create(...): identifies the device anonymously.
  2. After login: await cf.identify(userId: …) (or trackSignIn).
  3. After the user agrees: await cf.setConsent(true). Until then, track, location and string sensors send nothing.
  4. Only then: requestConsent, registerPush for that user, updatePushTopics. The server refuses consent and push-topic calls that name a user this device is not identified as.
  5. On sign-out: await cf.logout().

Testing your integration

  1. In the dashboard, copy the test key <workspace-id>_test (Developers → Your keys) and publish the block you are testing to Test.
  2. Check the key and content without the app: curl -H 'X-CF-Key: <workspace-id>_test' https://api.contentflow.click/sdk/v1/sync. An empty blocks array means nothing is published for this key yet.
  3. Run with debug: true, which logs every request and response, Sync failed errors, and Event not tracked: consent not granted for dropped events:
Dart
await ContentFlow.create(const ContentFlowConfig(
  publicKey: '<workspace-id>_test',
  debug: true,
));
final cf = ContentFlow.instance;
final blocks = await cf.sync();
debugPrint('blocks: ${blocks.map((b) => b.key).toList()}');
await cf.setConsent(true);                       // test build only
await cf.trackCtaClick(blockKey: 'home_hero');
await cf.flush();                                // watch the POST /events response in the log
Log or error Cause and fix
Sync failed: … type 'List<dynamic>' is not a subtype of type 'Map<dynamic, dynamic>?' The 2.2.1 block-parsing bug; upgrade to 2.2.2
Event not tracked: consent not granted Call setConsent(true) after the user agrees
401 Wrong or missing key
400 CHANNELS_REQUIRED setChannelConsent in 2.2.1; upgrade to 2.2.2
403 DEVICE_NOT_IDENTIFIED / USER_MISMATCH Call identify(userId: …) for that user first
A "legacy path" notice Set baseUrl to https://api.contentflow.click/sdk/v1, or remove it

In widget tests, call SharedPreferences.setMockInitialValues({}) before ContentFlow.create, because the SDK stores its state in shared_preferences.

Configuring the base URL

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

Terminal
flutter run --dart-define=CONTENTFLOW_BASE_URL=https://api.contentflow.click/sdk/v1

Core Features

Sync Content

Dart
final cf = ContentFlow.instance;

// Sync all blocks
final blocks = await cf.sync();

// Get specific block
final banner = cf.getBlock('home_hero');
print(banner?.title);             // #title_main, #title, #headline, ...
print(banner?.imageUrl);          // declared image field, else #header_image, #image, ...
print(banner?.cta?.label);        // #cta_label (action from #cta_url)
print(banner?.get('#promo_code')); // any field, by tag

sync() never throws; on failure it returns the cached blocks. A response it can't use (no blocks array, or a block without a key) is reported as a CFSyncException through ContentFlowConfig.onError and cf.errors, and logged even with debug off. Its ETag isn't stored, so the next sync fetches the content again. Ordinary network outages aren't reported there; they're retried on the next poll. A refused SDK key stops polling and is reported once as a CFInvalidKeyException (see Error Handling).

The delivered content is keyed by the field tag you defined in the dashboard (for example #title), not by title.

Identify Users

Dart
// After login
await cf.identify(
  userId: 'user_123',
  traits: {
    'tier': 'gold',
    'region': 'riyadh',
  },
  consent: true,
);

// Or use shorthand
await cf.setUserId('user_123');
await cf.updateTraits({'plan': 'premium'});

Sign-out and data deletion

Dart
// On sign-out: detach this device from the user and remove its profile push
// token. Forgets the user id and segments, drops events queued under the old
// identity, and re-syncs anonymous content. Channel consent is kept.
await cf.logout();

// GDPR right to erasure: the server deletes this device, its linked user data
// and its analytics events. The SDK then clears its local state and starts
// over with a new device id.
await cf.deleteIdentity();

Both throw on failure and leave local state unchanged, so they can be retried.

Track Events

All events use a unified track() method with category-based routing:

Dart
// Universal track() method
final block = cf.getBlock('home_hero');   // the block you rendered

await cf.track(CFEvent(
  type: CFEventType.impression,
  blockKey: 'home_hero',
  instanceId: block?.id,
  screen: 'home',
));

await cf.track(CFEvent(
  type: CFEventType.ctaClick,
  blockKey: 'home_hero',
  custom: {'destination': 'promo_page'},
));

await cf.track(CFEvent(
  type: CFEventType.conversion,
  blockKey: 'checkout_upsell',
  custom: {'orderId': 'ORD-123', 'value': 299.0},
));

// Convenience wrappers
await cf.trackImpression(blockKey: 'home_hero', instanceId: block?.id, screen: 'home');
await cf.trackCtaClick(blockKey: 'home_hero', custom: {'destination': 'promo_page'});
await cf.trackConversion(blockKey: 'checkout_upsell', custom: {'orderId': 'ORD-123'});

// Or use trackEngagement for explicit control
await cf.trackEngagement(
  type: CFEventType.impression,
  blockKey: 'home_hero',
  instanceId: block?.id,
  screen: 'home',
);

Identity Events

Dart
// Identifies the device as the user, then records the event
await cf.trackSignUp(
  username: 'user@example.com',
  fullName: 'Ahmed Mohammed',
  email: 'user@example.com',
  phone: '+966501234567',
);

await cf.trackSignIn(username: 'user@example.com');

// Equivalent forms
await cf.trackIdentity(type: CFIdentityEventType.signIn, username: 'user@example.com');
await cf.track(CFEvent.identity(type: CFIdentityEventType.signIn, username: 'user@example.com'));

trackSignUp / trackSignIn: sign-up and sign-in go to POST /track-event: the SDK first identifies the device as username, then records the event, and throws if the server does not confirm it. signOut and profileUpdate are ordinary analytics events.

Push Notifications

The SDK registers a token your app obtains; it has no Firebase dependency. With package:firebase_messaging (Android 13+ also needs the POST_NOTIFICATIONS runtime permission, and iOS needs the Push Notifications capability):

Dart
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';

await cf.identify(userId: 'user_123');            // topics and preferences need a signed-in device
await FirebaseMessaging.instance.requestPermission();
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null) return;

// Register FCM/APNs token (use CFPushProvider.apns with a raw APNs token)
await cf.registerPush(CFPushConfig(
  token: fcmToken,
  platform: Platform.isIOS ? CFPushPlatform.ios : CFPushPlatform.android,
  provider: CFPushProvider.fcm,
  topics: ['promotions', 'updates'],
  preferences: CFPushPreferences(
    quietHours: CFQuietHours(
      enabled: true,
      start: '22:00',
      end: '08:00',
    ),
  ),
));

// Update topics
await cf.updatePushTopics(CFTopicUpdate(
  subscribe: ['flash_sales'],
  unsubscribe: ['daily_digest'],
));
final topics = await cf.getPushTopics();

// Remove this device's token (idempotent; channel consent is unchanged).
await cf.unregisterPush();
Dart
// 1. Consent for a named user needs the device identified as that user first.
await cf.identify(userId: 'user_123');

// 2. Analytics consent, from the user's choice (gates events, location and string sensors)
await cf.setConsent(userAcceptedAnalytics);

// 3. Per-channel consent, after identify on this device.
await cf.setChannelConsent('user_123', const CFChannelConsent(
  push: true,
  sms: true,
  email: true,
  whatsapp: false,
));

setChannelConsent sends POST /consent with {deviceId, username, channels: {...}}. The channels are push, sms, whatsapp, email, locationTracking and marketing (sent only when set). Every other flag is sent too, unset ones as false, so pass the user's full choice. inApp isn't a channel of this route and isn't sent; use requestConsent for it.

The server records consent only for the user this device is identified as. If this device isn't identified as userId, the call throws a StateError and sends nothing. A write the server refuses or doesn't confirm throws CFRequestException with the server's code (for example DEVICE_NOT_IDENTIFIED or USER_MISMATCH).

Observe Block Changes

Dart
// Subscribe to real-time updates
final unsubscribe = cf.observeBlock('promo_banner', (block) {
  if (block != null) {
    setState(() => _banner = block);
  }
});

// Later: stop observing
unsubscribe();

Show in-app messages

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

getBlocks('journey_message') lists every pending message in delivery order (newest first); getBlock('journey_message') still returns one (the last delivered, so the oldest), and blocks holds them all.

Dart
final messages = cf.getBlocks('journey_message');
for (final m in messages) {
  showMessage(m.id, m.title ?? '', m.body ?? '', m.imageUrl, m.cta);   // cta: label + action (#cta_url)
  await cf.trackImpression(blockKey: m.key, instanceId: m.id);          // once, when on screen
}

Future<void> onButton(CFBlock m) =>
    cf.trackCtaClick(blockKey: m.key, instanceId: m.id);       // counts the click once and removes it

Future<void> onClose(CFBlock m) =>
    cf.trackDismiss(blockKey: m.key, instanceId: m.id);        // removes it

Always pass instanceId: without it the event can't be matched to the message. A dismiss, cta_click or tap on a message hides it on this device straight away: getBlock, getBlocks, blocks, observeBlock and the list sync() returns leave it out, across restarts and even without analytics consent (the event itself is still sent only with setConsent(true)). The device keeps only the message id, for 8 days, and forgets it on logout(), a switch to another user, a complete deleteIdentity() and resetFrequency(). With consent, the event also stops the server delivering the message; otherwise it expires at the campaign's end, or after 7 days. Showing one message at a time with observeBlock('journey_message', ...) also works: after the user closes it, the callback gets the next one. Details: SDK guide: Show in-app messages.

Location Tracking

The SDK uses geolocator, so declare the permissions in your app first:

XML
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
XML
<!-- ios/Runner/Info.plist -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby branches and offers.</string>

The first requestLocation/startLocationTracking call shows the OS permission prompt, so make it from a screen that explains why. If the user denies it, startLocationTracking throws. Nothing is sent to ContentFlow without setConsent(true).

Dart
// Request single location
final location = await cf.requestLocation(
  mode: CFLocationMode.balanced,
  timeoutMs: 10000,
);
print('Location: ${location?.lat}, ${location?.lng}');

// Start continuous tracking
await cf.startLocationTracking(CFLocationTrackingConfig(
  mode: CFLocationMode.balanced,
  intervalMs: 30000,
  distanceFilterMeters: 50,
  onUpdate: (loc) => print('New location: ${loc.lat}, ${loc.lng}'),
  onError: (e) => print('Location error: $e'),
));

// Stop tracking
await cf.stopLocationTracking();

Geofencing

Dart
// Register circular geofence
cf.registerGeofence(
  CFGeofence.circle(
    id: 'store_entrance',
    lat: 24.7136,
    lng: 46.6753,
    radius: 100,
  ),
  onEnter: true,
  onExit: true,
  onDwell: true,
  dwellTimeMs: 300000,
);

// Register polygon geofence
cf.registerGeofence(
  CFGeofence.polygon(
    id: 'delivery_zone',
    lat: 24.7136,
    lng: 46.6753,
    points: [
      [24.71, 46.67], [24.72, 46.67],
      [24.72, 46.68], [24.71, 46.68],
    ],
  ),
);

// Geofence events are automatically reported to the server
// and trigger matching campaigns

// Unregister when done
cf.unregisterGeofence('store_entrance');

Fences registered this way are evaluated in the app. Their events count in analytics only when the dashboard has a fence with the same id: device reports for an id the dashboard doesn't have no longer reach analytics. For fences managed in the dashboard, use on-device geofences (below).

On-device geofences

registerGeofence (above) evaluates fences in Dart against the fixes of startLocationTracking, so it needs continuous location. On-device geofencing instead registers the workspace's fences with the OS (iOS region monitoring, Android GeofencingClient), which wakes the app on a transition without continuous GPS. The SDK reports each transition to ContentFlow, and the server dedupes against its own evaluation, so a stay fires once.

The Flutter SDK ships no native code. It is a pure Dart package, so it cannot call CLLocationManager or GeofencingClient itself. The SDK does everything else (fetching the fences, picking the nearest that fit the OS limit, re-selecting as the device moves, dwell, reporting, consent and the workspace switch) and hands the OS calls to a CFGeofenceMonitor you implement with the geofencing plugin your app already uses.

Dart
abstract class CFGeofenceMonitor {
  int get platformCap;            // default: 20 on iOS, 100 on Android
  bool get supportsOsDwell;       // default false: dwell is computed by the SDK
  Future<bool> hasPermission();   // "Always" / background location granted?
  Future<Set<String>> monitoredRegionIds();   // every region, the app's own included
  Future<CFCoarsePosition?> lastKnownPosition();  // no new fix
  Future<void> startMonitoring(CFMonitoredRegion region);
  Future<void> stopMonitoring(String identifier);
  Future<void> clearSdkRegions();   // optional, default no-op
  Stream<CFRegionTransition> get transitions;
}

OS geofence registrations outlive the app process, so your plugin must let the SDK clear SDK regions left by an earlier launch: monitoredRegionIds() must list regions the OS still holds (not only those registered in this process), or clearSdkRegions() must remove them (on Android, for example, removeGeofences with the PendingIntent used for SDK regions). Once per process before the first registration, and on every teardown (stop, consent or switch off), the SDK calls clearSdkRegions() and stops every cf.geo. id the monitor lists; app regions are never touched.

An adapter sketch. geolocator is already a dependency of this SDK; MyGeofencePlugin stands for the geofencing plugin you choose, so adapt the calls to its API:

Dart
class PluginGeofenceMonitor extends CFGeofenceMonitor {
  final _events = StreamController<CFRegionTransition>.broadcast();

  PluginGeofenceMonitor() {
    MyGeofencePlugin.onEvent((id, event, position) => _events.add(CFRegionTransition(
          identifier: id,
          type: switch (event) {
            'enter' => CFRegionTransitionType.enter,
            'exit' => CFRegionTransitionType.exit,
            _ => CFRegionTransitionType.dwell,
          },
          lat: position?.latitude,
          lng: position?.longitude,
        )));
  }

  @override
  bool get supportsOsDwell => Platform.isAndroid; // if the plugin passes the loitering delay through

  @override
  Future<bool> hasPermission() async => await Geolocator.checkPermission() == LocationPermission.always;

  @override
  Future<Set<String>> monitoredRegionIds() => MyGeofencePlugin.registeredIds();

  @override
  Future<CFCoarsePosition?> lastKnownPosition() async {
    final p = await Geolocator.getLastKnownPosition();
    return p == null ? null : CFCoarsePosition(p.latitude, p.longitude, accuracy: p.accuracy);
  }

  @override
  Future<void> startMonitoring(CFMonitoredRegion r) => MyGeofencePlugin.add(
        id: r.identifier, lat: r.lat, lng: r.lng, radius: r.radiusMeters,
        onEnter: r.notifyOnEntry, onExit: r.notifyOnExit,
        onDwell: r.notifyOnDwell, loiteringDelayMs: r.loiteringDelayMs);

  @override
  Future<void> stopMonitoring(String id) => MyGeofencePlugin.remove(id);

  @override
  Stream<CFRegionTransition> get transitions => _events.stream;
}

await cf.startGeofencing(PluginGeofenceMonitor());
// later
await cf.stopGeofencing();

To turn geofencing off in a launch that has not called startGeofencing (for example the user switched it off, and the app relaunched), pass the monitor so the SDK can sweep the regions an earlier launch registered: await cf.stopGeofencing(monitor: PluginGeofenceMonitor());. Calling startGeofencing while consent or the location switch is off sweeps them too. App regions are never touched.

If your plugin delivers transitions to a background isolate that creates its own ContentFlow client, start geofencing there too and pass each event with cf.handleGeofenceTransition(...).

What the SDK does

Opt-in and consent. Nothing happens until the app calls startGeofencing. It runs only while analytics consent is granted (setConsent(true)), the workspace's location switch is on, and location consent has been explicitly granted through the SDK (setChannelConsent(userId, CFChannelConsent(locationTracking: true)) or requestConsent(location: true), in this launch or an earlier one). Never asked counts as not granted, so geofencing stays idle until the grant (#1030), the same as the JS, Swift and Kotlin SDKs. A grant takes effect once the server confirms it; a revoke (locationTracking: false) takes effect on the device immediately, even if the request fails, and calling startGeofencing again does not undo it. GET /geofences returning no fences without location consent is the server-side backstop; the server's consent refusal of POST /location is keyed on analytics consent, not location consent. When any of these turns off, the SDK unregisters every region it registered and drops unsent events; it resumes when they turn back on, unless the app called stopGeofencing. A report refused for consent (LOCATION_CONSENT_REQUIRED) also unregisters everything until the next confirmed location-consent write or startGeofencing. Sign-out and deleteIdentity() unregister everything and forget the fences; unsent events are never sent as another user.

Limits. iOS: 20 regions per app, shared with the app's own regions. Android: 100 per app. Your plugin may have a lower limit; override platformCap.

Permissions your app must add (the SDK never requests them):

Before the server route ships the fetch answers 404, and geofencing stays idle without errors.

Campaigns

The server evaluates eligibility (segments, platform, language) and returns campaigns in descending priority.

Dart
// Get campaigns for a placement (locale defaults to the current locale)
final campaigns = await cf.getCampaigns('home_hero', locale: 'ar');

for (final campaign in campaigns) {
  print('${campaign.name}: ${campaign.title}');
  if (campaign.isActive) {
    // Display campaign
  }
}

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

Marketers can give a block instance or an in-app campaign a frequency rule in the portal: at most N impressions or taps per hour, day, week, month or ever, at most N per session, a minimum interval, or "until the customer taps, closes or converts". The server already withholds an item that is capped for the user or device. The SDK also keeps a small local ledger with the same rule, so caps hold before the event batch is sent, offline, from cache and within a session. The ledger is updated by the track calls you already make: trackImpression, trackCtaClick, trackEngagement(type: CFEventType.tap), trackDismiss and trackConversion for blocks, and trackCampaignImpression, trackCampaignTap, trackCampaignDismiss and trackCampaignConversion for campaigns.

For a modal, popup or interstitial, ask for one with getPopup(screen). It returns at most one interruptive item for the screen: its blocks and, while the campaigns switch is on, the in-app campaigns for the screen placement, highest priority first, after each item's own rule and the workspace popup limits (config.frequency.fatigue: per session, per day, minimum gap). It returns null when nothing may be shown.

Dart
final popup = await cf.getPopup('home');
switch (popup) {
  case CFBlockPopup(:final block):
    await cf.trackImpression(blockKey: block.key, instanceId: block.id, screen: 'home');
    showMyBlockDialog(block, onClose: () => cf.trackDismiss(blockKey: block.key, instanceId: block.id));
  case CFCampaignPopup(:final campaign):
    await cf.trackCampaignImpression(campaign);
    showMyCampaignDialog(campaign, onClose: () => cf.trackCampaignDismiss(campaign));
  case null:
    break;
}

// Any block or campaign you render yourself:
if (cf.shouldShow(block)) render(block);
print(cf.frequencyReason(block)); // e.g. CFSuppressionReason.cappedImpressions, or null

// Debug builds: forget this device's local counts so a "once ever" popup shows again.
cf.resetFrequency();

frequencyReason returns cappedImpressions, cappedClicks, cappedSession, cooldown, stoppedOnCtaClick, stoppedOnDismiss, stoppedOnConversion or workspaceGuard (.value is the wire name, such as capped_impressions).

Session id

The SDK keeps one session id and sends it as X-CF-Session-Id on the reads that deliver popups (sync, fetchBlock, getCampaigns) and as context.sessionId on event batches, so the server can tell which session served a popup. A new session (and a new id) starts exactly when the popup frequency ledger starts one: a cold start, returning after 30 minutes in the background, resetFrequency(), logout() or a complete deleteIdentity(). An event keeps the id of the session it was tracked in, even if it is sent later. The id is a random UUID, carries no personal data, and is not kept across app restarts.

Built-in campaign popup

presentPopup(screen) asks getPopup(screen) and, for an in-app campaign, draws it for you: a centered card over a dimmed backdrop with the campaign image, title, body, CTA button and a close button. The SDK tracks one impression when it appears, then cta_click (CTA) or dismiss (close button, backdrop tap, system back; a close the user did not make, such as the app popping the route, ends it with no dismiss). Images load over https only.

A block popup goes to popups.onBlockPopup when you set one (you draw and track it, as before). Without it, a block that uses the standard fields is drawn in the same card and tracked by the SDK with the block events (impression, then cta_click or dismiss, so frequency rules and statistics count it): #title and/or #body (at least one non-empty), optional #image (image URL), #cta_label and #cta_url, and #cta_visible: false to hide the button. Any other block returns not_handled. CFPopupOptions(blockPopups: CFBlockPopupMode.off) never draws a block (the 2.4.0 behaviour). renderCampaignPopup applies to campaigns only.

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

Dart
final cf = await ContentFlow.create(ContentFlowConfig(
  publicKey: 'ws_abc123_app',
  popups: CFPopupOptions(
    navigatorKey: navigatorKey,      // or pass `context:` to presentPopup
    onAction: (item, url) async {
      // Open the CTA link (deep link or web URL). The SDK has no URL launcher.
      // For a block drawn by the SDK, item is a CFBlockActionContext (item.block).
      return myRouter.open(url);     // true = handled
    },
    onBlockPopup: (block) => showMyBlockDialog(block), // optional: you draw blocks
    // blockPopups: CFBlockPopupMode.off,              // never draw blocks (2.4.0)
    theme: const CFPopupTheme(accentColor: Color(0xFF0B6E4F)),
  ),
));

final result = await cf.presentPopup('home', context: context);
// result.shown, result.kind (campaign/block), result.id,
// result.reason: none, already_showing, not_handled, features_off, no_ui_context

To present automatically on screen changes, turn on autoPresent (off by default, so upgrading never shows new UI) and report screens, either with the observer (route names are the screens) or per screen:

Dart
MaterialApp(
  navigatorKey: navigatorKey,
  navigatorObservers: [ContentFlowPopupObserver()],
  ...
);
// or, without named routes:
ContentFlowPopupHost(screen: 'home', child: HomePage());
// or by hand:
cf.screenChanged('home');

With CFPopupOptions(autoPresent: true, delayMs: 800) the popup appears 800 ms after the screen change (0..60000); moving to another screen first cancels it.

Dart
// Get consent preset configuration
final preset = await cf.getConsentConfig('banking_ksa');   // banking_ksa, insurance_ksa, healthcare_ksa, travel_ksa, retail_ksa, food_delivery_ksa, fintech_ksa
print('Required: ${preset?.required}');

// Request full consent with preset
final consent = await cf.requestConsent(
  preset: 'banking_ksa',
  channels: CFChannelConsent(push: true, sms: true),
  marketing: CFMarketingConsent(promotional: true, transactional: true),
  location: true,
  version: '2.0',
  legalBasis: 'consent',
);

Localization

Dart
// Locales the workspace offers (e.g. for a language picker)
final catalog = await cf.getLocales();
final offered = catalog?.locales.where((l) => l.coverage >= 80);

// Switch language: re-syncs blocks; then reload strings
await cf.setLocale('ar');
final strings = await cf.getStrings();
print(strings['welcome.title']);

String sensors

Keys the workspace marks as sensors can report when they are shown or used. Only keys in the last catalog loaded by getStrings() are tracked, analytics consent is required, and each impression (per source and key) is sent once per session.

Dart
await cf.trackStringImpression('welcome.title', source: StringImpressionSource.viewport);
await cf.trackStringInteraction('welcome.cta');

Registering strings (server-side only)

registerStrings creates or updates translation keys and their source text. It needs a write key, so it must never run in a shipped app. The Flutter SDK needs a running Flutter app (it uses shared_preferences), so from a build script or CI call the REST route it wraps:

Terminal
curl -X POST https://api.contentflow.click/api/v1/strings/sync \
  -H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
  -d '{"strings":[{"key":"welcome.title","sourceText":"Welcome","description":"Home hero title"}]}'

From an internal Flutter tool (never a store build) the same call is:

Dart
final result = await cf.registerStrings([
  CFStringRegistration(key: 'welcome.title', defaultValue: 'Welcome', context: 'Home hero title'),
], writeKey: const String.fromEnvironment('CF_WRITE_KEY'));
print('${result.registered} created, ${result.updated} updated');

disableStrings stops tracking keys; their text is still served to apps. It turns their sensors off, so the SDK stops sending string events for them (they drop out of smartKeys on the next strings refresh) and the server rejects any that still arrive. Same write-key rule: from a build script or CI, call the route directly:

Terminal
curl -X POST https://api.contentflow.click/api/v1/strings/disable \
  -H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
  -d '{"keys":["legacy.promo_title","legacy.promo_body"]}'

or, from an internal Flutter tool:

Dart
final result = await cf.disableStrings(
  ['legacy.promo_title', 'legacy.promo_body'],
  writeKey: const String.fromEnvironment('CF_WRITE_KEY'),
);
print('${result.disabled.length} disabled, ${result.alreadyDisabled.length} already off, unknown: ${result.unknown}');

It takes 1 to 500 non-empty keys of at most 200 characters; duplicates are dropped, and anything else throws ArgumentError before a request is sent. Like registerStrings, a failed request throws (DioException). Tracking is turned back on in the portal (Sensors → Enable sensors); there is no SDK call for it, and registerStrings never changes whether a key is tracked.

KYC Verification

Not available yet. submitKYC and getKYCState call /kyc/* routes that the SDK API does not expose, so they currently fail. They are kept for API stability until a backend route exists.

Dart
// Submit KYC verification
final result = await cf.submitKYC(CFKYCSubmission(
  level: CFKYCLevel.standard,
  verificationType: CFKYCVerificationType.document,
  verificationProvider: 'nafath',
  documentHashes: CFDocumentHashes(
    nationalId: 'sha256_hash_here',
    selfie: 'sha256_hash_here',
  ),
));
print('Verification ID: ${result.verificationId}');

// Check KYC state
final state = await cf.getKYCState();
print('Level: ${state?.level}, Status: ${state?.status}');

Industry-Specific Tracking

Industry events use the unified track() method or the trackIndustry() convenience wrapper:

Dart
// Universal track() method
await cf.track(CFEvent.industry(
  type: 'transfer_completed',
  industry: CFIndustryType.banking.value,
  data: {
    'transactionId': 'TXN-123',
    'amount': 500.0,
    'currency': 'SAR',
    'type': 'internal',
  },
));

await cf.track(CFEvent.industry(
  type: 'claim_submitted',
  industry: CFIndustryType.insurance.value,
  data: {
    'claimId': 'CLM-456',
    'policyId': 'POL-789',
    'policyType': 'motor',
    'amount': 15000.0,
  },
));

await cf.track(CFEvent.industry(
  type: 'appointment_booked',
  industry: CFIndustryType.healthcare.value,
  data: {
    'appointmentId': 'APT-001',
    'providerId': 'DR-123',
    'specialty': 'cardiology',
    'date': '2026-09-15',
    'type': 'video',
  },
));

// Convenience wrapper
await cf.trackIndustry(
  industry: CFIndustryType.banking,
  eventType: 'transfer_completed',
  data: {
    'transactionId': 'TXN-123',
    'amount': 500.0,
    'currency': 'SAR',
    'type': 'internal',
  },
);

await cf.trackIndustry(
  industry: CFIndustryType.insurance,
  eventType: 'claim_submitted',
  data: {
    'claimId': 'CLM-456',
    'policyId': 'POL-789',
    'policyType': 'motor',
    'amount': 15000.0,
  },
);

await cf.trackIndustry(
  industry: CFIndustryType.healthcare,
  eventType: 'appointment_booked',
  data: {
    'appointmentId': 'APT-001',
    'providerId': 'DR-123',
    'specialty': 'cardiology',
    'date': '2026-09-15',
    'type': 'video',
  },
);

Industry Profile

Not available yet. updateIndustryProfile calls a route that the SDK API does not expose, so it currently fails. It is kept for API stability until a backend route exists.

getIndustryProfile is deprecated and always returns null without a request. Industry data is write-only from the SDK: your app already holds it, and ContentFlow uses it server-side. There is no read-back route, by design.

Dart
// Update industry-specific profile
await cf.updateIndustryProfile(CFIndustryProfileUpdate(
  industry: CFIndustryType.banking,
  profile: {
    'accountType': 'premium',
    'kycLevel': 'enhanced',
    'creditScore': 750,
  },
));

SSE Live Updates

The SDK listens on GET /stream. The server sends a change signal (block.updated, block.published, ...) whenever content changes; the SDK re-syncs and emits every block whose content changed.

Dart
// Connect to live updates
await cf.connectLiveUpdates();

// Listen for real-time block changes
cf.liveUpdates.listen((block) {
  print('Block updated: ${block.key}');
  // Update UI
});

// Disconnect when done
await cf.disconnectLiveUpdates();

Workspace feature switches

A ContentFlow platform operator can turn some built-in features off for your workspace. The switches arrive with every sync (data.config.features), so there is no extra request, and a 304 keeps the last value. Until a sync says otherwise, and for any switch the server doesn't send, every feature is on.

Switch When it is off
liveUpdates connectLiveUpdates() opens no stream and an open one is closed. Content stays fresh through the normal sync polling. The stream reopens when the switch turns back on, if you called connectLiveUpdates() and not disconnectLiveUpdates().
campaigns getCampaigns() returns an empty list without a request.
location Tracking pauses, points collected but not yet sent are dropped (the server would drop them too), and location batches and geofence events are not sent. Tracking resumes when the switch turns back on, if you started it and didn't stop it.
frequencyCaps The server applies no popup frequency rule, and the SDK ignores its local ledger: shouldShow is always true (new in 2.4.0; see Popup frequency).
contentSync The server keeps the content you already have (a 304), or answers an empty list on a first install, so your built-in defaults show.
strings The server keeps the strings you already have, or answers an empty catalog, so your bundled strings show.
analyticsEvents The server acknowledges and drops every event (dropReasons.feature_disabled); nothing is recorded.
pushPreferences Topic and preference changes are accepted but not stored. Push token registration keeps working.
geofences The server sends no fences to monitor.
profileUpdates The device is still registered and erasure is still enforced, but nothing is written to the customer's profile; the device keeps its last known segments.
Dart
final features = cf.features; // SdkFeatures(liveUpdates, campaigns, location, frequencyCaps, contentSync, ...)
cf.onFeaturesChange.listen((f) => print('Location allowed: ${f.location}'));

A switch can only pause a feature your app started. It never starts one, never grants consent (location still needs analytics consent), and never changes the base URL, keys, authentication or certificate pinning.

Offline Resilience & Security

Offline Event Queue

Events are automatically queued when offline and retried with exponential backoff:

Dart
// Events are queued automatically when offline
await cf.track(CFEvent(type: CFEventType.conversion, blockKey: 'promo'));

// Check queue status
print('Pending events: ${cf.pendingEventCount}');
print('Online: ${cf.isOnline}');

// Manual flush
await cf.flushOfflineQueue();

// Clear queue
await cf.clearOfflineQueue();

Request Signing

HMAC-SHA256 request signing prevents tampering and replay attacks:

Dart
final cf = await ContentFlow.create(
  ContentFlowConfig(
    publicKey: '<workspace-id>_test',
    enableRequestSigning: true,  // Adds X-CF-Timestamp & X-CF-Signature
  ),
);

Certificate Pinning

Optional public-key (SPKI) pinning. Off by default. Leave it off unless you control the server's TLS key and have a rotation plan, because a wrong or stale pin makes every request fail until users install an app update.

When enabled, each HTTPS connection is checked in this order:

  1. The platform's normal validation: the certificate chain must lead to a trusted root, and the certificate must match the hostname. A matching key never overrides a failure here.
  2. Right after the TLS handshake, and before any request bytes are written, the leaf certificate's public key must match one of your pins. On a mismatch the connection is closed and the request fails with a DioException whose error is a CFCertificatePinningException (a HandshakeException). The server receives nothing: no URL, no headers (including X-CF-Key), no body.

The live-updates stream (connectLiveUpdates) uses the same pinned client.

Pins

Use sha256/<base64> pins: the SHA-256 of the leaf's DER-encoded SubjectPublicKeyInfo. A key pin keeps working when the certificate is renewed, as long as the renewal reuses the same key.

Terminal
# Pin of the key the server presents today
openssl s_client -connect api.contentflow.click:443 -servername api.contentflow.click </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary | openssl base64

# Pin of a backup key, generated now and kept offline until it is needed
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out backup-key.pem
openssl pkey -in backup-key.pem -pubout -outform der | openssl dgst -sha256 -binary | openssl base64

CFCertificatePinner.spkiPin(cert.der) computes the same value in Dart.

Current pins (certificates valid until 2026-12-11): api.contentflow.click (the default from 2.2.1) is sha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8=; app.contentflow.click (the default host in 2.2.0 and earlier) is sha256/A3O5BGFGDQZm4wNh1QpbA4QYeI0vT6D5tCSOy65IrhI=.

At least two distinct pins are required (the current key and a backup key). Enabling pinning with fewer, or with a malformed pin, throws an ArgumentError from ContentFlow.create; it never falls back to running unpinned.

Dart
final cf = await ContentFlow.create(
  ContentFlowConfig(
    publicKey: '<workspace-id>_app',
    enableCertificatePinning: true,
    pinnedCertificates: [
      'sha256/<current key, base64>',
      'sha256/<backup key, base64>',
    ],
  ),
);

Rotation

Limits

Configuration Options

Option Type Default Description
publicKey String required SDK key from dashboard
locale String? null Content locale (en, ar)
consent bool false Initial analytics consent
userId String? null Known user ID
traits Map? null Initial user traits
baseUrl String --dart-define=CONTENTFLOW_BASE_URL, else https://api.contentflow.click/sdk/v1 SDK API base. The legacy …/api/v1/sdk form and the portal host still work but log a one-time notice; see Configuring the base URL
pollMs int 30000 Background sync interval
timeoutMs int 10000 Request timeout
eventBatchSize int 10 Events per batch
eventFlushMs int 15000 Max batch wait time
cache bool true Enable persistent cache
debug bool false Enable debug logging
onError void Function(Object)? null Errors the SDK recovered from, such as a CFSyncException
enableOfflineQueue bool true Persist events when offline
maxOfflineQueueSize int 100 Max queued events (FIFO)
maxRetries int 5 Retries before dropping event
enableRequestSigning bool false HMAC-SHA256 signing
enableSecureStorage bool true Obfuscates (XOR, not encryption) the stored device and user IDs; use flutter_secure_storage for real encryption
enableCertificatePinning bool false Public-key (SPKI) pinning
pinnedCertificates List [] sha256/<base64> SPKI pins, at least 2
rateLimitPerSecond int 10 Max requests/second

Models

CFBlock

Dart
block.id          // Instance ID (the delivered instanceId); also block.instanceId
block.key         // Block definition key
block.name        // Block name
block.screen      // Screen the block is authored for
block.segment     // Targeted segment ('' when untargeted)
block.version     // Instance content version
block.values      // All field values, keyed by tag ('#title')
block.schema      // Field schema: List<CFFieldSchema> of tag + type
block.variantId   // A/B variant (if any)

// Field access (tag with or without '#')
block.get('#title')           // Any field value
block.field<String>('title')  // Typed field access
block.typeOf('#hero')         // Declared type, e.g. 'image'
block.getCollection('#cards') // A collection field's cards, each a CFBlock

// Convenience accessors (same tag conventions as the JS SDK)
block.title       // #title_main, #title, #headline, #screen_title, #tile_title
block.body        // #body_desc, #body, #subtext, #desc
block.imageUrl    // declared image fields, then #header_image, #image, ...
block.ctaLabel    // #cta_label, #cta_text, #btn_label
block.cta         // CFBlockCta(label: ctaLabel, action: #cta_url or #cta_action)
block.ctaVisible  // false only when #cta_visible is false
block.enabled     // false only when #block_enabled / #enabled is false

block.fields still works but is deprecated: it returns values.

CFEvent Types

Error Handling

Reads (sync, getStrings, getCampaigns, getLocales, getPushTopics, requestConsent) never throw: on failure they log (with debug: true) and return cached or empty values. Calls that write identity or consent throw on failure (usually a DioException from package:dio): identify, setUserId, updateTraits, setConsent, setChannelConsent, registerPush, unregisterPush, updatePushTopics, logout, deleteIdentity, trackSignUp, trackSignIn, registerStrings and disableStrings. setChannelConsent throws CFRequestException (with the server's code) when the server refuses the write. track() queues the event and does not throw.

A /sync response the SDK can't use is reported as a CFSyncException through ContentFlowConfig.onError and cf.errors; ordinary network outages are not.

Dart
import 'package:dio/dio.dart' show DioException;

// sync() never throws: on failure it returns the last cached blocks.
final blocks = await cf.sync();

// Guard the calls that write to the server:
try {
  await cf.identify(userId: 'user_123');
  await cf.trackSignIn(username: 'user_123');
} on DioException catch (e) {
  debugPrint('ContentFlow ${e.response?.statusCode}: ${e.response?.data}');
}

// Report unusable sync responses and refused writes:
await ContentFlow.create(ContentFlowConfig(
  publicKey: '<workspace-id>_app',
  onError: (error) => reportToCrashlytics(error),
));
ContentFlow.instance.errors.listen((error) {
  if (error is CFSyncException) debugPrint('ContentFlow sync: ${error.message}');
});

Refused SDK key

The client stops for good when the server refuses its SDK key: an HTTP 401 (any code, or none), or a 403 with code INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH. Any other 403, such as DEVICE_NOT_IDENTIFIED, USER_MISMATCH or a location refusal, is a refusal of that one request and leaves the key in use.

When the key is refused, the client:

The state isn't persisted. To resume, create a new client with a valid key (ContentFlow.create(newConfig)), or restart the app. Write-key calls (registerStrings, disableStrings, syncBlocks) are about the write key and never trigger it.

Retries and queue limits

Only failures with no response, 429 and 5xx are retried, with exponential backoff and jitter (about 1 s doubling to at most 5 minutes). A Retry-After header (seconds or HTTP date, at most 1 hour) is the minimum wait. Any other 4xx drops the batch. The live-updates stream reconnects after it drops or fails to connect, with the same backoff.

Every retry queue holds at most 500 events; past that the oldest are dropped. Dropped events (queue full, refused with a 4xx, or out of retries in the offline queue) are reported as a CFDroppedEventsException with counts by reason, at most once a minute.

Dart
ContentFlow.instance.errors.listen((error) {
  if (error is CFInvalidKeyException) {
    reportToCrashlytics('ContentFlow key refused: ${error.code}, dropped ${error.droppedCount}');
  } else if (error is CFDroppedEventsException) {
    debugPrint('ContentFlow dropped ${error.droppedCount} events: ${error.counts}');
  }
});

Cleanup

Dart
// Dispose when done (e.g., app termination)
cf.dispose();

Support

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.