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.
- 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.
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.
Blocks & placements
In-app components whose content, targeting, schedule and A/B variants change from the dashboard. Banners, cards, carousels, onboarding tips, whole screens.
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.
Push notifications
APNs, FCM and HMS delivery to every install a person has, with channel consent, token hygiene and delivery tracking.
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.
Analytics
Every impression, tap and conversion lands in analytics within seconds. Dashboards, per-person activity, experiments, and an hourly rollup that makes behaviour segmentable.
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.
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.
Industry playbooks
Banking, fintech, insurance, healthcare, travel, retail and food delivery scenarios, each mapped to the products and the exact calls.
Which tool do I use?
| You want to | Use | How it reaches people |
|---|---|---|
| Show persistent, personalised content inside the app | Block | The app fetches it with /sync and renders it in a slot. No push. |
| Send a timed message to a segment | Campaign | Over one or more channels, with approval and a schedule. |
| Run a multi-step flow per person | Journey | Triggers, waits, branches and sends, tracked per person. |
| Reach people outside the app | Push, SMS, WhatsApp | These are campaign channels (and journey steps), not separate products. |
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.
/sdk/v1. Resolves workspace and environment from the key, rate limits, checks consent/sync returnsPOST /events, validated, consent-checked/api/v1. Roles, approvals, plan limits, campaigns, journeys and schedules, including Hijri and prayer-time windowsCore 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 lifecycle
- Define, a developer adds a block type in code and registers it from a server or CI with
POST /sdk/v1/cards/registerand the write key. A ContentFlow CLI is not published yet. - Fill, someone creates an instance in the dashboard (or seeds it via
/cards/sync). - Target, pick a segment, a schedule and a priority.
- Publish, the block goes live. There is no content webhook; connected apps see it on the live stream.
- Sync, apps pull it on the live signal or their next
GET /sdk/v1/sync, no redeploy. - Roll back, restore a saved version of the instance in the dashboard. Apps pick it up the same way.
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.
| Caller | Credential | Can call | Never |
|---|---|---|---|
| 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, /stream | Edit content, read other users, send campaigns |
| Your build or CI (cards and strings as code) | Write key wk_live_… in X-CF-Write-Key | POST /cards/register, POST /cards/sync, POST /api/v1/strings/sync, POST /api/v1/strings/disable | Ship inside an app |
| Your backend (read only) | Read key rk_live_… in X-CF-Read-Key | A 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 integrations | Portal sign-in token (JWT) with X-Tenant-Id | The management API: blocks, campaigns, journeys, segments, people, analytics, approvals | Ship 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.
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 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.
- 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.
- 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.
- Sends the device id, the signed-in user id (from
config.userIdorsetUserId()), 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.
- The workspace and environment come from the key. ContentFlow records the device and its consent state:
granted,denied, orunknownfor 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).
- 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
/syncwill 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.
- ContentFlow keeps the device's user, consent and segments for 24 hours (this is what
/eventsand/locationcheck) and answers. The SDK treats a consent grant as effective only once the server echoesconsent: true. - Asks for the blocks this device should see in this locale. A matching ETag gets
304and 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.
- 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_messageblocks withsource: "campaign"; an app that only renders the block keys it knows will drop them. - Fetches the localized strings catalog and the list of string keys that are enabled as sensors.
- The app renders the block by reading values by tag. When no block is returned it renders its own fallback, never an empty slot.
- 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.
- ContentFlow validates the batch, attributes events to the device's linked user, and records them. They are queryable in analytics within seconds.
- 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();Consent decides what's stored
Analytics consent travels on /identify and governs what ContentFlow may store about a person: without it no profile, traits, segments, events or location are written, and only untargeted content is delivered. Channel consent, sent with setChannelConsent() to POST /consent, governs what may be sent to them. The full model is in Identity and consent.
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.
| Mistake | What you get | Fix |
|---|---|---|
| Events before the first identify | 202 accepted: 0, dropReasons.device_not_identified | Call start() (or identify()) first; the SDK holds events up to 10 s for it. |
| Events with no analytics consent | dropped locally, consent_not_granted; not replayed later | Ask for consent before the moments you need to measure. |
| A long session that never re-identifies | 202 device_not_identified after ContentFlow's 24 h record of the device expires | Call identify() on launch and resume (start() does). |
trackEvent() (sign-up / sign-in) before identify | 202 accepted: false, device has no linked userId - call identify() first | Use trackSignUp() / trackSignIn(), which identify first. |
sign_up or sign_in sent to /events | 400 ENDPOINT_MISMATCH; the whole batch is rejected | Identity events go to /track-event. |
| Push registration with a user id the device isn't signed in as | 403 DEVICE_NOT_IDENTIFIED or USER_MISMATCH | Identify with that user first, or register without a user id (filed under the device). |
setChannelConsent() for a user before identify | 403 DEVICE_NOT_IDENTIFIED; 400 DEVICE_REQUIRED with no device | Identify first. Device-only channel consent is accepted before identify. |
updateIndustryProfile() with no signed-in user | Thrown locally, no request: no signed-in user: call setUserId() or identify() with a userId first | Set the user, then identify. |
| Location before consent | 202 LOCATION_CONSENT_REQUIRED; Swift and Kotlin don't retry | Location is gated on analytics consent. |
| Test key on a live device, or the reverse | 409 DEVICE_ENVIRONMENT_MISMATCH | Keep test and live installs apart. |
| More than 100 events in one batch | 400 MAX_BATCH_SIZE; nothing queued | The SDK caps batches at 100; custom senders must too. |
| Too many requests for the workspace | 429 SDK_RATE_LIMIT_EXCEEDED, Retry-After: 60 | The SDK keeps the batch and retries. Limit: 1,000 requests/min per workspace, burst 100. |
| Too many changes for one user | 429 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 running | 409 ERASURE_PENDING | Wait 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 key | 401 INVALID_SDK_KEY | Copy the key from the dashboard. A derived key stops working if your admin switched derived keys off. |
| A service is briefly down | 502/503/504 UPSTREAM_UNAVAILABLE, retryable: true, Retry-After: 5 | The SDK keeps the last good content and retries with backoff. |
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.
/syncnever 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.
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.
# "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 key | Detail |
|---|---|
| Allowed characters | Must match ^[a-zA-Z0-9_]{1,64}$. Letters, digits and underscore only, 1 to 64 characters. |
Dots and $ are rejected | user.plan is not a valid trait key. Nest inside the value instead, never in the key. |
| Case sensitive | Age and age are two different traits. |
| Count | Maximum 50 traits per identify call. |
| Value size | Each value must serialize to 4096 bytes or fewer of JSON. Nested objects and arrays are allowed as values. |
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 send | What happens |
|---|---|
| A non-empty object | Replaces 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 omitted | Leave 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 number | Leave the stored traits unchanged. A malformed value is a no-op, not an error. |
{} 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.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.
// 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 } ] }
| Operator | What it means for a trait |
|---|---|
equals | Strict comparison against the stored value. The string "30" does not equal the number 30, so send numeric traits as numbers. |
not_equals | True when that same strict comparison fails. |
greater_than | Numeric. 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_than | Numeric, same coercion and the same quiet false on a non-numeric value. |
greater_than_or_equal | Numeric, inclusive at the boundary. |
less_than_or_equal | Numeric, inclusive at the boundary. |
between | Needs a two element [min, max] array in value, and is inclusive at both ends. |
contains | Case sensitive. On a string value it tests substring; on an array value it tests membership. |
not_contains | The inverse of contains, and case sensitive in the same way. |
in | Needs an array in value. True when the trait is one of its entries. |
not_in | Needs an array in value. True when the trait is none of its entries. |
exists | True when the trait key is present on the profile, whatever its value. |
not_exists | True when the trait key is absent. |
regex | Pattern match against the trait value. |
not_regex | True when the pattern does not match. |
starts_with | Case sensitive prefix test. |
ends_with | Case sensitive suffix test. |
is_true | True when the trait is boolean true. |
is_false | True when the trait is boolean false. |
date_after | The trait is an ISO date string later than the date in value. |
date_before | The trait is an ISO date string earlier than the date in value. |
days_ago | The trait is an ISO date string within the last value days. |
more_than_days_ago | The trait is an ISO date string more than value days in the past. |
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.
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.
How it resolves at delivery
An instance reaches a device when any one of these is true:
- Its
segIdsinclude one of the device's segment ids, or (name-only writers) itssegnames one of the device's segment names. segis absent.segis the empty string.segis exactlyAll 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.
5. Prove it landed
Ask the platform which segments an identifier is in, rather than eyeballing a block that happened to render.
$ curl https://api.contentflow.click/api/v1/read/users/u_8812/segments \ -H "X-CF-Read-Key: $CF_READ_KEY"
{
"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 get | What it means |
|---|---|
404 | No profile exists for that identifier. Check the identifier before you check your rules. |
200 with an empty segmentNames | A known profile in zero segments. The identifier resolved, the rules did not match. Check your rules, not your identifier. |
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.Integration checklist
- 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.
- Create one client per workspace and account, and call
start()on launch. - Set the user id on sign-in (
setUserId), clear it on sign-out, and re-identify. - Ask for analytics consent before measuring, and for channel consent before messaging. They are separate calls.
- Register block types as code from CI with the write key; give every slot a bundled fallback.
- Track impressions and taps on every block (the React hooks do impressions for you) and conversions at the business outcome.
- Register the push token after the OS grants permission, and again whenever it refreshes.
- Handle dropReasons and the
onEventsDropped/onEventsUncertaincallbacks in your diagnostics. - Wire erasure:
deleteIdentity()wipes the device only once the server confirms everything was deleted.
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:
- 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. - Identify and consent. Without analytics consent the device has no segments, so it gets only
All userscontent. - Segment membership. Check the device's segments with the read call. Allow about a minute after a trait change.
- Instance status. A draft or an instance waiting for approval is not delivered.
- 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 toAsia/Riyadh. - Frequency rules. A cap or cooldown may be hiding it for this device.
- Device cache. The SDK may still be showing cached content until the next sync.
- 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.
Glossary
Every term used across these docs, defined once.
| Workspace | One tenant: its content, people, keys and settings. All data is scoped to it; the older word tenant means the same. |
| Key | The 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 key | The 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 key | The 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 key | pk_live_… or pk_test_…, for servers only. Never ship it in an app. |
| Write key | Build 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 key | Server-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. |
| Environment | Live 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. |
| Device | One app install, identified by a persistent device id. It holds the traits, consent state and segment snapshot that /sync uses. |
| Profile | A 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. |
| Trait | A 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. |
| customAttributes | The 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.*. |
| Enrichment | Attributes 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 consent | Sent on /identify. Decides what ContentFlow may store about a person: profile, traits, segments, events and location. |
| Channel consent | Sent to POST /consent, per channel (push, SMS, WhatsApp, email, marketing). Decides what may be sent to the person. It is independent of analytics consent. |
| Block | A 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 type | The definition of a block: its key and typed fields, addressed by tags. |
| Field | One 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. |
| Tag | The stable name a field is read by, such as #title_main. Renaming one breaks any app version already reading it. |
| Collection | The 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. |
| Instance | One 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. |
| Placement | The screen slot a block renders in. |
| externalId | Stable id you set so a seeding sync stays idempotent (no duplicates). |
| Segment | An audience a block or campaign targets, defined by rules over traits and other profile fields. Created in the dashboard under Audience → Segments. |
| seg / segIds | The 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 rule | One 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. |
| Schedule | When 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 rule | A cap or cooldown on how often a device sees an item, for example a popup. See Popup frequency and limits. |
| Approval | Maker-checker review: one person submits, another approves, before content goes live. An instance waiting for approval is not delivered. |
| Campaign | A timed message to a segment on one or more channels, with approval and a schedule. |
| Channel | A campaign delivery route: in-app, popup, push, SMS, WhatsApp or email. Blocks reach the app through /sync, not a channel. |
| Journey | A multi-step flow per person: triggers, waits, branches, sends. Journey messages reach the app as journey_message blocks. |
| Sensor | A 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 / accepted | Fields 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 header | x-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 SDK | The client library inside your app: device id, consent, offline queue, cache. It is ContentFlow code running in your app. |
| SDK API | https://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 API | https://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. |
| Dashboard | The web portal your team uses, at app.contentflow.click. |
| MCP | How AI assistants work with a workspace, acting as the signed-in dashboard user with that user's role. |
| Analytics | Where accepted events land: queryable within seconds, and rolled up into profiles hourly. |