Home / How it works
Platform guide

How ContentFlow works

ContentFlow lets a product team change what users see and receive without shipping an app release. Developers put a small SDK in the app and define the shapes of in-app components in code. Marketers then fill those components with content, target them to audiences, run bilingual campaigns over push, SMS, WhatsApp, email and in-app channels, and measure the result.

This guide is the map. It shows every product, how the pieces talk to each other, which key each caller uses, and the exact order of calls an app makes in a session, including what you'll see when a step is skipped. Each product then has its own guide with sequences, use cases and failure tables.

In short
  • Developers define block shapes in code and the app fetches them with the SDK; marketers fill, target and schedule them in the dashboard, with no app release.
  • Four callers use different kinds of key, each limited to its own job; a key leaked from an app cannot edit content or read profiles.
  • The key decides the environment: a test key sees Test and Live content, a live key sees Live only, and a device stays in the environment it was first identified in.
  • Analytics consent (sent on identify) decides what is stored; channel consent (push, SMS, WhatsApp, email, marketing) is a separate decision that decides what may be sent.
  • Most ordering mistakes do not throw: the SDK API accepts the request and reports what it dropped in dropReasons.
i Reading the diagramsEach sequence diagram is interactive. Use Next or Play to walk through the calls, click a step to jump to it, and open What if this step is skipped? to see the real consequence, with the status and error code the platform returns. Without JavaScript the same steps read as a numbered list.

What's in the box

The product map

Everything below runs on one workspace (tenant). The SDK is how an app takes part; the dashboard and the management API are how a team operates it.

Show in the app

Blocks & placements

In-app components whose content, targeting, schedule and A/B variants change from the dashboard. Banners, cards, carousels, onboarding tips, whole screens.

Reach people

Campaigns, journeys & channels

Bilingual campaigns over in-app, push, SMS, WhatsApp and email, with maker-checker approval, scheduling, Hijri dates and prayer-time blackouts. Journeys chain steps per user.

Reach people

Push notifications

APNs, FCM and HMS delivery to every install a person has, with channel consent, token hygiene and delivery tracking.

Know people

Audience & privacy

Profiles from identify, traits, CSV/CRM enrichment and analytics; rule-based segments; consent per channel; PDPL opt-out and erasure; strings and localization.

Measure

Analytics

Every impression, tap and conversion lands in analytics within seconds. Dashboards, per-person activity, experiments, and an hourly rollup that makes behaviour segmentable.

Measure

Sensors

Turn any localized string into a sensor and see who saw it and who acted. Relate sensors in funnels and absences, and let rules move people into segments.

Context

Location & geofencing

IP-derived country and city for every identified profile, device fixes, and on-device geofence enter/exit/dwell events for branch, mall and airport moments.

Put it together

Industry playbooks

Banking, fintech, insurance, healthcare, travel, retail and food delivery scenarios, each mapped to the products and the exact calls.

Choosing

Which tool do I use?

You want toUseHow it reaches people
Show persistent, personalised content inside the appBlockThe app fetches it with /sync and renders it in a slot. No push.
Send a timed message to a segmentCampaignOver one or more channels, with approval and a schedule.
Run a multi-step flow per personJourneyTriggers, waits, branches and sends, tracked per person.
Reach people outside the appPush, SMS, WhatsAppThese are campaign channels (and journey steps), not separate products.
Architecture

How the pieces talk

Four public surfaces reach ContentFlow, and each has its own credential. App traffic from the SDK goes to the SDK API at api.contentflow.click/sdk/v1. Operator traffic goes to the management API at api.contentflow.click/api/v1: from your servers and CI with a write or read key, and from the dashboard at app.contentflow.click with a portal sign-in. AI assistants connect through ContentFlow's MCP server, acting as the signed-in dashboard user. All four work on the same workspace data.

ContentFlow platformContentFlow SDK (runs in your app)External
ContentFlow SDKInside your app: device id, consent, queue, cache
SDK API/sdk/v1. Resolves workspace and environment from the key, rate limits, checks consent
Your contentThe device's profile and segments decide which blocks, strings and in-app messages /sync returns
ContentFlow SDKEvent queue inside your app: batches of up to 10 every 15 s
SDK APIPOST /events, validated, consent-checked
AnalyticsQueryable in dashboards within seconds; rolled up into profiles hourly so behaviour is segmentable
Dashboard user, your server, or an AI assistantPortal sign-in (JWT), a write or read key, or MCP
Management API/api/v1. Roles, approvals, plan limits, campaigns, journeys and schedules, including Hijri and prayer-time windows
ProvidersPush (APNs/FCM/HMS), SMS, WhatsApp and email deliver campaign and journey messages
Vocabulary in practice

