Home / Guides / Blocks & placements
Product guide

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.

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

Why teams use blocks

Business use cases

Acquisition

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.

Seasonal

Campaign windows

A summer cashback banner scheduled for June 1 to August 31 that switches itself off, with Arabic and English copy.

Optimisation

A/B test the hero

Split traffic 50/50 between two headlines, measure taps and conversions per variant, then serve the winner to everyone.

Onboarding

Guided first steps

Tooltips and checklist cards for users in their first 30 days, removed automatically when they're no longer new.

Operations

Kill switch

Pause a block or disable a whole block type to pull a promotion or an entry point instantly, without a release.

Merchandising

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.

Compliance

Maker-checker publishing

Editors draft; only approvers make content live. Every version and approval is kept.

Model

Types, instances, collections

ConceptOwned byWhat it holds
Block type (definition)Developers, in codeA 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.
InstanceMarketers, in the dashboardThe 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.
CollectionBothA 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.

End to end

From code to screen

Define, fill, approve, deliver
  1. 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: true archives 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.

  2. Definitions are stored per workspace. Field changes don't break live content: values for removed tags are dropped at delivery and logged.
  3. 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.
  4. Admins and approvers publish directly. Anyone else creates a block_publish approval request and the response says submittedForApproval: 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.

  5. The instance goes live once it is approved, or at once for admins and approvers.
  6. Connected apps get a change signal and re-sync within about a second; others pick it up on the next poll.
  7. 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.

  8. Live (or test) instances only; segment match; schedule window; locale overlay; variant bucket; priority order. See delivery rules.
  9. Each block arrives with key, instanceId, version, values keyed by tag, the field list, and variantId or sponsored when they apply.
  10. 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

Lifecycle and approval

An instance is in one of five states:

StatusDelivered toHow it gets there
draftNobodyEvery new or edited instance; seeded content from cards sync
reviewNobodySet explicitly while content is being checked
testApps using a test keyPublish to Test (no approval)
liveEveryone who matchesPublish, directly or through approval
pausedNobodyPause; 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.


Delivery rules

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:

  1. Status: live on a live key; live and test on a test key.
  2. 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.
  3. Schedule: see Schedules.
  4. 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.
  5. Language: values are overlaid with the requested locale's translation; see Languages.
  6. A/B: if a test is running, the device gets variant A or B; see A/B tests.
  7. Order: all eligible instances are returned, by priority (high first), then newest version.
i Frequency rulesAn instance can carry a frequency rule (for example Once per day, or until the customer closes it). ContentFlow leaves an instance out once this user or device has reached its rule, counted from the impressions, taps and dismissals the app tracked, and popups also share the workspace popup limits. Without a rule, an eligible instance is delivered on every sync; sponsored content has its own daily and share limits. See Popup frequency and limits.
Delivery rules

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.

Delivery rules

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.

Delivery rules

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.


In the app

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.

In the app

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: the traits in the client config, then the ones you send with identify or updateTraits. {{name}} and {{full_name}} are the name trait, {{first_name}} and {{last_name}} its first and last word, {{plan_tier}} the plan trait, 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; updateTraits re-syncs, so blocks re-render with the new values. Turn it off with interpolate: false in 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.

In the app

Refresh and offline

  • Launch: cached blocks paint first, then identify, then sync.
  • Live: the SSE stream sends block.published, block.updated or block.paused and 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 fallbackUrl mirror (blocks-<locale>.json), then the bundledCatalog you ship.
  • Unchanged: a matching ETag gets 304; nothing is downloaded.
In the app

Tracking

SDK callStored asCounts toward
trackImpression(block)BANNER_IMPRESSIONImpressions (once per instance per session)
trackTap(block)BANNER_CLICKClicks, click rate
trackCtaClick(block)CTA_CLICKClicks
trackDismiss(block)DISMISSEDDismissals
trackConversion(block)CONVERSIONConversions, 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.

Troubleshooting

When it doesn't work

CauseWhat the app seesFix
Block type never registeredgetBlock → undefined; GET /blocks/{key} → 404 unknown blockRun cards sync from CI.
Instance not published (draft, review, paused) or test-only on a live keyAbsentPublish, or approve the pending request.
Device not in the segmentAbsent; a new or non-consented device sees only All users contentCheck identify ran with consent; check the person's segments in Audience.
Custom schedule in the future or pastAbsentRemember windows are in the schedule's time zone (Asia/Riyadh by default).
Field tag renamed in codeThat tag's value is null; the server logs a warningKeep tags stable; re-enter the value under the new tag.
Frequency rule reached for this user or deviceAbsent until the rule's window resetsExpected. Check Developers → Test mode → Why didn't it show?; see Popup frequency.
Token shown as {{first_name}}The raw token in the textSet the trait it reads (JavaScript), or replace it in your code (Swift, Kotlin, Flutter); see Personalisation tokens.
Translation missingSource-language textAdd the translation; no error is raised.
Image field empty or invalid""Use Content Library assets (their durable URL never expires).
Type disabledThe whole key disappearsRe-enable it in Blocks.
Wrapper too largePublish refused with collection_payload_too_large (limit 512 KB); at run time trailing cards are cutRemove 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