Developer platform · API v1

ContentFlow developer docs

Server-driven content blocks for React Native, iOS, Android, and Web. Define block types in code, render them natively, then publish, localize, and roll back over the REST API, without an app release.

i You do not have to identify anyone to fetch contentContent delivery is a read. GET /sdk/sync, GET /sdk/blocks/:key and GET /sdk/strings all answer 200 with your workspace's live content when the only header you send is X-CF-Key: no device id, no identify call, no traits, no profile, nothing stored. If your app is privacy-sensitive, read Anonymous, content-only mode before you write a line of client code.

Get Started

Quickstart

Once the prerequisites below are met, the coding part takes about five minutes: you authenticate with a workspace key, sync live blocks over REST, and render one on screen. Getting the workspace, the seat, and the first published block is the part that is not five minutes, and it is not something this page can do for you.

0 · Before you start

The whole quickstart is gated on access you may not have yet. Check all four of these before you open an editor.

  • A workspace and a seat in it. ContentFlow is workspace-scoped, and there is no self-serve path from this page. If you do not already belong to a workspace, the person who administers it has to invite you. Nothing below works without that.
  • The SDK key and the tenant id. Both are visible to any workspace member at Dashboard, Developers, "Your keys" on app.contentflow.click: the Developers page, Overview tab, a card headed "Your keys" with rows for Base URL, Tenant ID, SDK key, Write key, and Read key.
  • The write key and the read key, if you need them. They are shown in that same card to admin and editor roles only. On a viewer seat the card tells you to ask a workspace admin, and no amount of API calling changes that: the seed and read endpoints are the ones that need those keys, so a viewer cannot complete them alone.
  • At least one block type registered and one live instance. GET /sdk/sync answers 200 for a brand new workspace, but data.blocks comes back as an empty array until content exists. An empty array is a correct answer, not a failure, so do not spend the afternoon debugging your headers over it.

1 · Install

Nothing is published yet: no ContentFlow SDK can be installed from a package registry today. @contentflow/sdk, @contentflow/cli, @contentflow/cache, @contentflow/react-native and the bare name contentflow all return 404 from npm, and Maven Central holds zero artifacts under the group click.contentflow.

There is also nothing to vendor. There is no public repository URL, no downloadable tarball, no CDN build, and no Maven coordinate, and cdn.contentflow.click does not resolve. Where this site says a package is "usable only from a local or vendored copy" it means a copy held inside ContentFlow, not an object you can go and fetch. If you do not already have the source on disk, there is nothing to look for, so stop looking.

Hand writing a REST client is the intended and supported path today, on every platform including React Native, and it is not a workaround. The rest of this page and the full REST reference document that path end to end: the headers, the response envelope, ETag caching, locale negotiation, and every error code.

terminal
# npm install @contentflow/sdk  → 404, not published
# yarn add @contentflow/sdk      → 404, not published
# no git URL, no tarball, no CDN, no Maven coordinate either

React Native. There is no React Native package to wait for, and you do not need one. The supported path today is exactly the REST calls shown on this page: plain fetch over HTTPS, no native module, no linking, no pod install, no Gradle change. The only platform specific pieces are supplying a stable device id string and persisting it yourself between launches, and you only need that for the calls that are about a device (identify, register-push, events, track-event, consent). Fetching content needs neither.

2 · Initialize