Core concepts

Blocks vs. instances

A block type is a UI shape defined in code (a hero, a card, a carousel). A block instance is that shape filled with content, a specific hero on your home screen. Types are defined by developers and registered over the REST API, the CLI that will version them from git is not published yet; instances are managed by anyone in the dashboard. Field types and tag addressing are covered in Blocks & fields.

A list is not a special case of either. A collection field holds an ordered list of references to other instances, so a carousel is an ordinary instance whose slides are ordinary instances. Every card therefore has its own targeting, schedule, approval state, A/B variants and analytics, and delivery inlines the eligible ones in full under the field's tag.

Channels

A channel is a route a campaign is delivered over: in-app, popup, push, SMS, WhatsApp or email. Blocks are different: they are delivered in-app through /sync, never by push. Popup and in-app render as block-style image cards; how often a popup may appear is set per item and by workspace limits (Popup frequency and limits). SMS runs through Twilio or Taqnyat, and WhatsApp through Twilio, using your own account or a ContentFlow-managed one.

Segments & targeting

A segment is an audience. A block instance is targeted only by its segment (seg and segIds). The locale does not decide who sees a block: it only selects the translation. Campaigns target segments, plus rules such as geo. The special "All users" segment still needs a phone number and consent on the contact before SMS or WhatsApp can reach it. For the whole chain, sending a trait through to proving the targeting landed, see Traits & targeting.

Content

Content lifecycle

  1. Define, a developer adds a block type in code and registers it from a server or CI with POST /sdk/v1/cards/register and the write key. A ContentFlow CLI is not published yet.
  2. Fill, someone creates an instance in the dashboard (or seeds it via /cards/sync).
  3. Target, pick a segment, a schedule and a priority.
  4. Publish, the block goes live. There is no content webhook; connected apps see it on the live stream.
  5. Sync, apps pull it on the live signal or their next GET /sdk/v1/sync, no redeploy.
  6. Roll back, restore a saved version of the instance in the dashboard. Apps pick it up the same way.
Credentials

Four callers, different kinds of key

Use the credential that matches the caller. Each one can do only its own job, so a key leaked from an app can't edit content or read profiles.

An app can use three kinds of key. A publishable key, shaped <workspace-id>_cfpk plus 40 hex characters, is the recommended one. You create, rotate and revoke it under Settings → API keys; it is stored hashed, and its environment comes from its stored record. Derived keys, <workspace-id>_app and <workspace-id>_test, are the legacy form. Anyone who knows the workspace id can compute them, so they are not secret. Admins can switch them off per workspace, and operators globally; a refused key gets 401 INVALID_SDK_KEY. A secret key, pk_live_… or pk_test_…, is for servers.

CallerCredentialCan callNever
Your app (SDK)Publishable key <workspace-id>_cfpk… (recommended), or a legacy derived key <workspace-id>_app / <workspace-id>_test. Sent as X-CF-Key with the device id in X-CF-Device. Secret server keys pk_live_… / pk_test_… are also accepted. A publishable key with a conflicting X-Tenant-Id gets 401 TENANT_KEY_MISMATCH; for the other kinds the header is ignored./identify, /sync, /strings, /events, /consent, /register-push, /location, /profile, /streamEdit content, read other users, send campaigns
Your build or CI (cards and strings as code)Write key wk_live_… in X-CF-Write-KeyPOST /cards/register, POST /cards/sync, POST /api/v1/strings/sync, POST /api/v1/strings/disableShip inside an app
Your backend (read only)Read key rk_live_… in X-CF-Read-KeyA user's devices and segments: GET /api/v1/read/users/{userId}/segments, …/devices, /read/devices/{deviceId} (push tokens stripped)Ship inside an app
Dashboard and server integrationsPortal sign-in token (JWT) with X-Tenant-IdThe management API: blocks, campaigns, journeys, segments, people, analytics, approvalsShip inside an app

Create and rotate publishable SDK keys in the dashboard under Settings → API keys. Write and read keys are under Developers → Your keys; they are shown in full only once, when they are created or rotated; after that you see a masked value, so rotate a key you have lost. See Keys for headers and the API explorer for every operation.

Environments

Live and test never mix

The key decides the environment; there's no client-side flag. For a publishable key the environment comes from its stored record; for a derived key it comes from the suffix (_test or _app). A test key sees content published to Test as well as Live; a live key sees Live only. Test traffic is stamped env: test, excluded from live analytics and never updates block counters.

