ContentFlow SDK for Flutter
Version: 2.4.1
What's new in 2.4.1
- Action needed:
presentPopupandautoPresentnow draw a block popup that uses the standard fields (a non-empty#titleor#body, like the Standard popup) in the built-in card, where 2.4.0 returnednot_handled. SetCFPopupOptions(blockPopups: CFBlockPopupMode.off)to keep the 2.4.0 behaviour. - Action needed:
popups.onActioncan now receive a block: its first argument is aCFBlockActionContext(item.block) for a block card. - Fixed: a closed in-app message stays closed without analytics consent, and every pending in-app message is kept, not one per key.
See the changelog.
What's new in 2.4.0
- Action needed: on-device geofencing stays idle until the app grants location consent with
setChannelConsent(userId, CFChannelConsent(locationTracking: true))orrequestConsent(location: true); location consent that was never asked no longer counts as granted. - New: popup frequency control (
shouldShow,frequencyReason,getPopup,resetFrequency(), a local ledger, campaign tracking calls); an SDK-drawn campaign popup (presentPopup,closePopup,screenChanged,ContentFlowPopupObserver,ContentFlowPopupHost,ContentFlowConfig.popups), withautoPresentoff by default;trackCampaignCtaClick. - Fixed: one session id per session, sent as
X-CF-Session-Idonsync,fetchBlockandgetCampaigns;getConsentConfig(preset)keeps the whole preset; a location revoke is no longer cleared bystartGeofencing. - Changed: requests declare
X-CF-SDK-Capabilities: erasure-partial-v1,frequency-v1.
See the changelog.
What's new in 2.3.0
- Action needed:
deleteIdentity()throwsCFRequestExceptionon a partial erasure (the server's 207) and keeps the device id and local state; it used to wipe them as if the erasure had completed. Catch it and calldeleteIdentity()again to finish. - Action needed: a refused key (HTTP 401, or 403
INVALID_SDK_KEY/MISSING_SDK_KEY/SDK_KEY_IN_QUERY/TENANT_KEY_MISMATCH) now stops the client for that key: sync and live updates stop, queues are dropped, andCFInvalidKeyExceptionis reported. Create a new client with a valid key, or restart the app, to resume. - Action needed: 2.3.0 is licensed under the proprietary ContentFlow SDK License (
LICENSE); earlier releases keep their terms. - New: on-device geofences through a
CFGeofenceMonitoryour app implements (see On-device geofences), workspace feature switches (ContentFlow.features),disableStrings(keys, writeKey:)(server/CI only). - Fixed: retries cover only transport failures, 429 and 5xx, with backoff and
Retry-After; queues hold at most 500 events;submitLocationBatchsends every point, at most 100 per request; live updates reconnect after a drop. - Changed: requests declare
X-CF-SDK-Capabilities: erasure-partial-v1, so a partial erasure answers 207 instead of 502. - Deprecated:
getIndustryProfile()returnsnullwithout a request.
What's new in 2.2.2
- Fixed:
sync()returns blocks from the live SDK API. Blocks are read from the delivered shape:idis theinstanceId, content is invalueskeyed by tag (block.get('#title')), andtitle,body,imageUrlandctaread the conventional tags. A payload the SDK can't use is reported throughonError/errorsinstead of silently returning the cache. - Fixed:
setChannelConsentnests the flags underchannelsand no longer sendsinApp, so the server accepts it. Callidentify(userId: ...)first; otherwise it throws aStateError, and a refused write throwsCFRequestException. - New: every request sends
X-CF-SDK-Version: flutter/2.2.2and an emptyX-CF-SDK-Capabilities, so the server can tell SDK versions apart.
What's new in 2.2.1
- Action needed: the default base URL is now
https://api.contentflow.click/sdk/v1(it washttps://app.contentflow.click/api/v1/sdk). If you enable certificate pinning and don't setbaseUrl, add the api-host pins first; the hosts have different keys (see Certificate Pinning). A base that doesn't end in/sdk/v1logs a one-time "legacy path" notice. - New: configure the base URL without code, with
--dart-define=CONTENTFLOW_BASE_URL=…(see Configuring the base URL).
What's new in 2.2.0
- Breaking / action needed: sign-up and sign-in failures now throw; string-sensor calls send only for keys the catalog marks as sensors; location isn't sent without analytics consent.
- Fixed: string sensors, live updates (
/stream), campaigns (POST), sign-up/sign-in, location,getStringsandsetLocalenow work against the real SDK API routes. - New:
logout(),deleteIdentity(),unregisterPush(),getLocales(),trackSignUp()/trackSignIn(),registerStrings()(server-side only),localegetter. Logout clears cached blocks. - See the changelog.
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.
- Blocks did not load. 2.2.1 couldn't parse the block shape the SDK API delivers, so
sync()returned the empty cache andgetBlockreturnednull. setChannelConsentwas refused with400 CHANNELS_REQUIRED. It now works afteridentify(userId: ...)for that user.
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:
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/# pubspec.yaml
dependencies:
contentflow_sdk:
path: vendor/contentflow-sdk-flutterflutter pub getRequires 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
- Download and verify the new zip as above, and replace
vendor/contentflow-sdk-flutterwith it. - Run
flutter pub get. - From 2.2.0 or earlier: if you set
baseUrl: 'https://app.contentflow.click/api/v1/sdk', change it tohttps://api.contentflow.click/sdk/v1(or remove it). If certificate pinning is on, add theapi.contentflow.clickpin first (see Certificate Pinning). - From 2.1:
trackSignUp/trackSignInnow throw on failure; wrap them intry/catch. - Read the changelog for everything else, then repeat Testing your integration.
Quick Start
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
ContentFlow.create(...): identifies the device anonymously.- After login:
await cf.identify(userId: …)(ortrackSignIn). - After the user agrees:
await cf.setConsent(true). Until then,track, location and string sensors send nothing. - Only then:
requestConsent,registerPushfor that user,updatePushTopics. The server refuses consent and push-topic calls that name a user this device is not identified as. - On sign-out:
await cf.logout().
Testing your integration
- In the dashboard, copy the test key
<workspace-id>_test(Developers → Your keys) and publish the block you are testing to Test. - Check the key and content without the app:
curl -H 'X-CF-Key: <workspace-id>_test' https://api.contentflow.click/sdk/v1/sync. An emptyblocksarray means nothing is published for this key yet. - Run with
debug: true, which logs every request and response,Sync failederrors, andEvent not tracked: consent not grantedfor dropped events:
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.
flutter run --dart-define=CONTENTFLOW_BASE_URL=https://api.contentflow.click/sdk/v1- Validation: the dart-define value must be
https;httpis accepted only forlocalhost,127.0.0.1and10.0.2.2. Any other value is ignored with a logged error, and the default is used. An explicitbaseUrlisn't validated. - Never remote: the base URL is never fetched remotely, because a remote value could redirect your app's traffic and keys and would bypass pinning.
- Legacy notice: a base that doesn't end in
/sdk/v1prints once per client, even withdebugoff:ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).
Core Features
Sync Content
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 tagsync() 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
// 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
// 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:
// 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
// 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):
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();Consent Management
// 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
// 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.
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 itAlways 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:
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /><!-- 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).
// 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
// 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.
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:
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
- Fetches the fences with
GET /geofences(X-CF-Device; the last-knownlatandlng, together or not at all, rounded to 3 decimals and used only for nearest-first ordering;max= the free slots, capped at 100;If-None-Match). After the device leaves the boundary region it refetches withoutIf-None-Match. Sending the ETag would also be correct (the server hashes the list for the rounded position, so a 304 means the nearest set for the new position is unchanged); skipping it just guarantees a fresh list after a move. It refetches when the app returns to the foreground after the server'srefreshAfterSec, and when the device leaves the boundary region. A 304, 502 or 503 keeps the registered regions as they are (the SDK retries at the next refresh); a 200 with no fences unregisters everything. - Registers the nearest fences that fit:
min(maxRegions, platformCap - reserved), wherereservedis the larger ofCFGeofencingOptions.reservedAppRegionsand the regions without thecf.geo.prefix thatmonitoredRegionIds()lists. When the answer saystruncated: true(more eligible fences exist than were returned) and a position is known, the SDK registers one fewer and uses that slot for a boundary region (cf.geo.__boundary) around the current position, reaching the nearest fence left out (200 m to 50 km); leaving it refetches and re-selects. Withtruncated: falseeverything returned is registered with no boundary region, so on Android a full answer of 100 fills all 100 slots. An older server that does not sendtruncatedis detected: that answer counts as truncated when it is as long asmax, and later requests ask for one more than fits (max= free slots + 1) until an answer includestruncatedagain. Without a known position the server order is kept and there is no boundary region. - Registers every selected fence for both enter and exit and reports both, whatever the fence's
enter/exittriggers say: the server needs the exit to close a stay, and the triggers decide only what fires server-side. Dwell is reported only for fences with thedwelltrigger. - Registers the circle the server sends, also for polygon fences (
exact: "polygon"arrives as its bounding circle; the server confirms the exact shape). - Reports each transition to
POST /locationasgeofenceEvents: [{fenceId, event, timestamp, lat, lng, accuracy?, source: "device"}], with the OS transition time astimestamp(the server rejects reports more than 5 minutes in the future or more than 24 hours old), at the triggering position when the plugin supplies one, otherwise at the fence centre. A refusal (success: false, such asDEVICE_NOT_IDENTIFIED) drops the event; a transport error, 5xx or 429 keeps it (up to 50) and sends it with the next transition or foreground. - Dwell: with
supportsOsDwellthe region asks the OS for dwell (notifyOnDwell,loiteringDelayMs = dwellMs, Android). Otherwise the SDK records the enter time (persisted, so a relaunch keeps it) and reports one dwell at the first wake (a transition or the app returning to the foreground) afterdwellMswhile still inside, timestampedmin(enteredAt + dwellMs, now). A computed dwell is therefore sent late but carries the time it became due; it is never reported early. A dwell more than 24 hours old at that wake is not sent (the server would reject it). - Never starts continuous location updates and never prompts for permission. Without permission it stays idle and tries again at the next refresh.
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):
- iOS
Info.plist:NSLocationWhenInUseUsageDescriptionandNSLocationAlwaysAndWhenInUseUsageDescription, pluslocationinUIBackgroundModesif your plugin needs it. Request "Always" authorization. - Android
AndroidManifest.xml:ACCESS_FINE_LOCATIONandACCESS_BACKGROUND_LOCATION(Android 10+), requested at runtime in that order, plus whatever receiver your plugin declares.
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.
// 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
}
}Popup frequency
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.
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).
- Sessions. A new session starts when the app returns after at least 30 minutes in the background, with a new client, or on
resetFrequency(),logout()or a completedeleteIdentity(). - Identity. Counts are kept per user (signed in) and per device, and the larger wins, so a "once" popup seen before login stays seen after it. The ledger is cleared on
logout()and on a completedeleteIdentity(). It holds counters and timestamps only, never content, in the SDK's per-device storage (obfuscated whenenableSecureStorageis on). - Time zone. Hour, day, week (starting Sunday) and month windows use the workspace time zone (
config.frequency.timezone, Asia/Riyadh by default), so "once a day" resets at local midnight. Dart has no time-zone database, so the SDK resolves only fixed-offset zones exactly: Asia/Riyadh, Asia/Kuwait, Asia/Qatar, Asia/Bahrain, Asia/Aden, Asia/Baghdad (UTC+3), Asia/Dubai, Asia/Muscat (UTC+4) and UTC. For any other workspace zone it counts windows in the device's local time, which matches the server only when the device is in that zone. The server applies the rule in the right zone either way. - Consent. Local counting works without analytics consent (nothing leaves the device); server-side caps need analytics consent, since the server only counts events it receives.
- With the
frequencyCapsswitch off,shouldShowis always true and the ledger is not updated.
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.
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_contextTo 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:
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.
- One at a time. While a popup (campaign or block) is on screen,
presentPopupreturnsalready_showing. A block popup handed toonBlockPopupholds the slot until the Future it returns completes. - Closing it yourself.
cf.closePopup()takes down the SDK-drawn popup (a campaign, or a block in the built-in card) with nodismissand nocta_click(the impression stays counted) and frees the slot, so the nextpresentPopupshows. It does nothing when no popup is showing, and a block popup handed toonBlockPopupis yours to close. A pendingautoPresentpresentation is not cancelled. - CTA links. Without
onAction(or when it returns false) the CTA trackscta_clickand closes, and nothing opens: addurl_launcherto your app and call it fromonActionif you want web links opened. Onlyhttp,httpsand app custom schemes are passed on;javascript:,data:,file:,vbscript:,blob:andabout:are refused. - Direction and language. The campaign content and text direction (for a block, the text direction) follow
presentPopup(locale:), else the client locale: Arabic and other RTL locales render right-to-left with the close button at the top left and an Arabic close label. - Accessibility. Dialog semantics named by the title, a 48 dp close button, system text scaling (long bodies scroll), safe areas, and no animation when the system asks for reduced motion.
- Your own design.
renderCampaignPopup: (context, session) => ...replaces the card; callsession.tapCta()andsession.dismiss(), and the SDK still tracks the impression and the terminal event.
Full Consent Flow
// 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
// 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.
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:
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:
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:
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:
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.
submitKYCandgetKYCStatecall/kyc/*routes that the SDK API does not expose, so they currently fail. They are kept for API stability until a backend route exists.
// 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:
// 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.
updateIndustryProfilecalls a route that the SDK API does not expose, so it currently fails. It is kept for API stability until a backend route exists.
getIndustryProfileis deprecated and always returnsnullwithout 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.
// 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.
// 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. |
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:
// 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:
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:
- 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.
- 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
DioExceptionwhoseerroris aCFCertificatePinningException(aHandshakeException). The server receives nothing: no URL, no headers (includingX-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.
# 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 base64CFCertificatePinner.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.
final cf = await ContentFlow.create(
ContentFlowConfig(
publicKey: '<workspace-id>_app',
enableCertificatePinning: true,
pinnedCertificates: [
'sha256/<current key, base64>',
'sha256/<backup key, base64>',
],
),
);Rotation
- Renew certificates with the same key (for certbot:
--reuse-key). Many managed certificate services generate a new key on every renewal; if yours does, key pinning breaks at renewal, so don't enable it. - To retire the current key: move the server to the backup key, then ship an app release whose pins are the backup key plus a newly generated backup. Only apps that already contain the backup pin keep working across the switch.
Limits
- Only the leaf certificate is checked.
dart:ioexposes the server's certificate but not the rest of the chain, so pins of an intermediate or root CA never match. - While pinning is on, the SDK connects directly and ignores
HTTP_PROXY/HTTPS_PROXYenvironment proxies: through a proxy tunnel,dart:iodoes the TLS handshake where the pin check can't run first. Plainhttp://URLs are refused. - Pinning needs
dart:io(iOS, Android, desktop). On web the browser owns TLS, so enabling pinning there throws instead of running unpinned. - Legacy pins written as 64 hex characters (optionally with colons or a
SHA256:prefix) are still accepted. They match the SHA-256 of the whole leaf certificate, so they stop matching at every renewal. Replace them withsha256/key pins.
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
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 falseblock.fields still works but is deprecated: it returns values.
CFEvent Types
CFEventType.impression- Block became visibleCFEventType.tap- User tapped blockCFEventType.ctaClick- User tapped CTACFEventType.dismiss- User dismissed blockCFEventType.conversion- User completed goal
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.
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:
- reports one
CFInvalidKeyExceptionthroughonErrorandcf.errors, and logs it as an error even withdebugoff. It is aCFRequestExceptionwhosecodeis the server's code (INVALID_SDK_KEYfor a 401 without one), withstatusCodeanddroppedCount; - drops every queued event (memory and offline queue) and unsent location point, counted in
droppedCount; - cancels periodic sync, closes the live-updates stream and does not reconnect;
- makes no further request with that key.
sync()returns the cache,track()drops events silently,connectLiveUpdates()andsetChannelConsent()throw theCFInvalidKeyException, and the other calls that throw fail at once with aDioExceptionwhoseerroris theCFInvalidKeyException.cf.invalidKeyErrorholds it. Nothing is reported again.
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.
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
// Dispose when done (e.g., app termination)
cf.dispose();Support
- Dashboard: https://app.contentflow.click
- Documentation: https://dev.contentflow.click/sdk-flutter.html
- Email: support@contentflow.click