Audience & privacy
Everything ContentFlow targets is built on profiles: one per person, assembled from what your app sends on identify, what you upload from your CRM, and what people do in the app. Segments are rules over profiles. Consent decides what may be stored and what may be sent. This guide covers all three, plus the localized strings and assets your content is made of.
- A profile exists only once a device identifies with analytics consent; traits, enrichment uploads and the hourly behaviour roll-up fill it in.
- Segments are rules over profiles; blocks evaluate them at identify time without enrichment rules, while campaigns and journeys use stored membership that supports every rule.
- Analytics consent (
identify/setConsent) and channel consent (setChannelConsent) are separate decisions, and a channel is used only when its flag is exactly true. deleteIdentity()erases the device's data on the server, and the SDK wipes local data only after a full success.- The most common trap: setting consent for a signed-in user before identifying as that user, which is refused with
403 DEVICE_NOT_IDENTIFIED.
Business use cases
Eligibility segments
"Salary over 3,000 SAR and no personal finance" from a CRM upload, used by a banner, a push campaign and a journey branch at once.
New, at-risk, churned
Lifecycle stages computed from activity drive onboarding for new users and win-back for dormant ones.
Engaged non-converters
Hourly analytics roll-ups make "clicked 3+ times, never converted" a segment you can message.
PDPL-ready consent
Industry presets (SAMA, CCHI, CBAHI with PDPL) define required and optional consents and topics per sector.
Profiles and traits
A profile is created when a device identifies with analytics consent, keyed by your user id (or by the device id for a consented install that never signs in). It holds:
| Part | Comes from | Segment field examples |
|---|---|---|
| Traits | identify({ traits }), merged into the profile key by key (the device's own trait set, used for live targeting, is replaced by each identify that sends traits) | customAttributes.plan_tier |
| Demographic | Identify contact fields, your updates | demographic.country, demographic.preferredLanguage |
| Current location | The IP address, on identify | currentLocation.city |
| Behaviour | Analytics roll-up (hourly) | enrichment.analytics_clicks |
| Enrichment | CSV, CRM, ERP, loyalty, manual API | enrichment.salary_band |
| Industry profile | updateIndustryProfile() (see below), server API | industryType |
| Consent | Identify and the consent API | consent.push, consent.marketing |
Trait keys must match ^[a-zA-Z0-9_]{1,64}$, with at most 50 per call and 4 KB per value; other keys are dropped silently. Sending traits: {} never clears anything.
Anonymous to signed-in
A consented install that browses before signing in has its own profile, keyed by the device id. When it identifies with your user id, that anonymous profile merges into the signed-in one: empty contact and demographic fields and traits are filled (the signed-in profile wins conflicts), segments and push topics are combined, and channel consent fills only where the signed-in profile has no recorded decision. Merges are deterministic: only the device's own anonymous profile merges. (A pseudonymous id can be declared with userIdType: 'pseudonymous' only on a server-side profile call; no SDK can send it.) Email and phone never trigger a merge.
Lifecycle stages
Computed when a profile is read, first match wins: new (account under 30 days), churned (inactive over 90 days), dormant (over 60), at-risk (over 30, or a low engagement score), engaged (high engagement score and active this week), otherwise active. Segment on lifecycle, for example in ['dormant', 'churned'].
Industry profile and KYC
An app can send what it knows about the signed-in customer's relationship with you, such as a banking tier or the products they hold, as an industry profile. It goes to POST /sdk/v1/profile with { userId, category, data } and is stored on the person's profile under customAttributes.domainProfile.<industry>, replacing what was there for that industry. Industries: banking, insurance, healthcare, travel, retail, food_delivery and fintech (the native SDKs also have general).
// 1. The device must already be identified as the signed-in user
// (identify with a userId, or setUserId). Without one this throws.
// 2. Send the industry data. It replaces this industry's previous value.
await client.updateIndustryProfile({
industry: 'banking',
profile: { tier: 'gold' },
});
// Throws CFWriteFailedError if the server did not store it.import { useCF } from '@contentflow/sdk/react';
function SaveTier({ tier }: { tier: string }) {
const cf = useCF();
// 1. The device must already be identified as the signed-in user.
// 2. Send the industry data. It replaces this industry's previous value.
const save = () => cf.updateIndustryProfile({ industry: 'banking', profile: { tier } });
return <button onClick={save}>Save</button>;
}import { Button } from 'react-native';
import { useCF } from '@contentflow/sdk/react-native';
function SaveTier({ tier }: { tier: string }) {
const cf = useCF();
// 1. The device must already be identified as the signed-in user.
// 2. Send the industry data. It replaces this industry's previous value.
const save = () => cf.updateIndustryProfile({ industry: 'banking', profile: { tier } });
return <Button title="Save" onPress={save} />;
}// 1. The device must already be identified as the signed-in user
// (identify(userId:)). Without one this throws.
// 2. Send the industry data. It replaces this industry's previous value.
try await client.updateIndustryProfile(
IndustryProfileUpdate(industry: .banking, profile: ["tier": .string("gold")])
)// 1. The device must already be identified as the signed-in user
// (identify(userId = ...)). Without one this throws.
// 2. Send the industry data. It replaces this industry's previous value.
client.extensions.updateIndustryProfile(
IndustryProfileUpdate(IndustryType.Banking, mapOf("tier" to JsonValue.Str("gold"))),
)Not available in the Flutter SDK (2.4.1 and earlier): its updateIndustryProfile calls a route the SDK gateway does not have, so it throws and nothing is stored (noted in the Flutter changelog). Send the same data from your backend as an import instead (Importing users and data), or call the REST route with the app's SDK key and device id.
# 1. The device in X-CF-Device must already be identified as this user.
# 2. Send the industry data. It replaces this industry's previous value.
curl -X POST https://api.contentflow.click/sdk/v1/profile -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' -d '{"userId":"cust_1042","category":"banking","data":{"tier":"gold"}}'- Write-only. There is no way to read the profile back from a device.
getIndustryProfile()is deprecated in every SDK: from 2.3.0 it returnsnull(nilin Swift) without a request, and it will be removed in the next major version. Your app already holds this data. - Signed-in users only. A device not identified as that user is refused, the same way as channel consent for a named user.
- KYC is not available.
submitKYCandgetKYCStateexist in the SDKs, but the SDK gateway has no route for them, so nothing is submitted or stored. Don't build on them; keep KYC in your own systems and, if you need it for targeting, import the outcome as an attribute (Importing users and data).
Rules and operators
A segment is a list of rules joined by AND or OR, with nested groups up to six levels deep, and optional excluded segments. Names are unique in a workspace.
POST /api/v1/segments
{
"name": "Salaried, no personal finance",
"logicalOperator": "AND",
"rules": [
{ "field": "enrichment.monthly_salary", "operator": "greater_than", "value": 3000 },
{ "field": "consent.marketing", "operator": "is_true" },
{ "logicalOperator": "OR", "conditions": [
{ "field": "industryProfile.products.hasLoan", "operator": "is_false" },
{ "field": "industryProfile.products.hasLoan", "operator": "not_exists" } ] }
]
}Operators: equals, not_equals, greater_than, less_than, greater_than_or_equal, less_than_or_equal, between, contains, not_contains, in, not_in, exists, not_exists, regex, not_regex, starts_with, ends_with, date_after, date_before, days_ago, geo_radius, app_version_gte, is_true, is_false.
Every device is in All users. Any other segment must be a segment in your workspace; there are no built-in trait segments.
How fresh is a segment?
Segments are evaluated in three places, each with its own freshness. Knowing which one a feature uses explains most "why didn't they get it" questions.
| Used by | Evaluated | Freshness | Notes |
|---|---|---|---|
Blocks and in-app content (/sync) | On identify, over the profile's analytics copy | Updated about a second after a profile change; /sync uses the snapshot from the last identify, refreshed in the background at most once a minute | Rules on enrichment.*, geo_radius and in-app consent aren't available on this path |
| Campaigns, journeys, dashboard counts | Stored membership | Per the segment's update frequency: realtime (on write, plus a daily sweep), hourly, daily or weekly | Supports every rule, including enrichment |
| Audience size estimates | Analytics SQL, falling back to exact evaluation | Live | Exact when a rule can't be translated |
Enrichment
Bring attributes from your systems without changing the app: upload a file or send rows from a pipeline, and each attribute becomes a segment field enrichment.<name>. Values are typed, can expire, and are tagged with their source (CRM, loyalty, ERP, manual, CSV, or ContentFlow's own analytics roll-up); when two sources hold the same attribute, the higher-priority one wins. A missing or expired value means a rule on it doesn't match. The staged import with its dry run, the simple upload, roll-back and source priority are all in Importing users and data.
The hourly analytics roll-up writes analytics_impressions, analytics_clicks, analytics_conversions, analytics_events_total, analytics_last_active_days and analytics_ctr over the last 90 days.
Consent model
Analytics consent (identify({ consent }) / setConsent()) decides whether a profile, traits, segments, events and location are stored; channel consent (setChannelConsent()) decides whether push, SMS, WhatsApp, email, location or marketing may be used, and a channel is used only when its flag is exactly true. Both, with samples for every platform, are in Identity and consent.
Industry consent presets
Presets such as banking_ksa (SAMA + PDPL) describe what a consent screen for a regulated industry should offer; fetch one with GET /sdk/v1/consent/config/{preset}. The seven presets and how to record consent against them: Identity and consent: Consent presets.
Suppression and PDPL opt-out
Suppression switches marketing and the four outbound channels off while remembering the previous settings; PDPL opt-out blocks every outbound channel. Neither deletes data. See Identity and consent: PDPL notes.
Erasure
deleteIdentity() (DELETE /identify) erases the device and its linked user's profile, location, enrichment and events, and the SDK wipes local data only after a complete erasure. Samples, the sequence and the partial-erasure (207) rules: Identity and consent: Erasure.
Strings and localization
Strings are keys with a source text and a translation per language. Only approved translations are delivered; anything else falls back to the source text. How a key is created, approved, delivered and kept up to date is in Localization & strings.
// 1. Load the catalog for the current locale (ETag-cached; part of start()).
await client.getStrings();
// 2. Read a key with a fallback: value ?? fallback ?? key.
label.textContent = client.t('home.balance', 'Balance');import { useCFString } from '@contentflow/sdk/react';
function BalanceLabel() {
// 1. The hook loads the catalog for the current locale (ETag-cached) and re-renders
// when it arrives or changes. useCFTranslations() returns the whole catalog.
// 2. Read a key with a fallback: value ?? fallback ?? key.
const label = useCFString('home.balance', 'Balance');
return <span>{label}</span>;
}import { Text } from 'react-native';
import { useCFString } from '@contentflow/sdk/react-native';
function BalanceLabel() {
// 1. The hook loads the catalog for the current locale (ETag-cached) and re-renders
// when it arrives or changes. useCFTranslations() returns the whole catalog.
// 2. Read a key with a fallback: value ?? fallback ?? key.
const label = useCFString('home.balance', 'Balance');
return <Text>{label}</Text>;
}// 1. Load the catalog for the current locale.
let strings = try await client.getStrings()
// 2. Read a key with a fallback.
label.text = strings.string("home.balance", fallback: "Balance")// 1. Load the catalog for the current locale.
val strings = client.getStrings()
// 2. Read a key with a fallback.
label.text = strings.value("home.balance", "Balance")final cf = ContentFlow.instance;
// 1. Load the catalog for the current locale (a key-to-text map).
final strings = await cf.getStrings();
// 2. Read a key with a fallback.
final label = strings['home.balance'] ?? 'Balance';// From CI, with the write key:
// POST /api/v1/strings/sync X-CF-Write-Key: wk_live_… create / update keys
// POST /api/v1/strings/disable X-CF-Write-Key: wk_live_… stop tracking keys (still served)Any key can also be a sensor, switched on in Sensors → Enable sensors and off there or with disableStrings(); its text is served either way. Locale fallback and sensor switching are covered in Localization & strings.
Content library
POST /api/v1/content/generate-upload-urlwith{ fileName, mimeType }returns a presigned upload URL (valid 5 minutes) and acontentId.- Upload the file with
PUTto that URL (up to 100 MB). POST /api/v1/content/finalize-uploadrecords the asset.
Put the durable URL https://api.contentflow.click/api/v1/content/{id}/raw in image fields. It's public, never expires, and redirects to a freshly signed file each time. It returns 404 for a missing asset and 503 with Retry-After: 30 if storage is briefly unavailable.
When it doesn't work
| Symptom | Cause |
|---|---|
| No profile for a person | Their device never identified with analytics consent. |
| A trait is missing | The key didn't match ^[a-zA-Z0-9_]{1,64}$ or the value was over 4 KB; it was dropped silently. |
| A block targets an enrichment segment but nobody sees it | Enrichment rules aren't evaluated on the in-app delivery path; target a trait, or use campaigns and journeys for enrichment segments. |
| A new user's segments are empty on first sync | Identify hadn't completed with consent yet; the next identify or the background refresh fills them. |
| Duplicate segment name | 409 Segment name must be unique within the tenant |
| Consent call refused | 400 CHANNELS_REQUIRED (wrap flags in channels), 403 DEVICE_NOT_IDENTIFIED, 409 DEVICE_ENVIRONMENT_MISMATCH, 429 USER_RATE_LIMITED (more than 10 consent changes per user per hour) |
deleteIdentity() throws | The server couldn't confirm a complete erasure (a 502, or a partial 207 for an SDK that declares erasure-partial-v1). The device id and local data are kept; retry the call. While an erasure is incomplete, identify returns 409 ERASURE_PENDING. |
| A string shows its key | The key doesn't exist or has no approved value in that locale and no fallback was passed. |
Next
- Analytics: the events that feed behaviour segments and the hourly roll-up.
- Identity and consent: identify, both kinds of consent, presets and erasure, with samples for every platform.