Sandbox workspace. If your workspace has a linked sandbox (Settings → Sandbox, beta), its test keys send their data to the sandbox, not to the live workspace: the derived <workspace-id>_test key, test publishable keys (cfpk_test), and test secret keys (pk_test_) all land there, and a test publishable key is created in the sandbox itself. The sandbox still shows your live blocks and strings unless it overrides them. Without a sandbox, test keys behave as before. Live keys (_app, live publishable and pk_live_) are unchanged and never write to the sandbox. A sandbox workspace can only create and list test keys; asking for a live key there answers 403 SANDBOX_LIVE_KEY_FORBIDDEN. In Test mode, the Developers page shows the sandbox's test key. The sandbox's test key is managed from inside the sandbox: switch to the sandbox workspace, then create, rotate or revoke it under Settings → API keys there (the live workspace's key list never shows it). If ContentFlow briefly cannot confirm where a test key's traffic goes (for example during a brief outage), writes with that key (identify, events, track-event, consent, register-push, location and the other write calls) answer 503 SANDBOX_ROUTE_UNAVAILABLE with a Retry-After header instead of being stored in the live workspace, and the SDK retries them; reads (sync, blocks, strings) and the in-app campaign fetch (POST /campaigns, a read) keep working, answered from the workspace the key belongs to. Live keys are never affected.

A device belongs to the environment it was first identified in. Calling /identify, /consent, /register-push or logout for that device with the other environment's key returns 409 DEVICE_ENVIRONMENT_MISMATCH. Use a separate install (or a fresh device id) for test builds.


The SDK session

The call sequence, step by step

This is what one app launch looks like with the JavaScript SDK. client.start() runs steps 2 to 8 for you; the native SDKs follow the same order. Walk through it, then open the skip notes to see what each step protects.

One app session: launch to first event
  1. The app creates one client per workspace and account. The SDK loads or creates a persistent device id, restores consent and the last segments, and opens its offline queue. Nothing is sent yet.
  2. If a previous session cached content, the SDK paints it immediately so the first screen never waits on the network. A first-ever launch falls back to your bundled defaults.
  3. Sends the device id, the signed-in user id (from config.userId or setUserId()), traits, and the analytics consent decision when the app has made one.

    If this step is skipped: Without identify ContentFlow doesn't know the device: every event batch is acknowledged with 202 but accepted: 0 and dropReasons.device_not_identified, /location answers DEVICE_NOT_IDENTIFIED, and /sync delivers only content targeted to All users, because the device has no segments. The SDK holds events for up to 10 s waiting for the first identify, then sends them anyway.

  4. The workspace and environment come from the key. ContentFlow records the device and its consent state: granted, denied, or unknown for a new device that hasn't decided.

    If this step is skipped: A body without a device id is refused with 400 device identification required (X-CF-Device header or deviceId in body).

  5. Only when consent is granted: traits are merged into the profile's custom attributes, contact fields fill empty fields only, and the profile is created if it's new. ContentFlow then resolves the person's segment membership and stores it on the device as the snapshot /sync will use, so the response reports real segments.

    If this step is skipped: If consent isn't granted, this step doesn't happen: traits, user id and segments are not written and no profile is created. That is by design (PDPL). The response says consent: false and identity.status unchanged.

  6. ContentFlow keeps the device's user, consent and segments for 24 hours (this is what /events and /location check) and answers. The SDK treats a consent grant as effective only once the server echoes consent: true.
  7. Asks for the blocks this device should see in this locale. A matching ETag gets 304 and the SDK keeps what it has.

    If this step is skipped: With no sync the app shows only cached or bundled content, so dashboard changes never arrive. Calling sync before identify on a brand-new device returns only All users content.

  8. Every eligible instance per block key, ordered by priority, with values keyed by field tag, the A/B variant when a test is running, and the workspace feature switches. In-app campaign sends and journey messages also arrive here, as journey_message blocks with source: "campaign"; an app that only renders the block keys it knows will drop them.
  9. Fetches the localized strings catalog and the list of string keys that are enabled as sensors.
  10. The app renders the block by reading values by tag. When no block is returned it renders its own fallback, never an empty slot.
  11. Impressions, taps and custom events are queued and sent in batches of 10 or every 15 s, and on backgrounding with a keepalive request.

    If this step is skipped: Events are consent-gated twice. Before consent the SDK drops them locally with consent_not_granted and does not hold them for a later grant. If they reach ContentFlow for a device without consent, the batch is acknowledged with dropReasons.consent_not_granted.

  12. ContentFlow validates the batch, attributes events to the device's linked user, and records them. They are queryable in analytics within seconds.
  13. When a marketer publishes, the stream sends a change signal with no payload, and the SDK re-runs /sync. Backgrounding stops polling and the stream; returning forces a sync.

    If this step is skipped: Without the live stream, content still refreshes on the poll tick (every 30 s by default, with backoff). getLiveStatus() reports mode 'poll' and the reason.

import { createCFClient } from '@contentflow/sdk/web';

const client = createCFClient({
  publicKey: CF_PUBLISHABLE_KEY,        // a test-environment key in dev builds, a live one in production
  userId: signedInUserId,               // omit for anonymous installs
  locale: 'ar',
  consent: false,                       // analytics consent: ask first
});

await client.start();                   // cached paint → identify → sync + strings → live updates

// Later, when the user agrees to analytics:
await client.setConsent(true);          // re-identifies with consent: true

const banner = client.getBlock('home_banner');
if (banner) render(banner.values); else renderBundledFallback();
Failure modes

Out of order: what happens

Most ordering mistakes don't throw: the SDK API acknowledges the request and tells you what it dropped. Watch dropReasons on event responses and the onEventsDropped callback.

MistakeWhat you getFix
Events before the first identify202 accepted: 0, dropReasons.device_not_identifiedCall start() (or identify()) first; the SDK holds events up to 10 s for it.
Events with no analytics consentdropped locally, consent_not_granted; not replayed laterAsk for consent before the moments you need to measure.
A long session that never re-identifies202 device_not_identified after ContentFlow's 24 h record of the device expiresCall identify() on launch and resume (start() does).
trackEvent() (sign-up / sign-in) before identify202 accepted: false, device has no linked userId - call identify() firstUse trackSignUp() / trackSignIn(), which identify first.
sign_up or sign_in sent to /events400 ENDPOINT_MISMATCH; the whole batch is rejectedIdentity events go to /track-event.
Push registration with a user id the device isn't signed in as403 DEVICE_NOT_IDENTIFIED or USER_MISMATCHIdentify with that user first, or register without a user id (filed under the device).
setChannelConsent() for a user before identify403 DEVICE_NOT_IDENTIFIED; 400 DEVICE_REQUIRED with no deviceIdentify first. Device-only channel consent is accepted before identify.
updateIndustryProfile() with no signed-in userThrown locally, no request: no signed-in user: call setUserId() or identify() with a userId firstSet the user, then identify.
Location before consent202 LOCATION_CONSENT_REQUIRED; Swift and Kotlin don't retryLocation is gated on analytics consent.
Test key on a live device, or the reverse409 DEVICE_ENVIRONMENT_MISMATCHKeep test and live installs apart.
More than 100 events in one batch400 MAX_BATCH_SIZE; nothing queuedThe SDK caps batches at 100; custom senders must too.
Too many requests for the workspace429 SDK_RATE_LIMIT_EXCEEDED, Retry-After: 60The SDK keeps the batch and retries. Limit: 1,000 requests/min per workspace, burst 100.
Too many changes for one user429 USER_RATE_LIMITED. Limits are per user: 3 new push devices per 24 h, 10 consent changes per hour.Don't re-register or toggle consent in a loop. (A per-IP limit of 300 requests/min exists but is monitor-only by default.)
Identify while an erasure is still running409 ERASURE_PENDINGWait for the erasure to finish. After it does, profile writes answer 409 SUBJECT_ERASED, and channel consent stays suppressed until the person gives fresh consent.
Unknown, suspended or switched-off key401 INVALID_SDK_KEYCopy the key from the dashboard. A derived key stops working if your admin switched derived keys off.
A service is briefly down502/503/504 UPSTREAM_UNAVAILABLE, retryable: true, Retry-After: 5The SDK keeps the last good content and retries with backoff.
Freshness

Timing and freshness

  • Segments on first launch. Identify waits for the profile to be written before it reads segments, so the first identify reports real membership. /sync never waits: it answers from the device's stored segments and refreshes them in the background at most once a minute.
  • Content. A publish reaches connected apps through the live stream in about a second; otherwise on the next poll (30 s by default).
  • Events. Up to 15 s in the SDK queue, then near real time into analytics.
  • Behaviour in segments. Counts such as impressions, clicks and days since last active are rolled up into profiles hourly, so segments on them are up to an hour behind.
Walkthrough

From a trait to a targeted block

A trait is a fact your app tells us about a device or a person. A segment is a named audience defined by rules over those facts. An instance is targeted at segments by name and id. This section walks the whole chain in the order you build it: send a trait → write a rule over it → create the segment → point an instance at it → prove it landed.

Read the warn notes even if you skim the rest. Three of them cover failures that return a success status and then reach nobody: an invalid trait key, a trait sent without consent, and a typo in seg.

1. Send a trait

Traits are sent on POST /sdk/v1/identify, authenticated with your SDK key in X-CF-Key. On /sdk/v1/* the key is the only required credential, and this call also needs X-CF-Device because it is about a device. No tenant header is needed: the key already names the workspace. The traits body field is optional, and its keys are flat: nesting goes inside a value, never in a key.

curl
# "consent" is the user's recorded choice; true is shown for a user who opted in
$ curl -X POST https://api.contentflow.click/sdk/v1/identify \
     -H "Content-Type: application/json" \
     -H "X-CF-Key: $CF_SDK_KEY" \
     -H "X-CF-Device: $CF_DEVICE_ID" \
     -d '{
       "userId": "u_8812",
       "consent": true,
       "traits": { "region": "riyadh", "salary": 7200, "signupDaysAgo": 12 }
     }'

No declaration step exists. You do not register a trait before sending it, the first identify call that carries it is enough.

Rule for a trait keyDetail
Allowed charactersMust match ^[a-zA-Z0-9_]{1,64}$. Letters, digits and underscore only, 1 to 64 characters.
Dots and $ are rejecteduser.plan is not a valid trait key. Nest inside the value instead, never in the key.
Case sensitiveAge and age are two different traits.
CountMaximum 50 traits per identify call.
Value sizeEach value must serialize to 4096 bytes or fewer of JSON. Nested objects and arrays are allowed as values.
! An invalid trait key is dropped silently, and the call still returns 200A key that breaks the rules above is discarded on the way in. There is no error, no warning field, and nothing in the response names the key that was dropped. If a trait never shows up in targeting, an invalid key is the first thing to check.
! A trait sent without consent is discardedConsent is a hard gate. Nothing trait related is stored or forwarded unless analytics consent is granted, so send the user's actual choice as consent on identify (for example from your consent banner), never a hard-coded true. Without a grant the call still succeeds and the traits go nowhere, which reads exactly like a targeting bug later.

What the traits field does when you send a real set, leave it out, or send something odd:

You sendWhat happens
A non-empty objectReplaces the device's stored trait set. It does not merge, so send every trait you still want on every call. The profile attributes that segment rules read are different: they are merged per key, and a key you stop sending is not removed from the profile.
traits omittedLeave the stored traits unchanged.
"traits": {}Leave the stored traits unchanged. An empty object means nothing to report, not clear what you have.
null, or a malformed value such as an array, a string or a numberLeave the stored traits unchanged. A malformed value is a no-op, not an error.
! There is no way to clear a device's traits from identifyEvery shape above either replaces the set with the one you sent or leaves it alone. An empty object used to clear it and no longer does, because the clients that send {} are almost always apps with nothing to report on that call rather than apps asking to erase a profile. To erase a device's data outright, including its traits, call DELETE /sdk/v1/identify.
i There is no userId gateAn anonymous device that granted consent still gets a stored profile, keyed on its device id. A userId is not required for a device to be targeted.

2. Build a rule from the trait

Inside a segment rule a trait is addressed as customAttributes.<traitKey>. A trait called plan is matched with the field customAttributes.plan.

A rule is either a leaf or a group. Groups nest.

rule shapes
// leaf
{ "field": "customAttributes.plan", "operator": "equals", "value": "gold" }

// group, logicalOperator is "AND" or "OR"
{
  "logicalOperator": "OR",
  "conditions": [
    { "field": "customAttributes.plan", "operator": "equals", "value": "gold" },
    { "field": "customAttributes.salary", "operator": "greater_than", "value": 3000 }
  ]
}
OperatorWhat it means for a trait
equalsStrict comparison against the stored value. The string "30" does not equal the number 30, so send numeric traits as numbers.
not_equalsTrue when that same strict comparison fails.
greater_thanNumeric. Both sides coerce with Number(), so a numeric string such as "30" works. A value that is not numeric evaluates to false rather than erroring. The same applies to the other numeric operators below.
less_thanNumeric, same coercion and the same quiet false on a non-numeric value.
greater_than_or_equalNumeric, inclusive at the boundary.
less_than_or_equalNumeric, inclusive at the boundary.
betweenNeeds a two element [min, max] array in value, and is inclusive at both ends.
containsCase sensitive. On a string value it tests substring; on an array value it tests membership.
not_containsThe inverse of contains, and case sensitive in the same way.
inNeeds an array in value. True when the trait is one of its entries.
not_inNeeds an array in value. True when the trait is none of its entries.
existsTrue when the trait key is present on the profile, whatever its value.
not_existsTrue when the trait key is absent.
regexPattern match against the trait value.
not_regexTrue when the pattern does not match.
starts_withCase sensitive prefix test.
ends_withCase sensitive suffix test.
is_trueTrue when the trait is boolean true.
is_falseTrue when the trait is boolean false.
date_afterThe trait is an ISO date string later than the date in value.
date_beforeThe trait is an ISO date string earlier than the date in value.
days_agoThe trait is an ISO date string within the last value days.
more_than_days_agoThe trait is an ISO date string more than value days in the past.
✓ Dates work as ISO stringsSend a date trait as an ISO-8601 string, for example 2026-03-01 or a full timestamp. The four date operators then work on it. A value that does not parse as a date evaluates to false.

Fields that are not traits carry their own prefix: demographic.*, behavioral.*, device.*, consent.*, location.*, metadata.* and enrichment.*. Note that POST /sdk/v1/identify does not populate demographic.*: identify sets traits, name, email, phone and the device id only.

3. Create the segment

Create the segment in the dashboard under Audience → Segments, with the rules from step 2. The rule builder there uses exactly the fields and operators above. Segments are managed in the dashboard, not with an SDK key.

i Only "All users" is built inContentFlow has one built-in audience, All users, which is always true. Every other audience comes from a real segment in your workspace. A trait alone does not create a segment: the name you put in seg must be a segment you created in step 3.

4. Target an instance at the segment

In the dashboard, open the block instance and pick one or more segments as its audience. The instance stores them in two fields. seg holds the segment name, or the names joined with ", " when there are several. segIds holds the matching segment ids. Clearing the audience makes the instance untargeted.

When segIds is present, delivery matches by id, so renaming a segment in the dashboard keeps working. Writers that send names only (the API, cards as code) are matched by name until a backfill resolves the ids. The backfill runs hourly.

! A typo in seg reaches nobody, and nothing tells youA name written without ids is never validated against an existing segment. Misspell it and you get a live, published instance targeted at an audience that does not exist. No error on the write, none at delivery, and the instance simply never appears for anyone.
i Renaming a segmentInstances saved from the dashboard carry ids and follow a rename. An instance written by name only keeps the old string until the backfill resolves its ids, and if the old name no longer exists it matches nobody. Repoint those instances when you rename.

How it resolves at delivery

An instance reaches a device when any one of these is true:

  • Its segIds include one of the device's segment ids, or (name-only writers) its seg names one of the device's segment names.
  • seg is absent.
  • seg is the empty string.
  • seg is exactly All users.

The last three all mean untargeted. Matching is byte exact and case sensitive, so VIP and vip are different segments. A device with no segments sees only untargeted instances, which is a valid answer and not an error. A device gets no segments if it never called identify, or if its identify calls never reached granted consent. Delivery also filters on status and on schedule windows, independently of targeting.

Timing

Segment membership is recomputed when a trait value the write proposes actually differs from what is stored, so re-sending identical traits does not trigger a recomputation. A device's segment snapshot then refreshes at most once every 60 seconds. Expect roughly a minute for a targeting change to reach a device, not instant.

i A check run immediately after identify can legitimately be staleThat is the refresh window doing its job, not a bug. Give the assertion room for the interval, or retry until it has passed.

5. Prove it landed

Ask the platform which segments an identifier is in, rather than eyeballing a block that happened to render.

curl
$ curl https://api.contentflow.click/api/v1/read/users/u_8812/segments \
     -H "X-CF-Read-Key: $CF_READ_KEY"
data
{
  "userId": "u_8812",
  "segmentNames": ["All users", "VIP customers"]
}

The second name is an example of a segment you created in your own workspace; yours will differ.

It answers with names, the same strings an instance's seg holds, so your assertion compares like with like. The {userId} slot accepts whatever the profile is keyed on, so a device id works there too. The full contract is on the REST API reference.

What you getWhat it means
404No profile exists for that identifier. Check the identifier before you check your rules.
200 with an empty segmentNamesA known profile in zero segments. The identifier resolved, the rules did not match. Check your rules, not your identifier.
✓ Where the read key comes fromShaped rk_live_ plus 24 lowercase hex characters, issued under Dashboard, Developers, "Your keys" at app.contentflow.click, admin or editor only. It is a server and CI credential: never ship it inside an app.

Ship it

Integration checklist

  1. Use the test key in development and the live key only in production builds. Never ship a write key or dashboard credentials in an app.
  2. Create one client per workspace and account, and call start() on launch.
  3. Set the user id on sign-in (setUserId), clear it on sign-out, and re-identify.
  4. Ask for analytics consent before measuring, and for channel consent before messaging. They are separate calls.
  5. Register block types as code from CI with the write key; give every slot a bundled fallback.
  6. Track impressions and taps on every block (the React hooks do impressions for you) and conversions at the business outcome.
  7. Register the push token after the OS grants permission, and again whenever it refreshes.
  8. Handle dropReasons and the onEventsDropped / onEventsUncertain callbacks in your diagnostics.
  9. Wire erasure: deleteIdentity() wipes the device only once the server confirms everything was deleted.
Questions

FAQ

Do I need to redeploy my app to change content?

No. Publishing an instance makes it live on the next GET /sdk/v1/sync your app performs, with no app release, no store review and nothing to redeploy. That's the whole point. The Get started shows the call.

Which SDK can I actually use today?

All four: JavaScript (web, React, React Native, Expo), Swift, Kotlin and Flutter. Each is distributed as an archive from the releases page on this site, not through a public package registry. The downloads are public and need no account or token; verify the published SHA-256 before installing. The REST API is also supported directly.

Why is my block empty, or stuck on old content?

An empty blocks list means nothing is published for the key's environment, or nothing targets this device. A wrong or suspended key does not give an empty list: it returns 401 INVALID_SDK_KEY. Work down this list:

  1. Key and environment. A test key sees test and live content; a live key sees live only. The base URL is https://api.contentflow.click/sdk/v1; see the REST guide.
  2. Identify and consent. Without analytics consent the device has no segments, so it gets only All users content.
  3. Segment membership. Check the device's segments with the read call. Allow about a minute after a trait change.
  4. Instance status. A draft or an instance waiting for approval is not delivered.
  5. Schedule. The mode may be always, payday (the 25th to the 3rd), Ramadan (Hijri month 9) or a custom window. It is evaluated in the instance's schedule.tz, which defaults to Asia/Riyadh.
  6. Frequency rules. A cap or cooldown may be hiding it for this device.
  7. Device cache. The SDK may still be showing cached content until the next sync.
  8. Sponsored content. Sponsored content is off by default, so an empty sponsored slot is normal.

Why didn't my SMS / WhatsApp campaign deliver?

The audience likely had zero contacts with phone + consent, or the workspace has no configured SMS or WhatsApp channel. Check both in the dashboard.

Can non-developers manage content?

Yes. Developers define block types in code; anyone can create and edit instances in the dashboard.

Vocabulary

Glossary

Every term used across these docs, defined once.

WorkspaceOne tenant: its content, people, keys and settings. All data is scoped to it; the older word tenant means the same.
KeyThe credential an app sends in X-CF-Key (alias X-API-Key; if both are sent, X-CF-Key wins silently). On /sdk/v1/* it is the only required header, because it names the workspace. X-CF-Device is needed only by calls about a device: identify, register-push, events, track-event, consent. Older cf_live_ / cf_test_ examples no longer parse. See the next three entries.
Publishable keyThe recommended app key, shaped <workspace-id>_cfpk plus 40 hex characters. Created, rotated and revoked under Settings → API keys, and stored hashed. Its environment comes from its stored record.
Derived keyThe legacy app key, <workspace-id>_app (live) or <workspace-id>_test (test; if the workspace has a sandbox, its data goes to the sandbox). It can be computed from the workspace id, so it is not secret. Admins can switch derived keys off per workspace, and operators globally; a refused key gets 401 INVALID_SDK_KEY.
Secret keypk_live_… or pk_test_…, for servers only. Never ship it in an app.
Write keyBuild and CI credential shaped wk_live_…, sent as X-CF-Write-Key. Syncs cards (block types) and strings from CI. Never ship it in an app.
Read keyServer-side credential shaped rk_live_ plus 24 lowercase hex characters, passed as X-CF-Read-Key. Issued under Dashboard, Developers, "Your keys", admin or editor only. Reads back what the platform knows, such as a user's segment names. Never ship it inside an app.
EnvironmentLive or Test. The key decides it. A test key sees Test and Live content; a live key sees Live only. Test traffic is kept out of live analytics.
DeviceOne app install, identified by a persistent device id. It holds the traits, consent state and segment snapshot that /sync uses.
ProfileA person, keyed by your user id (or the device id for consented anonymous installs). Holds the attributes segment rules read. One profile can have many devices.
TraitA fact your app sends about a device or person, on POST /sdk/v1/identify in the traits object. Key must match ^[a-zA-Z0-9_]{1,64}$, 50 per call, each value 4096 bytes of JSON or fewer. Stored only when consent is granted, and dropped silently when the key is invalid.
customAttributesThe field prefix a trait is addressed by inside a segment rule. The trait plan is matched as customAttributes.plan. Sibling prefixes for non-trait fields are demographic.*, behavioral.*, device.*, consent.*, location.*, metadata.* and enrichment.*.
EnrichmentAttributes added to profiles from a CRM, CSV or similar source. They are merged by source priority, so a higher-priority source wins when two disagree. Addressed as enrichment.* in rules.
Analytics consentSent on /identify. Decides what ContentFlow may store about a person: profile, traits, segments, events and location.
Channel consentSent to POST /consent, per channel (push, SMS, WhatsApp, email, marketing). Decides what may be sent to the person. It is independent of analytics consent.
BlockA named, typed slot in your app: a key plus typed fields, filled at runtime with content from the dashboard. Defined in code (the "block type") and registered over the REST API.
Block typeThe definition of a block: its key and typed fields, addressed by tags.
FieldOne typed slot inside a block type. The eight types are text, textarea, image, number, select, toggle, url and collection. The first seven are scalar. The server stores an unknown type, but the SDKs will not render it. Addressed by tag, never by index.
TagThe stable name a field is read by, such as #title_main. Renaming one breaks any app version already reading it.
CollectionThe one non-scalar field type: an ordered list of references to other block instances. The block holding it is the wrapper; each referenced instance is a card, and delivery inlines every eligible card in full under the field's tag. A card is a block, so it carries its own targeting, schedule, approval state, variants and analytics. See Collections.
InstanceOne set of values for a block, together with who sees it (audience), when (schedule), its priority and its status. A block can have many instances. Managed in the dashboard or seeded via API.
PlacementThe screen slot a block renders in.
externalIdStable id you set so a seeding sync stays idempotent (no duplicates).
SegmentAn audience a block or campaign targets, defined by rules over traits and other profile fields. Created in the dashboard under Audience → Segments.
seg / segIdsThe two instance fields that hold targeting. seg is the segment name, or several names joined with ", ". segIds is the matching ids. Delivery matches by id when ids exist, so renames keep working; name-only writers match by name until a backfill resolves ids. null, "", absent, or exactly All users mean untargeted. A name is not validated, so a typo reaches nobody in silence.
Segment ruleOne node of a segment's logic. Either a leaf, { field, operator, value }, or a group, { conditions, logicalOperator } where logicalOperator is AND or OR. Groups nest. See the operator table.
ScheduleWhen an instance may be delivered: always, payday (25th to 3rd), Ramadan (Hijri month 9) or a custom window. Evaluated in schedule.tz, default Asia/Riyadh.
Frequency ruleA cap or cooldown on how often a device sees an item, for example a popup. See Popup frequency and limits.
ApprovalMaker-checker review: one person submits, another approves, before content goes live. An instance waiting for approval is not delivered.
CampaignA timed message to a segment on one or more channels, with approval and a schedule.
ChannelA campaign delivery route: in-app, popup, push, SMS, WhatsApp or email. Blocks reach the app through /sync, not a channel.
JourneyA multi-step flow per person: triggers, waits, branches, sends. Journey messages reach the app as journey_message blocks.
SensorA localized string key a marketer flags for tracking. The SDK then reports views and taps on it, and the results can be used in analytics and in segments.
dropReasons / acceptedFields on an /events response. accepted is how many events were kept; dropReasons counts the rest by reason, such as device_not_identified or consent_not_granted. The request itself still succeeds.
Capability headerx-cf-sdk-capabilities: a request header where an SDK declares features it can render. Content that needs one, such as sponsored surfaces, is withheld from a caller that does not declare it.
Handshake (retired term)There is no handshake call, in this SDK or any other, and no bootstrap or init round trip of any kind. Older material named one; nothing written against it ever ran. The check that replaces it: call GET /sdk/v1/sync with your key and confirm the response is enveloped ({ success, data }) and carries data.blocks.

Surfaces

The parts of ContentFlow you deal with directly, as they appear in the diagrams.

ContentFlow SDKThe client library inside your app: device id, consent, offline queue, cache. It is ContentFlow code running in your app.
SDK APIhttps://api.contentflow.click/sdk/v1, called by apps with an app key: identify, /sync, strings, events, consent, push registration, location and the live stream.
Management APIhttps://api.contentflow.click/api/v1, called by the dashboard and by your servers and CI with a portal sign-in, write key or read key: blocks, campaigns, journeys, segments, people, analytics and approvals.
DashboardThe web portal your team uses, at app.contentflow.click.
MCPHow AI assistants work with a workspace, acting as the signed-in dashboard user with that user's role.
AnalyticsWhere accepted events land: queryable within seconds, and rolled up into profiles hourly.
Next: Blocks & placements → Industry playbooks