Home / Guides / Audience & privacy
Product guide

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.

In short
  • 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.
Why it matters

Business use cases

Targeting

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.

Lifecycle

New, at-risk, churned

Lifecycle stages computed from activity drive onboarding for new users and win-back for dormant ones.

Behaviour

Engaged non-converters

Hourly analytics roll-ups make "clicked 3+ times, never converted" a segment you can message.

Compliance

PDPL-ready consent

Industry presets (SAMA, CCHI, CBAHI with PDPL) define required and optional consents and topics per sector.

People

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:

PartComes fromSegment field examples
Traitsidentify({ 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
DemographicIdentify contact fields, your updatesdemographic.country, demographic.preferredLanguage
Current locationThe IP address, on identifycurrentLocation.city
BehaviourAnalytics roll-up (hourly)enrichment.analytics_clicks
EnrichmentCSV, CRM, ERP, loyalty, manual APIenrichment.salary_band
Industry profileupdateIndustryProfile() (see below), server APIindustryType
ConsentIdentify and the consent APIconsent.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.

People

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.

People

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'].

People

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 returns null (nil in 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. submitKYC and getKYCState exist 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).

Segments

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.

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 byEvaluatedFreshnessNotes
Blocks and in-app content (/sync)On identify, over the profile's analytics copyUpdated about a second after a profile change; /sync uses the snapshot from the last identify, refreshed in the background at most once a minuteRules on enrichment.*, geo_radius and in-app consent aren't available on this path
Campaigns, journeys, dashboard countsStored membershipPer the segment's update frequency: realtime (on write, plus a daily sweep), hourly, daily or weeklySupports every rule, including enrichment
Audience size estimatesAnalytics SQL, falling back to exact evaluationLiveExact when a rule can't be translated
Segments

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.


Privacy

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.

Privacy

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.

Privacy

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.


Content

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

Content library

  1. POST /api/v1/content/generate-upload-url with { fileName, mimeType } returns a presigned upload URL (valid 5 minutes) and a contentId.
  2. Upload the file with PUT to that URL (up to 100 MB).
  3. POST /api/v1/content/finalize-upload records 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.

Troubleshooting

When it doesn't work

SymptomCause
No profile for a personTheir device never identified with analytics consent.
A trait is missingThe 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 itEnrichment 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 syncIdentify hadn't completed with consent yet; the next identify or the background refresh fills them.
Duplicate segment name409 Segment name must be unique within the tenant
Consent call refused400 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() throwsThe 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 keyThe 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.