Blocks & placements
A block is a piece of your app's UI whose content is controlled from ContentFlow: a home banner, a product card, an onboarding tooltip, a carousel, a whole benefits screen. Your developers define the block's shape once, in code. From then on the marketing team changes what it says, who sees it, when, and in which language, without an app release.
- Developers define a block type and its tagged fields once in code; marketers then fill, target and publish its content from the dashboard.
- In the app, read the block for its key (
getBlock(key)in JavaScript), read values by tag, and always keep a fallback. - A device gets only published instances that match its segment and schedule, highest priority first.
- Record an impression when the block shows and a tap when it's used, so the dashboard can count them.
- The most common trap: before identify, or without consent, a device has no segments and sees only All users content.
This guide follows a block from the definition in your repository to the pixels on a user's screen, and lists every reason it might not appear. For field types and the exact definition format, see Blocks & fields.
Business use cases
Targeted product offers
Show "Personal finance up to 500,000 SAR" only to the Salary > 3,000 SAR segment, and an auto-finance card to new users, in the same slot.
Campaign windows
A summer cashback banner scheduled for June 1 to August 31 that switches itself off, with Arabic and English copy.
A/B test the hero
Split traffic 50/50 between two headlines, measure taps and conversions per variant, then serve the winner to everyone.
Guided first steps
Tooltips and checklist cards for users in their first 30 days, removed automatically when they're no longer new.
Kill switch
Pause a block or disable a whole block type to pull a promotion or an entry point instantly, without a release.
Carousels and screens
A collection block holds an ordered list of real cards, each with its own targeting and metrics, so marketers curate a carousel or a full screen.
Maker-checker publishing
Editors draft; only approvers make content live. Every version and approval is kept.
Types, instances, collections
| Concept | Owned by | What it holds |
|---|---|---|
| Block type (definition) | Developers, in code | A key such as home_banner (lowercase letters, digits, underscores), name, screen, and fields. Each field has a tag the app reads values by, a type, and a default. A definition can be disabled, which removes it from delivery, or archived. |
| Instance | Marketers, in the dashboard | The content for one type: values per field, translations, a target segment, a priority (default 5), a schedule, an optional A/B test, and a status. |
| Collection | Both | A field of type collection on a wrapper block holds references to other card instances. ContentFlow inlines the eligible cards in order; a wrapper with no eligible cards is omitted. |
An app can show several instances of the same type: delivery returns every eligible instance for a key, highest priority first. getBlock(key) takes the first; getBlocks(key) takes them all.
From code to screen
- Your build sends the manifest of block types with the write key in
X-CF-Write-Key. New types are created; the app's existing content is seeded as draft instances.prune: truearchives types missing from the manifest.If this step is skipped: Without a registered type there's nothing to fill: the dashboard has no block to edit, getBlock(key) returns undefined, and GET /blocks/{key} answers 404 unknown block. An empty manifest is refused unless you pass prune: true.
- Definitions are stored per workspace. Field changes don't break live content: values for removed tags are dropped at delivery and logged.
- In the dashboard, the marketer fills the fields, adds Arabic and English text, picks a segment, priority and schedule, and optionally an A/B test. Saving with status live still saves a draft.
- Admins and approvers publish directly. Anyone else creates a
block_publishapproval request and the response sayssubmittedForApproval: true; approval publishes it. Publishing bumps the version and is refused if the payload would exceed the sync size limit.If this step is skipped: An unpublished instance (draft, review, paused) is never delivered. Publish-to-Test needs no approval and is delivered only to apps using the test key.
- The instance goes live once it is approved, or at once for admins and approvers.
- Connected apps get a change signal and re-sync within about a second; others pick it up on the next poll.
- The SDK asks for this device's blocks. The response is built fresh for each request.
If this step is skipped: Before the first identify (or without consent) the device has no segments, so only instances targeted to All users are returned.
- Live (or test) instances only; segment match; schedule window; locale overlay; variant bucket; priority order. See delivery rules.
- Each block arrives with
key,instanceId,version,valueskeyed by tag, the field list, andvariantIdorsponsoredwhen they apply. - The app renders by tag and records an impression (React hooks do this for you), then a tap or conversion when the user acts.
Lifecycle and approval
An instance is in one of five states:
| Status | Delivered to | How it gets there |
|---|---|---|
draft | Nobody | Every new or edited instance; seeded content from cards sync |
review | Nobody | Set explicitly while content is being checked |
test | Apps using a test key | Publish to Test (no approval) |
live | Everyone who matches | Publish, directly or through approval |
paused | Nobody | Pause; reversible |
Independent of status, an instance is also withheld when it's frozen by ContentFlow moderation, disabled, or its own on/enabled value is false. Disabling the type removes every instance of it at once. Instances are deleted outright; there's no archived state for them.
Which instances a device gets
On every /sync, for each block type in the workspace (in the type's display order), delivery applies these rules in turn:
- Status:
liveon a live key;liveandteston a test key. - Segment: the instance's segment is All users (or empty), or one of the device's segments. An instance aimed at several segments matches a device in any of them. The device's segments are the workspace's own segments it belongs to, plus All users.
- Schedule: see Schedules.
- Frequency: an instance whose frequency rule is reached for this user or device is left out, and the next instance of the same type by priority can take its place; see Popup frequency and limits.
- Language: values are overlaid with the requested locale's translation; see Languages.
- A/B: if a test is running, the device gets variant A or B; see A/B tests.
- Order: all eligible instances are returned, by priority (high first), then newest version.
Schedules
The schedule modes are always, custom, payday and ramadan, and all of them are enforced once someone has set the schedule in the dashboard:
always: no time limit.custom: from the start to the end, both included. Times are in the workspace time zone (Asia/Riyadh by default); a date without a time covers that whole day, 00:00 to 23:59.payday: every month from the 25th to the 3rd of the next month.ramadan: the month of Ramadan on the Umm al-Qura calendar.
payday and ramadan open and close at local midnight (Asia/Riyadh unless the schedule names another zone). Instances whose schedule was never set in the dashboard keep delivering whatever their mode.
Languages
The locale comes from ?locale= (the SDK always sends it), otherwise the first Accept-Language tag. The overlay then walks up the tag: ar-SA falls back to ar. Any field without a translation falls back to the instance's own value, then the type's default. A missing translation is never an error: the user sees the source text.
A/B tests
A test lives inside one instance: a split (the percentage that gets A) and B's translations. The bucket is a hash of the device id and the instance id, so a device always sees the same variant of that instance. A paused test serves no variant; a concluded test serves the winner to everyone. Each delivered block carries variantId, and the SDK stamps it on that block's events so results split by variant automatically.
Splitting across instances on the device. When a key has several eligible instances (for example two headlines, both for All users), the JavaScript SDK's variant(key) picks one of them for this device: a hash of the device id over the enabled instances for that key. A device keeps the same pick while the set of instances for the key stays the same; adding or removing one can change it. This is separate from the A/B test above: no variantId is assigned, and results split by instance. React and React Native have the same pick as the useCFVariant(key) hook, which also records the impression.
// 1. One of the eligible instances for the key, the same one for this device.
const hero = client.variant('home_hero');
// 2. Render it by tag, with a fallback.
showHero(hero ? String(hero.values['#headline']) : 'Welcome back');
// 3. Record the impression against the instance that was picked.
if (hero) client.trackImpression(hero);import { useCFVariant } from '@contentflow/sdk/react';
function Hero() {
// 1. One of the eligible instances for the key, the same one for this device.
// 3. The impression is recorded on first render.
const hero = useCFVariant('home_hero');
// 2. Render it by tag, with a fallback.
return <h1>{hero ? String(hero.values['#headline']) : 'Welcome back'}</h1>;
}import { Text } from 'react-native';
import { useCFVariant } from '@contentflow/sdk/react-native';
function Hero() {
// 1. One of the eligible instances for the key, the same one for this device.
// 3. The impression is recorded on first render.
const hero = useCFVariant('home_hero');
// 2. Render it by tag, with a fallback.
return <Text>{hero ? String(hero.values['#headline']) : 'Welcome back'}</Text>;
}Not available in the Swift SDK: there is no on-device pick across instances. Use an A/B test on one instance (above), which works on every SDK, or target the instances to different segments.
Not available in the Kotlin SDK: there is no on-device pick across instances. Use an A/B test on one instance (above), which works on every SDK, or target the instances to different segments.
Not available in the Flutter SDK: there is no on-device pick across instances. Use an A/B test on one instance (above), which works on every SDK, or target the instances to different segments.
Rendering
Read values by tag, never by position, and always have a fallback.
// 1. Read the first eligible instance, or undefined.
const banner = client.getBlock('home_banner');
// 2. Render by tag, with a fallback.
if (banner) {
showBanner(banner.values['#headline'], banner.values['#header_image']);
// 3. Record the impression, and the tap when the user acts.
client.trackImpression(banner);
button.onclick = () => client.trackTap(banner);
} else {
showBundledBanner();
}
// Website without a bundler: the embed fills this element.
// <div data-cf-block="home_banner"></div>import { useCFBlock } from '@contentflow/sdk/react';
// 1. Read the first eligible instance, or undefined.
// 3. The impression is tracked automatically on first render.
const banner = useCFBlock('home_banner');import { CFBanner } from '@contentflow/sdk/react-native';
// 1-3. Renders `fallback` when nothing is delivered, and tracks the impression and the tap.
<CFBanner blockKey="home_banner" fallback={<DefaultBanner />} />let headline = FieldKey<String>("#headline", type: .text)
let headerImage = FieldKey<ContentImage>("#header_image", type: .image)
// 1. Read one block for the key; nil when nothing is delivered.
// (Or render through ContentFlowSlot with a renderer for the placement.)
if let banner = try await client.fetchBlock(key: "home_banner") {
// 2. Render by tag, with a fallback.
try showBanner(banner.value(for: headline), banner.value(for: headerImage))
// 3. Record the impression, and the tap when the user acts.
_ = try await client.trackEngagement(.impression(banner))
// On tap: _ = try await client.trackEngagement(.tap(banner))
} else {
showBundledBanner()
}// 1. Read one block for the key, from a coroutine; null when nothing is delivered.
// (Or render through ContentFlowSlot in Compose with a renderer for the placement.)
val banner = client.fetchBlock("home_banner")
// 2. Render by tag, with a fallback.
if (banner != null) {
showBanner(banner.value(FieldKeys.text("#headline")), banner.value(FieldKeys.image("#header_image")))
// 3. Record the impression, and the tap when the user acts.
client.trackEngagement(type = "impression", blockKey = banner.key, instanceId = banner.instanceId)
// On tap: client.trackEngagement(type = "tap", blockKey = banner.key, instanceId = banner.instanceId)
} else {
showBundledBanner()
}final cf = ContentFlow.instance;
// 1. Read the cached block for the key, or null.
// getBlock doesn't skip hidden blocks, so check enabled too.
final banner = cf.getBlock('home_banner');
// 2. Render by tag, with a fallback.
if (banner != null && banner.enabled) {
showBanner(banner.get('#headline'), banner.get('#header_image'));
// 3. Record the impression, and the tap when the user acts.
await cf.trackImpression(blockKey: banner.key, instanceId: banner.instanceId);
// On tap: await cf.trackEngagement(type: CFEventType.tap, blockKey: banner.key, instanceId: banner.instanceId);
} else {
showBundledBanner();
}A block counts as hidden when its #block_enabled, #action_visible or #enabled value is false; in JavaScript, getBlock skips it. Use getBlocks(key) to render every eligible instance (for example a list of offers), and fetchBlock(key) for a one-off fresh read.
Personalisation tokens
A block value can contain tokens such as Welcome back, {{first_name}}. Marketers manage them in the dashboard under Content Library → Shared values, where each one is either a constant (the same text for everyone, such as a fee) or a personalisation value (from each customer's own data). They are resolved in two places:
- Constants, on the server. ContentFlow replaces a constant's
{{key}}(or the older[[key]]) with its current value before the block is sent, so every SDK receives the final text. Change the value once and every block that uses it updates. A token that isn't a constant is sent exactly as written. - Personalisation, on the device (JavaScript SDK). The SDK replaces
{{token}}and{token}in every text value with the device's traits: thetraitsin the client config, then the ones you send withidentifyorupdateTraits.{{name}}and{{full_name}}are thenametrait,{{first_name}}and{{last_name}}its first and last word,{{plan_tier}}theplantrait, and any other token the trait of the same name. A token with no matching trait stays exactly as written, braces included, so set the traits your copy uses or keep a fallback in your UI. Values are resolved when blocks arrive;updateTraitsre-syncs, so blocks re-render with the new values. Turn it off withinterpolate: falsein the config.
// 1. Give the SDK the traits your tokens use (re-syncs, so blocks re-render).
await client.updateTraits({ name: 'Sara Al-Qahtani', plan: 'gold' });
// 2. A headline authored as "Welcome back, {{first_name}}" arrives resolved.
const banner = client.getBlock('home_banner');
// 3. "Welcome back, Sara". A token with no matching trait stays as written.
showBanner(banner ? String(banner.values['#headline']) : 'Welcome back');import { CFProvider, useCFBlock } from '@contentflow/sdk/react';
// 1. Give the SDK the traits your tokens use.
<CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, traits: { name: 'Sara Al-Qahtani' } }}>
<WelcomeBanner />
</CFProvider>;
function WelcomeBanner() {
// 2. A headline authored as "Welcome back, {{first_name}}" arrives resolved.
const banner = useCFBlock('home_banner');
// 3. "Welcome back, Sara". A token with no matching trait stays as written.
return <h2>{banner ? String(banner.values['#headline']) : 'Welcome back'}</h2>;
}import { Text } from 'react-native';
import { CFProvider, useCFBlock } from '@contentflow/sdk/react-native';
// 1. Give the SDK the traits your tokens use.
<CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, traits: { name: 'Sara Al-Qahtani' } }}>
<WelcomeBanner />
</CFProvider>;
function WelcomeBanner() {
// 2. A headline authored as "Welcome back, {{first_name}}" arrives resolved.
const banner = useCFBlock('home_banner');
// 3. "Welcome back, Sara". A token with no matching trait stays as written.
return <Text>{banner ? String(banner.values['#headline']) : 'Welcome back'}</Text>;
}Not available in the Swift SDK: personalisation tokens arrive as written (for example {{first_name}}). Replace them in your own code before display, for example with replacingOccurrences(of:with:). Shared-value constants are already filled in by the server.
// The Kotlin SDK doesn't fill personalisation tokens itself; constants arrive filled in.
// 1. Read the block, from a coroutine.
val banner = client.fetchBlock("home_banner")
// 2. Replace {{key}} tokens yourself with the String.cf helper and your own user data.
val headline = banner?.value(FieldKeys.text("#headline"))?.cf(mapOf("first_name" to "Sara"))
// 3. "Welcome back, Sara". A token you don't pass stays as written.
showBanner(headline ?: "Welcome back")Not available in the Flutter SDK: personalisation tokens arrive as written (for example {{first_name}}). Replace them in your own code before display, for example with String.replaceAll. Shared-value constants are already filled in by the server.
In-app campaigns are personalised differently: see Personalising in-app campaigns.
Refresh and offline
- Launch: cached blocks paint first, then identify, then sync.
- Live: the SSE stream sends
block.published,block.updatedorblock.pausedand the SDK re-syncs. Without a stream, it polls every 30 s with backoff. - Resume: returning to the foreground forces a sync; backgrounding stops polling and the stream.
- Offline or failing: the SDK keeps the last good blocks. On a cold start with no network it tries its cache, then your
fallbackUrlmirror (blocks-<locale>.json), then thebundledCatalogyou ship. - Unchanged: a matching ETag gets
304; nothing is downloaded.
Tracking
| SDK call | Stored as | Counts toward |
|---|---|---|
trackImpression(block) | BANNER_IMPRESSION | Impressions (once per instance per session) |
trackTap(block) | BANNER_CLICK | Clicks, click rate |
trackCtaClick(block) | CTA_CLICK | Clicks |
trackDismiss(block) | DISMISSED | Dismissals |
trackConversion(block) | CONVERSION | Conversions, conversion rate |
Events need analytics consent and are batched (see Analytics). Live traffic also updates each instance's counters in the dashboard; test traffic never does.
When it doesn't work
| Cause | What the app sees | Fix |
|---|---|---|
| Block type never registered | getBlock → undefined; GET /blocks/{key} → 404 unknown block | Run cards sync from CI. |
| Instance not published (draft, review, paused) or test-only on a live key | Absent | Publish, or approve the pending request. |
| Device not in the segment | Absent; a new or non-consented device sees only All users content | Check identify ran with consent; check the person's segments in Audience. |
| Custom schedule in the future or past | Absent | Remember windows are in the schedule's time zone (Asia/Riyadh by default). |
| Field tag renamed in code | That tag's value is null; the server logs a warning | Keep tags stable; re-enter the value under the new tag. |
| Frequency rule reached for this user or device | Absent until the rule's window resets | Expected. Check Developers → Test mode → Why didn't it show?; see Popup frequency. |
Token shown as {{first_name}} | The raw token in the text | Set the trait it reads (JavaScript), or replace it in your code (Swift, Kotlin, Flutter); see Personalisation tokens. |
| Translation missing | Source-language text | Add the translation; no error is raised. |
| Image field empty or invalid | "" | Use Content Library assets (their durable URL never expires). |
| Type disabled | The whole key disappears | Re-enable it in Blocks. |
| Wrapper too large | Publish refused with collection_payload_too_large (limit 512 KB); at run time trailing cards are cut | Remove cards or shorten content. |
Still stuck? GET /blocks/{key} with the device's key and id tells you whether anything is eligible: visible: false means the type exists but no instance passes the rules for this device.
Next
- Campaigns & journeys: reach people with push, SMS, WhatsApp, email and pulled in-app campaigns.
- Blocks & fields: the field types and the exact block definition format.