Grab a workspace key from Dashboard, Developers, "Your keys". X-CF-Key is the only header a /sdk/* call actually requires, because the key already names the workspace. X-Tenant-Id is optional and must agree with the key if you send it, and X-CF-Device is needed only by the calls that are about a device: identify, register-push, events, track-event, consent.

2a · Use REST (Recommended)

Call the REST endpoints directly using standard HTTP headers. This is the canonical, working integration path.

curl
$ curl https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"

2b · Content only, with no device id and no identify

Fetching content is a read. If you do not need segment targeting, per-user analytics, or messaging, the snippet below is the entire integration, and it collects nothing about anybody.

curl
$ curl "https://app.contentflow.click/api/v1/sdk/sync?locale=ar" \
     -H "X-CF-Key: ws_a1b2c3d4_app"

GET /sdk/sync, GET /sdk/blocks/:key and GET /sdk/strings all answer 200 this way. No identify call, no persisted device id, no traits, no profile, and on a live _app key no write of any kind. You give up segment-targeted instances (an unidentified device resolves to no segments, so it receives the untargeted, global ones), engagement analytics, messaging campaigns, and per-device A/B assignment. You gain nothing to declare as a persistent identifier and nothing stored to retain or delete.

i Pick this first if your app is privacy-sensitiveAnonymous mode is a supported, first-class way to use ContentFlow, not a trial tier, and adding identify later is additive rather than a migration. Sending a persistent device id and user traits is a decision with real downstream consequences on a store privacy questionnaire, so make it deliberately rather than by following the first example you found. Full tradeoff table: Anonymous, content-only mode.

2c · Use an SDK (unpublished)

The Web and React Native packages are written and tested but unpublished, so the import below resolves only from a copy you already hold on disk. There is no registry entry, repository URL or tarball to obtain one from, so this path is not open to you unless ContentFlow has handed you the source. On iOS the Swift SDK builds today as a local package; on Android the Kotlin SDK is source that has never been compiled. Check the availability table before you plan around any of them.

app.ts
// @contentflow/sdk is not on npm, this resolves only from a local copy
// import { ContentFlow } from '@contentflow/sdk'

3 · Sync & render over REST

This runs today, as written, with no ContentFlow package imported. It is plain JavaScript and fetch, so the same file works in React Native, on the web, and in Node 18 or newer. It takes its two inputs, the key and a device id, from the environment, because neither belongs in the file as a literal.

Read the cfGet helper before you copy the rest of it. Four different failures and one empty workspace all come back as valid JSON, and without a status check and an envelope check they are indistinguishable from "there is simply no content yet". This snippet checks both, throws on all four failures, and sets a non-zero exit code, instead of rendering nothing and reporting success.

home-hero.js
const BASE = 'https://app.contentflow.click/api/v1'

// Both are inputs, not constants. A committed key cannot be rotated without an
// app release, and a hard-coded device id merges every install into one device.
function readConfig(name) {
  const v = globalThis.process?.env?.[name]
  if (!v) throw new Error('ContentFlow: ' + name + ' is not set')
  return v
}
const KEY = readConfig('CF_SDK_KEY')       // shaped ws_a1b2c3d4_app
const DEVICE = readConfig('CF_DEVICE_ID')  // one stable string per install, persisted by you

// Every /sdk/* reply is enveloped: { success, data }. Check the HTTP status AND
// the envelope. Skipping either turns 401, 403, 404 and a wrong base URL into an
// empty array indistinguishable from a workspace with nothing published yet.
async function cfGet(path) {
  const res = await fetch(BASE + path, { headers: { 'X-CF-Key': KEY } })
  const body = await res.json().catch(() => null)

  if (!body || typeof body.success !== 'boolean') {
    // No success member means this did not come from the current gateway.
    throw new Error('ContentFlow: unenveloped reply from ' + BASE + path +
      ' (HTTP ' + res.status + '). Check your base URL.')
  }
  if (!res.ok || !body.success) {
    const e = body.error || {}
    throw new Error('ContentFlow: ' + path + ' failed, HTTP ' + res.status +
      ' ' + (e.code || 'UNKNOWN') + ': ' + (e.message || ''))
  }
  return body.data
}

// Replace with your own renderer. Defined here so the file runs as written.
function renderYourHero(title) { console.log('hero title:', title) }

async function showHero() {
  const data = await cfGet('/sdk/sync')   // throws on every failure above
  const blocks = data.blocks || []

  // Reaching this line means the call genuinely succeeded, so an empty list is
  // a real answer: no live instance of this block, not a broken integration.
  const hero = blocks.find((b) => b.key === 'home_hero')
  if (!hero) {
    console.warn('ContentFlow: sync succeeded, no live home_hero instance')
    return
  }

  // values is keyed by field tag, never by index or order
  renderYourHero(hero.values['#title_main'])

  const ev = await fetch(BASE + '/sdk/events', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CF-Key': KEY,
      'X-CF-Device': DEVICE
    },
    body: JSON.stringify({
      events: [{ type: 'impression', instanceId: hero.instanceId }]
    })
  })
  // Analytics must never break rendering, so warn here rather than throw.
  if (!ev.ok) console.warn('ContentFlow: impression not recorded, HTTP ' + ev.status)
}

showHero().catch((err) => {
  console.error(err.message)
  if (globalThis.process) globalThis.process.exitCode = 1
})

Each delivered block carries key, name, screen, instanceId, segment, version, values and fields. Read content out of values by field tag; fields describes the shape those tags belong to. The impression is posted against instanceId, which identifies the exact instance you rendered, not the block key.

! Five states reach this code, and only the last one means "no content yet"Every row below produces a valid JSON body. Without the status check and the envelope check, all five collapse into an empty blocks array, a quiet return and exit code 0. Four of them are failures.
What you sentWhat comes backUncheckedChecked, as above
A key naming a workspace that does not exist404 TENANT_NOT_FOUNDrenders nothing, exit 0throws, names the code, exit 1
A base URL pointing at the retired host404, and a body with no success memberrenders nothing, exit 0throws, names the base URL, exit 1
No key at all401 MISSING_SDK_KEYrenders nothing, exit 0throws, exit 1
A key the gateway cannot parse401 INVALID_SDK_KEYrenders nothing, exit 0throws, exit 1
A correct key, workspace with nothing published200, blocks: []renders nothing, exit 0warns, exit 0, which is the right answer
The envelope check is the one that catches a wrong base URL, because that is the failure a status check alone misses: see Base URL.
! CF_DEVICE_ID is one stable string per install, and persisting it is your jobThere is no correct literal value for it, which is why this snippet refuses to start without one. A constant shared by every install collapses your whole audience onto a single device. A value regenerated on each launch turns one person into a new device every time the app opens. Generate it once on first run, store it in AsyncStorage, the Keychain, SharedPreferences or localStorage, and read it back on every launch. It is needed only by the calls that are about a device: identify, register-push, events, track-event and consent. Fetching content needs none of them, and step 2b shows that path.
i Reading results back from a server or from CIThe read surface is separate from delivery and uses its own confidential credential: X-CF-Read-Key, shaped rk_live_ plus 24 lowercase hex characters. Three routes exist: GET /api/v1/read/users/{userId}/devices, GET /api/v1/read/devices/{deviceId} and GET /api/v1/read/users/{userId}/segments. Use them from a server or a CI job to check what a given user or device actually resolved to; never ship a read key in client code. Full contract in the REST reference.

In React, if you are running a local copy of the unpublished SDK, use the hooks instead. CFBlock is a TypeScript type describing a delivered block, not a component, so there is nothing to place in JSX.

Home.tsx
import { CFProvider, useCFBlock } from '@contentflow/sdk/react'

function Hero() {
  const hero = useCFBlock('home_hero')   // auto-tracks the impression
  if (!hero) return null
  return <YourHero title={hero.values['#title_main']} />
}
! There is no handshake callThis page used to tell you to run cf.handshake() after init. That method does not exist, in this SDK or any other, so nothing written against it ever ran. To confirm your key, workspace, and environment resolve before you ship, call GET /sdk/sync with your key and check that the response is enveloped ({ success, data }) and carries data.blocks. That is the same check the SDK itself performs, and it doubles as the base-URL check.

Authentication

Every /sdk/* request carries a short list of headers, and only one of them is required. There is no OAuth flow and no session to keep alive.

HeaderRequiredMeaningFormat
X-CF-KeyYesPublishable workspace key, the only credential the delivery API needs<workspaceId>_app or <workspaceId>_test
X-API-KeyNoAccepted alias for X-CF-Key on /sdk/*. Supported today and not deprecated. Send one or the other: if both are present X-CF-Key wins outright, see the precedence note belowSame key format as X-CF-Key
X-Tenant-IdNoWorkspace id. Optional and redundant on /sdk/*, since the key already names the workspace. It is required on portal JWT callsIf sent, must match the workspace bound to the key, or 403 TENANT_KEY_MISMATCH
X-CF-DeviceNoStable device id you generate. Needed only by identify, register-push, events, track-event and consentAny stable opaque string, persisted per install

The key goes in a header, with exactly one exception. ?key= in the query string is accepted on GET /sdk/stream only, because EventSource cannot set headers. On any other /sdk/* route it is refused with 401 SDK_KEY_IN_QUERY. Details for both this and X-API-Key are in Authentication in the REST reference.

! If both key headers are present, X-CF-Key wins silentlyWhen a request carries X-CF-Key and X-API-Key naming two different workspaces, the workspace is taken from X-CF-Key, X-API-Key is ignored, and nothing in the response says a second key was sent. The precedence is positional, not quality-based: a malformed X-CF-Key alongside a valid X-API-Key answers 401 INVALID_SDK_KEY rather than falling back to the good one. This matters if you run a shared networking layer with a legacy header injector, because that is how a staging key ends up masking a production one with no error anywhere. Send one header. Check it both ways yourself:
curl
$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_alpha9_app" -H "X-API-Key: ws_beta7_app"
# 404 Tenant not found: ws_alpha9   ← the X-CF-Key one

$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_beta7_app" -H "X-API-Key: ws_alpha9_app"
# 404 Tenant not found: ws_beta7    ← still the X-CF-Key one
Both workspace names above are invented, so both requests are safe to run without a key of your own.
i The three reads work with the key aloneGET /sdk/sync, GET /sdk/blocks/:key and GET /sdk/strings need no device id and no prior identify. The examples on this page send all three headers because most integrations are personalized; that is a convention, not a requirement. See Anonymous, content-only mode.

Environment comes from the key suffix. A key ending in _test resolves to the test environment and sees live plus test-staged content; anything else is live. Nothing in a request body or header can flip that.

curl
$ curl https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"
! Old cf_live_ and cf_test_ examples resolve to a workspace that does not existThis site used to show keys shaped cf_live_… and cf_test_…. Here is what actually happens to them. The server strips a known _app or _test suffix; when neither is present it falls back to the text before the last underscore. So cf_live_xxx resolves to a workspace named cf_live, which does not exist, and the answer is 404 {"code":"TENANT_NOT_FOUND","message":"Tenant not found: cf_live"}. 401 INVALID_SDK_KEY is a different failure: it means no workspace id could be parsed out of the key at all, which happens when the key has no usable underscore, that is, no underscore anywhere after the first character, and no _app or _test suffix with at least one character in front of it. nounderscore, app, _, _app and _test all land there. __app does not: it leaves the workspace id _, which parses, so it answers 404 Tenant not found: _. cf_test_xxx also does not end in _test, so it would not select the test environment either. Copy your real key from the dashboard rather than adapting an old snippet. See Authentication in the REST reference.
! Keep keys out of your repoThe key is publishable but still workspace-scoped. Ship it via your build's secret store, not a committed constant, so you can rotate it without an app release.

Core concepts

  • Block type, a UI shape defined in code (banner, card, tile, hero, list, carousel, popup, tooltip, toast, panel), with a set of typed fields. Registered over the REST API today; the CLI that will version types in git is not published yet.
  • Block instance, filled content of a given type, managed in the dashboard or seeded over the API with a stable externalId.
  • Sync, the SDK pulls the live set of instances for your tenant. Idempotent and cache-friendly.
  • Channel, where a block ships: push, WhatsApp, SMS, popup, or in-app.
  • Segment, the audience a block or campaign targets.

See the Wiki glossary for the full vocabulary.


SDK

SDK · availability

Not one of these packages can be installed from a registry today. They are at genuinely different stages, and the differences matter more than the word "preview" ever conveyed, so the table says what each one actually is. The REST API is the canonical integration path for every platform until that changes.

PlatformPackageWhat is actually true today
Web / React@contentflow/sdkWritten and tested. Not published: npm returns 404 for the name. Usable only from a local or vendored copy.
React Native@contentflow/react-nativeWritten and tested. Not published: npm returns 404 for the name. Usable only from a local or vendored copy.
iOS (Swift)ContentFlow (SPM)The package exists, builds cleanly, and passes its 146 tests, collection decoding included. Not published: no public Git URL and no SPM tag, so .package(url:from:) will not resolve. Consumable today as a local or vendored package. See the Swift SDK reference.
Android (Kotlin)click.contentflow:*Source exists and the tests are written, but the package has never been compiled: no JVM on the machine it was authored on, so zero build verification and zero test runs. Not on Maven Central, and the build declares no publishing at all. Weaker than the Swift package and not something to plan an Android release around. See the Kotlin SDK reference.
! Read a package's own status page before you commit to it"Exists" and "usable" are not the same thing here. The Swift package has met a compiler and a test runner; the Kotlin package has met neither. If you need a shipping Android path this quarter, write a REST client against the REST API reference instead.

Sync & render

sync() pulls every live instance for the tenant behind your key. The SDK strips unsupported url() wrappers from image values and normalizes field types, so what you render matches what you defined.

! Everything below is a shape reference, not runnable codeThis section describes the client surface of the ContentFlow JavaScript SDK, which is in preview and is not published to any registry, so the cf object it calls methods on cannot be constructed today. Read it as what the SDK will expose, and to check a method name against something real, but do not write code against it expecting it to run. The supported integration path today is REST: see the quickstart snippet above and the full REST reference.
render
await cf.sync()

// the winning instance for a key, from what sync already fetched
cf.getBlock('home_hero')

// every delivered instance of that key
cf.getBlocks('card')

// everything currently held
cf.getAll()

// fetch one block on demand, without a full sync
await cf.fetchBlock('home_hero')

// a collection field's cards, as whole blocks
cf.getBlock('home_hero')?.getCollection('#slides')

// subscribe to live updates (no redeploy)
const stop = cf.subscribe((blocks) => rerender(blocks))

subscribe takes a listener and returns an unsubscribe function. It takes no event name.

! cf.block(), cf.blocksOfType() and cf.on() were never realThis section previously showed all three, plus cf.on('content.published', …) as a client subscription. None of them exists in the SDK. The replacements are above: getBlock, getBlocks, and subscribe. content.published is a webhook event delivered to a server endpoint you register, not something a client listens for.

Localization

ContentFlow extracts strings from your blocks, auto-translates them, and delivers only review-approved translations. Nothing half-translated reaches production.

  • cf.getStrings(namespace?), fetch approved strings (served from /sdk/strings). The locale comes from the client's configured locale, set at construction or with cf.setLocale(locale). The optional argument is a namespace, not a locale.
  • cf.t(key, fallback?) reads one string from the loaded catalog, cf.getAllStrings() returns the whole map, and cf.subscribeStrings(listener) re-fires when the catalog refreshes. In React: useCFTranslations(namespace?) and useCFString(key, fallback?).
  • Source language shows first in the dashboard; English is shown as reference when a source locale is selected.
  • Hidden strings are soft-hidden, kept but never delivered.
! cf.strings(locale) does not existThis page showed it for a long time. The real call is cf.getStrings(namespace?), and it takes a namespace rather than a locale, so code written against the old signature would have quietly requested a namespace named "ar" and received an empty catalog. Set the locale on the client with setLocale, or send ?locale= if you are calling /sdk/strings over HTTP.
i Review-gatedThe wire value that ships is ok, which the dashboard displays as "Approved." The only other values are review and missing - there is no approved status anywhere in the API or the database. Only strings with status ok ship to /sdk/strings; draft and machine-translated strings are stored as review and stay in the dashboard.

Sensors · REST today

Sensors

Runtime Product Intelligence: turn existing product language into observation points. Every localization string is a dormant sensor. It renders normally and stays quiet until you enable observation remotely, with one toggle and no app release for activation. Enabled sensors emit consent-aware string_impression and string_interaction signals. Saved views are optional groupings for investigation across a surface, journey, or copy watch. Sensor analytics in the dashboard include Views, Interactions, Engagement rate, Reach, and Live now. Enabling sensors and managing saved views is free, analytics reads are available on Pro and Enterprise plans.

How it works

  • Dormant sensors: every localization key is available as an observation point, but sends no sensor telemetry until observation is enabled.
  • Enabled sensors: enabled keys are delivered as smartKeys from GET /api/v1/sdk/strings. Older integrations can ignore this field. The response ETag changes when the enabled sensor set changes.
  • Signals: send string_impression when an enabled string is used or viewed, and string_interaction when the user clicks or taps marked copy. Saved views can group enabled strings for investigation, but are not required to start observation.

Emit signals over REST

The REST API is the canonical integration path today. Pass the same workspace, tenant, and device headers used for block events. Signals are accepted only when device-level analytics consent is granted.

curl
$ curl -X POST https://app.contentflow.click/api/v1/sdk/events \
         -H "Content-Type: application/json" \
         -H "X-CF-Key: ws_a1b2c3d4_app" \
         -H "X-Tenant-Id: ws_a1b2c3d4" \
         -H "X-CF-Device: dev_9f2c41" \
         -d '{
           "consent": true,
           "events": [
             {
               "type": "string_impression",
               "key": "checkout.title",
               "locale": "en",
               "source": "usage",
               "path": "/checkout"
             }
           ],
           "context": {
             "sessionId": "sess_x",
             "platform": "web"
           }
         }'

With the unpublished Web SDK

This path needs the @contentflow/sdk package, which is not on npm (see SDK availability), so it applies only if you are running a local copy. There, t() emits usage impressions automatically for enabled smartKeys. Viewport impressions and click interactions additionally require host markup with data-cf-t. Viewport tracking uses IntersectionObserver when at least 50% of the element is visible.

checkout.tsx
// @contentflow/sdk, local copy only, not on npm
    const title = cf.t("checkout.title") // emits a usage impression for enabled keys
    
    return (
      <h1 data-cf-t="checkout.title">
        {title}
      </h1>
    )
    
    // data-cf-t enables viewport impressions and click interactions.
    // Old SDKs ignore smartKeys and emit no string signals.
! Enabling is not emittingEnabling a sensor only changes what the SDK is told to observe, the key is added to the smartKeys array delivered on the next strings refresh. It does not make your app emit anything by itself. If your code never calls t(key) for that key and never renders an element with data-cf-t="key", the sensor stays at zero forever, and that is expected, not a bug. The same happens if the key's namespace does not match the SDK session's active namespace: getStrings() only ever returns smartKeys for the namespace it was called with, so an enabled key in another namespace never reaches t() or data-cf-t at all.

Signal types

TypeWhen to sendRequired fields
string_impressionAn enabled string is used in code or becomes visible in the UI.key, locale, source as usage or viewport, optional namespace, path
string_interactionA user clicks or taps UI copy marked with data-cf-t.key, locale, optional namespace, path
Consent and countingString signals are dropped client side and server side unless device-level analytics consent is granted, using the same gate as block events. Impressions count once per session per string key. A viewport impression upgrades a prior usage impression and does not double-count. Interactions are not deduped. Paths are normalized before collection: query strings and fragments are stripped, id-like segments are redacted to :id, paths are lowercased, and values are capped at 128 characters.

CLI

CLI · cards-as-code

Built, not published: the @contentflow/cli package is written but is not on npm, where both @contentflow/cli and the bare name contentflow return a 404. There is no public repository URL, no tarball and no CDN build either, so there is nothing to vendor and nothing to fetch. Nothing below runs today. You do not need it to ship: a block type can be registered today over the REST API, which is what the CLI will call anyway. See registering a block type.

terminal
# npx contentflow login    → not on npm, 404
# npx contentflow init     → not on npm, 404
# npx contentflow push     → not on npm, 404
# no git URL and no tarball, so npx has nothing to resolve

Block types

Every dynamic block picks a design type. The renderer and dashboard badge follow the type automatically.

banner

Full-width strip.

card

Boxed content unit.

tile

Compact grid item.

hero

Large lead unit.

carousel

Swipeable set.

list

Stacked rows.

popup

Modal overlay.

tooltip / toast

Transient hints.

panel

Docked container.

The design type decides the shape. What a block holds is its fields: text, textarea, image, number, select, toggle, and collection. The first six are scalar. A collection holds an ordered list of other block instances, which delivery inlines in full under the field's tag, so a carousel or list is a wrapper around real card instances rather than one instance holding an array of values. Fields are addressed by tag, never by index or order. Blocks & fields covers every type, the block definition shape, how to register a type over REST, collections end to end, and a worked image-background banner.

i A card in a collection is a first class instanceBecause each item is a real block instance, it carries its own segment targeting, schedule, approval state, A/B variants and analytics, and your app renders it with the same code that renders a top-level block. See Collections. The repeater field type this replaces was withdrawn and never shipped.

REST API

REST API

Base URL https://app.contentflow.click/api/v1. All requests are JSON and authenticated with the X-CF-Key header; X-Tenant-Id is optional and redundant on /sdk/*. Below are the most-used endpoints; the full REST reference documents every /sdk/* endpoint, anonymous content-only mode, the response envelope, ETag caching, locale negotiation, portal API differences, and errors.

! The retired host still answers, which is worse than a 404The only supported base URL is https://app.contentflow.click/api/v1. The old https://api.contentflow.click/v1 host was never switched off: it still returns 200 with real-looking, frozen content to legacy keys, in the old unenveloped format, so nothing you publish ever reaches an app pointed at it and nothing errors. Do not test your base URL by checking for a 404, test that the response body is enveloped. See Base URL in the REST reference.

Blocks

GET/sdk/sync, live blocks for the tenant
GET/sdk/blocks/:key, one block, resolved the same way
POST/cards/sync, seed instances (idempotent by externalId)
i Both reads work with the key aloneNo X-CF-Device and no prior identify is needed. An unidentified caller resolves to no segments and receives the untargeted, global instances, which is a valid answer rather than an error. See Anonymous, content-only mode.
! POST /cards/sync needs a different keyThe two reads above use your publishable X-CF-Key. POST /cards/sync instead authenticates with a confidential X-CF-Write-Key header. Copy yours from the dashboard at Dashboard, Developers, "Your keys" - visible to admin and editor workspace roles only; a viewer role is told to ask a workspace admin instead of being shown the key. Never ship this key in client code; call /cards/sync from a server or CI script.

An empty or malformed manifest is rejected with 400. To archive every block for a tenant in one call, pass "prune": true together with an empty blocks array, an empty array on its own is treated as malformed and rejected.

cards/sync
// POST /cards/sync, archive all blocks
{
  "prune": true,
  "blocks": []
}

Audience

POST/sdk/identify, upsert a device/user profile, consent-gated
POST/sdk/consent, messaging-channel consent, not analytics consent
! Identify is optional, and it is the call that makes your integration a privacy questionIt is not required to fetch content. Its job is to attach a profile to a device: an identifier, traits, consent state, and segment membership. Call it when you want segment targeting, per-user analytics, or messaging. If you only need remotely editable copy, anonymous mode gives you that with no identifier and nothing stored.
! Two consents, two places, and the endpoint named "consent" is the messaging onePOST /sdk/consent manages messaging channels only: push, sms, whatsapp, email, locationTracking, marketing. Analytics consent is the consent boolean on /sdk/identify. Neither call affects the other, and both answer 200, so an analytics toggle wired to /sdk/consent silently records nothing. A single privacy screen has to write to both. See Consent & PDPL.

Forwarding from identify to Audience and segmentation is asynchronous (fire-and-forget). A successful response means the ingest service accepted the update, not that every Audience count has refreshed yet.

Consented identifies (consent: true) typically show up in Audience People within a few seconds (target: p95 one minute). Segment reach updates on the segment's refresh schedule (Daily by default), or immediately via the segment's Refresh now action, not on identify.

! Identify returned 201 but Audience shows 0Check three things: (1) the request must include consent: true, without it ContentFlow keeps the previous consent state and never stores traits pre-consent; (2) forwarding is asynchronous, allow up to a minute for propagation (segment reach additionally waits for the segment's scheduled or manual refresh); (3) without a userId the device is stored as anonymous presence, not a known profile.
i Anonymous presenceAny device without a userId appears in the Anonymous tab: first seen, last seen, platform, and consent state only, under a pseudonymous id. Devices without granted consent never get traits stored, and non-consented visitors are purged after about 90 days of inactivity. Supplying a userId with granted consent upgrades the same record to a known profile in place, nothing is lost.

Strings / Localization

GET/sdk/strings, approved translations for a locale
POST/strings/sync, bulk-ingest extracted strings
i The catalog belongs to the workspace, not to a personGET /sdk/strings never reads a device at all, so X-CF-Device is ignored and identify is irrelevant to it. A key is the whole request.
! POST /strings/sync also needs the write keySame confidential X-CF-Write-Key header, same Dashboard, Developers, "Your keys" location, same admin/editor-only visibility as /cards/sync above.
i How fast a published translation shows upDelivery reads the string/locale catalog from a per-tenant cache with a 60 second TTL. An edit typically appears well under that, but the worst case is a full 60 seconds - there is no faster guaranteed number, so do not build a support script or UI that assumes it lands in a few seconds.

Campaigns

POST/campaigns/:id/dispatch, send to a segment
GET/analytics/dashboard, per-workspace metrics
! Audience gotchaDispatching to the "All users" pseudo-segment needs real contacts with phone + consent for SMS/WhatsApp. An empty audience delivers to zero recipients.

Webhooks

Webhooks

Register an endpoint and subscribe to events to keep your systems in sync with what's live.

EventFires when
content.publishedA block instance goes live
content.rolledbackA block is reverted
string.approvedA translation is review-approved
campaign.sentA campaign finishes dispatch
Full REST API reference → Blocks & fields Wiki