Home / Blocks & fields
Developer platform · API v1

Blocks & fields

A block type is a UI shape defined in code. Its fields are the editable slots inside that shape, and each field has a type. ContentFlow ships eight field types: seven scalars (text, textarea, image, number, select, toggle, url), including image, which holds an asset URL, plus collection, which holds an ordered list of real block instances. Fields are addressed by tag, never by array position.

This page covers every field type, the exact wire shape of a block definition, the two endpoints that put a block type into a workspace, how a collection turns a carousel into a wrapper around first class card instances, and a complete worked example of an image background banner.

i Start here if your workspace looks emptyA brand new workspace is seeded with exactly one starter block type, welcome_banner, which carries three text style fields and no image field. That is a starter seed, not the limit of the platform. Everything on this page applies to any workspace from day one, including yours.

Division of labour

Who controls what

Block types and fields are developer territory. The content inside those fields is marketer territory. That split is the whole design, and it is why there is no field editor in the dashboard.

Developers

Define block types and their fields

Cards as code. The schema lives in your repository and ships through your pipeline.

  • Block type key, name, screen, crumb
  • Which fields exist, and each field's type: text, image, number, and the rest
  • Tags the app reads values by
  • Defaults a fresh instance starts from
  • How the type is rendered natively in the app
Marketers

Edit the content inside those fields

In the dashboard, with no app release and no developer involved.

  • Create instances of a block type
  • Fill every field, including uploading and swapping images
  • Target a segment and a locale
  • Publish, pause, and roll back
  • Schedule and A/B the same block type

There is deliberately no "create a block type" button in the dashboard. A block type is a contract with your app's rendering code, so it is versioned in your repository alongside the code that renders it, exactly like a database migration. Adding one is a two minute API call, documented below.


Block types

Every block picks a design type; the renderer and dashboard badge follow it. What the block holds is its fields, described next.

bannerFull-width strip.
cardBoxed content unit.
tileCompact grid item.
heroLarge lead unit.
carouselSwipeable set.
listStacked rows.
popupModal overlay.
tooltip / toastTransient hints.
panelDocked container.
Reference

Field types

Eight types, all first class, all editable in the dashboard and all delivered over GET /sdk/v1/sync. Seven are scalars. The eighth, collection, holds a list.

TypeHoldsDelivered asTypical use
textSingle line stringStringHeadline, CTA label, badge
textareaMulti line stringStringBody copy, description, terms
imageAn asset URLPlain URL stringBackground image, hero, tile art, logo
numberNumeric valueNumberMinimum salary, rate, price, count
selectOne of a fixed setThe chosen valueTheme, variant, icon name, tier
toggleBooleanBooleanShow or hide the CTA, enable the block
urlA destinationValidated string: https://…, mailto:, tel:, an app deep link (myapp://path) or an /in-app/path. javascript:, data: and other unsafe schemes are rejected on saveCTA target, deep link
collectionAn ordered list of references to other block instancesAn array of whole delivered blocks, inlinedCarousel slides, a feed, a stacked row of cards
i image is a real field typeAn image field is not a text field with a convention on top. It is typed, the dashboard renders an upload and preview control for it, and ContentFlow normalizes its value to a plain URL before your app sees it. See Content Library assets for how an upload gets into one.
i A collection is the only non scalar type, and its items are real instancesEvery other field holds one value. A collection holds an ordered list of block instances, and ContentFlow inlines each one in full under the collection's tag. A card is therefore a block: it carries its own instanceId, its own segment targeting, schedule, approval state, A/B variants and analytics. See Collections.

Fields are addressed by tag

Every field carries a tag, a string like #title_main or #header_image. Your app reads values by tag. It never reads them by array index, and it never depends on field order.

GET /sdk/v1/sync returns each block's values object keyed by tag, alongside a fields array that repeats each tag with its type, so a renderer can branch on type instead of guessing from the name.

read by tag
// values is keyed by TAG
const title = block.values['#title_main']
const image = block.values['#header_image']

// fields tells you the type, so you can render by type
block.fields.forEach(f => {
  if (f.type === 'image') paintBackground(block.values[f.tag])
})
  • Tags are yours to choose. Pick names that describe the slot, not the current copy.
  • Tags must be unique within their immediate scope. Two different block types may both use #title_main. The server enforces this inside a collection only; for scalar fields it is not validated, but treat it as required, because a repeated tag makes the value ambiguous.
  • Renaming a tag is a breaking change for any app version already reading the old one. Add a new field instead, then retire the old one once old app versions have aged out.

Block definition shape

A block definition is a small JSON object. This is the whole contract.

PropertyMeaning
keyBlock type key, unique per workspace. Lowercase letters, digits, and underscores. 1 to 100 characters.
nameHuman label shown in the dashboard. 1 to 150 characters.
screenWhere the block appears in your app, for example Home screen. Up to 150 characters.
crumbBreadcrumb shown to editors, for example home_screen / hero_banner. Up to 150 characters.
fieldsArray of field descriptors. Each is { id, tag, type, label, default }.
blankOptional object of defaults a fresh instance starts from, keyed by field id.
inCampaignsWhether this block type can be picked as a campaign channel.
interruptiveOptional boolean. When true the block renders as a modal, popup or interstitial. Absent means not interruptive.

And a single field:

Field propertyMeaning
idStable internal id for the slot. Instance values are stored against it.
tagThe public address your app reads, for example #header_image.
typeOne of the eight types above. The server stores an unknown type string without complaint, but the SDKs will not render it.
labelWhat an editor sees next to the input in the dashboard.
defaultStarting value for a new instance.
optionsOptional array of choices for a select field.
maxLengthOptional positive integer: the character limit for a text field.
requiredOptional boolean: the field must not be empty.
allowedBlockKeyscollection only, and required there. The block definition keys this collection may reference. Validated against immutable keys, never display names.
minItems, maxItemscollection only. Non negative integers bounding how many references an author may store. Not a promise about how many cards a given device is delivered.

A scalar field that declares allowedBlockKeys, minItems, or maxItems is rejected, and so is any field that declares an inline fields sub schema. No field type carries a nested schema: a collection names other definitions by key, and each referenced definition owns its own fields.

block definition
{
  "key": "hero_banner",
  "name": "Hero banner",
  "screen": "Home screen",
  "crumb": "home_screen / hero_banner",
  "inCampaigns": true,
  "fields": [
    { "id": "bg",    "tag": "#header_image",  "type": "image",    "label": "Background image", "default": "" },
    { "id": "title", "tag": "#title_main",    "type": "text",     "label": "Headline",         "default": "" },
    { "id": "body",  "tag": "#body_desc",     "type": "textarea", "label": "Supporting text",  "default": "" },
    { "id": "cta",   "tag": "#cta_label",     "type": "text",     "label": "CTA label",        "default": "" },
    { "id": "on",    "tag": "#block_enabled", "type": "toggle",   "label": "Block enabled",    "default": true }
  ],
  "blank": { "bg": "", "title": "", "body": "", "cta": "", "on": true }
}
i id versus tagInstance values are stored against the field id, and delivered to your app against the field tag. You only need to think about tags in app code. Keep both stable once a block type is live.

The definition schema is not the delivery schema

There are two different fields arrays in this product, and conflating them is an expensive mistake. The one you write when registering a block type is not the one you read from GET /sdk/v1/sync.

Definition fieldsDelivered fields
WhereYour POST /cards/register or POST /cards/sync bodyThe GET /sdk/v1/sync response
Shape{ id, tag, type, label, default }, plus allowedBlockKeys / minItems / maxItems on a collection{ tag, type } for a scalar field. A collection field additionally carries allowedBlockKeys, and minItems / maxItems when the definition set them
PurposeAuthoring: names the slot, labels it for editors, seeds a defaultRendering: tells your app how to render each tag
ContentNot content. default is a starting value, not a live value.Not content either. Content lives in values.
! Do not look for content in the delivered fields arrayThe delivered fields array is schema only. It carries no id, no label, no default, and above all no value. Every live value is in the block's values object, keyed by tag. Read content from values[tag], and use fields only to learn what type each tag is. That holds for a collection too: the cards are in values, and the collection's allowedBlockKeys, minItems and maxItems in fields are authoring constraints, not a filter you are expected to apply.

Registering a block type

Which credential to use

Three keys, not interchangeable. The write key and read key are in the dashboard under Developers → Your keys (shown to admin and editor seats only, and rotated there); the SDK key is under Settings → API keys. Write and read keys are stored hashed: the full value is shown once, when it is created or rotated, and masked afterwards. If you lost it, rotate it.

KeyHeaderWhat it can do
Your SDK (publishable) key, <workspace-id>_cfpk<40 hex>, created under Settings → API keys. The older derived keys <workspace-id>_app / <workspace-id>_test still work unless the workspace switches them off.X-CF-Key (or X-API-Key)Ships in your app. Reads content and records consent, events and push tokens for one installation over /sdk/v1. It can never define or change a block type.
Write key wk_live_X-CF-Write-KeyServer or CI only. Registers block types and seeds instances: POST /cards/register and POST /cards/sync (on https://api.contentflow.click/sdk/v1, also served at /api/v1/cards/…), and POST /api/v1/strings/sync for source strings.
Read key rk_live_X-CF-Read-KeyServer only. Reads back what a user or device resolved to. It cannot write anything. See the REST guide.
! Do not send the SDK key to a write endpointYour SDK (publishable) key only authenticates /sdk/v1 delivery and device routes. Sending it to POST /cards/sync or /cards/register fails authentication, which is the most likely reason a first registration attempt returns 401.
! POST /cards/sync declares the full set, it does not mergeThe manifest you send is the complete intended state of the workspace, so every currently active definition the manifest does not name is archived. A CI job built on this endpoint has to send the complete set of block types on every single run, or the ones it leaves out are archived by that run. An empty manifest is refused outright unless the body carries "prune": true, which then archives everything. To touch one definition and leave the rest alone, use the single-type counterpart POST /cards/register, which upserts one definition by key. Neither route is a prerequisite for the other: a manifest entry creates its own definition, so you never have to call register first.
! Don't use the SDK registerBlock / syncBlocks helpers (2.2.2 and earlier)In all four SDKs they send each definition nested under a definition object, which the server does not read, and registerBlock sends a one-entry manifest to POST /cards/sync. The block type is saved with its key as its name and without your fields, and every other active block type in the workspace is archived. Call the HTTP routes on this page until a fixed SDK ships.
! Treat the write key like a deploy keyStore it in your CI secret store, never in the app bundle and never in the repository. If it leaks, rotate it under Developers → Your keys. The old key stops working the moment the new one is issued, with no overlap window, so land the replacement in your CI secret store in the same change. The read key rotates the same way.

Register one block type: POST /cards/register

POST/cards/register, register or upsert a single block definition

Authenticated with X-CF-Write-Key. Upserts by key, so running it twice is safe and the second run updates the definition in place. The body needs key (matching /^[a-z0-9_]+$/) and optionally takes name (defaults to the key), screen, crumb, fields, blank, inCampaigns and seedInstance. By default it also seeds one blank starter draft instance; send "seedInstance": false to skip it.

curl
$ curl -X POST https://api.contentflow.click/sdk/v1/cards/register \
     -H "X-CF-Write-Key: $CF_WRITE_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "key": "hero_banner",
       "name": "Hero banner",
       "screen": "Home screen",
       "fields": [
         { "id": "bg",    "tag": "#header_image",  "type": "image",    "label": "Background image" },
         { "id": "title", "tag": "#title_main",    "type": "text",     "label": "Headline" },
         { "id": "body",  "tag": "#body_desc",     "type": "textarea", "label": "Supporting text" },
         { "id": "cta",   "tag": "#cta_label",     "type": "text",     "label": "CTA label" },
         { "id": "on",    "tag": "#block_enabled", "type": "toggle",   "label": "Block enabled" }
       ],
       "blank": { "bg": "", "title": "", "body": "", "cta": "", "on": true }
     }'

Use this for a one-off registration or a quick experiment. Use the manifest below for anything that runs in CI.

Register many at once: POST /cards/sync, the manifest

POST/cards/sync, upsert definitions and seed instances in one idempotent call

Authenticated with the workspace write key in X-CF-Write-Key. The workspace is resolved from the key itself, so no tenant header is needed. This is the endpoint to run from CI on every merge: it declares the full set of block types your app expects, and reconciles the workspace to match.

  • Idempotent by externalId for seeded instances. Re-running updates in place rather than duplicating.
  • Definitions are upserted by key.
  • Each entry takes the same shape as a POST /cards/register body, plus an optional instances array carrying content.
  • The manifest array is blocks. The alias cards is accepted for the same array, and inside an entry the alias items is accepted for instances.
  • Any active block type not present in a landed manifest is archived. This endpoint declares the full set, it does not merge, so send your complete set every time.
  • An empty or malformed manifest is rejected with 400.
  • There is no dry-run mode. A dryRun flag in the body is ignored and the sync is applied, so read archived in the response.
  • Keys must match /^[a-z0-9_]+$/. An invalid key is reported per entry and the rest of the manifest still lands.
curl
$ curl -X POST https://api.contentflow.click/sdk/v1/cards/sync \
     -H "X-CF-Write-Key: $CF_WRITE_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "blocks": [
         {
           "key": "hero_banner",
           "name": "Hero banner",
           "screen": "Home screen",
           "fields": [
             { "id": "bg",    "tag": "#header_image", "type": "image", "label": "Background image" },
             { "id": "title", "tag": "#title_main",   "type": "text",  "label": "Headline" }
           ],
           "instances": [
             {
               "externalId": "home_hero_default",
               "title": "Summer campaign",
               "status": "draft",
               "values": {
                 "#header_image": "https://your-cdn.example.com/summer-hero.jpg",
                 "#title_main": "Summer rates are live"
               }
             }
           ]
         }
       ]
     }'

Seeded instance content may be supplied as values keyed by tag, which is the readable form shown above. Seeded instances land as draft unless you set status, so importing your app's existing content never publishes anything by accident.

! Archiving every block type is an explicit opt inAn empty blocks array on its own is treated as malformed and rejected, precisely so a broken build cannot wipe a workspace. To deliberately archive every block type, pass prune together with the empty array.
cards/sync, archive all
{
  "prune": true,
  "blocks": []
}

Worked example

An image background banner, end to end

The full path from nothing to a banner whose background is a marketer editable image: define the type, register it, let an editor fill it, read it back, and paint it. Four calls and one loop.

1 · Define the type with an image field

One image field tagged #header_image, alongside the text fields that sit on top of it. The image field is what the customer facing upload control binds to.

hero_banner.json
{
  "key": "hero_banner",
  "name": "Hero banner",
  "screen": "Home screen",
  "crumb": "home_screen / hero_banner",
  "fields": [
    { "id": "bg",    "tag": "#header_image",  "type": "image",    "label": "Background image" },
    { "id": "title", "tag": "#title_main",    "type": "text",     "label": "Headline" },
    { "id": "body",  "tag": "#body_desc",     "type": "textarea", "label": "Supporting text" },
    { "id": "cta",   "tag": "#cta_label",     "type": "text",     "label": "CTA label" },
    { "id": "on",    "tag": "#block_enabled", "type": "toggle",   "label": "Block enabled" }
  ],
  "blank": { "bg": "", "title": "", "body": "", "cta": "", "on": true }
}

2 · Register it in the workspace

With the write key and the single-type route, which upserts this one definition and leaves every other block type alone. @hero_banner.json is the file above.

curl
$ curl -X POST https://api.contentflow.click/sdk/v1/cards/register \
     -H "X-CF-Write-Key: $CF_WRITE_KEY" \
     -H "Content-Type: application/json" \
     -d @hero_banner.json

// 201 Created
{ "success": true,
  "data": { "registered": "hero_banner", "definitionCreated": true,
            "instanceId": "…", "instanceCreated": true } }

In CI, put this definition in your full manifest and use POST /cards/sync. That endpoint archives every block type the manifest leaves out, so never send it a partial list.

3 · An editor fills it in

hero_banner now appears in the dashboard Blocks library with a real upload control on the Background image field. A marketer creates an instance, uploads or picks an image, writes the copy, targets a segment, and publishes. No further developer involvement, and no app release.

To seed the first instance yourself instead, include an instances array in the manifest with values keyed by tag, as shown in Path 2.

4 · Read it back from the SDK surface

This is the delivery read, so it uses the publishable SDK key, not the write key. The key alone is enough here: the tenant is resolved from the key's stored record (for a publishable key), so there is no X-Tenant-Id header to set. A conflicting X-Tenant-Id is refused with 401 TENANT_KEY_MISMATCH.

curl
$ curl https://api.contentflow.click/sdk/v1/sync \
     -H "X-CF-Key: $CF_SDK_KEY" \
     -H "X-CF-Device: $CF_DEVICE_ID"

$CF_SDK_KEY is your test-environment publishable key while you test (it also sees instances published to Test) and your live publishable key in production.

200 OK
{
  "success": true,
  "data": {
    "blocks": [
      {
        "key": "hero_banner",
        "name": "Hero banner",
        "screen": "Home screen",
        "instanceId": "b1f0…",
        "segment": "All users",
        "version": 3,
        "values": {
          "#header_image": "https://your-cdn.example.com/summer-hero.jpg",
          "#title_main": "Summer rates are live",
          "#body_desc": "Fixed for 12 months, from 3.5%.",
          "#cta_label": "See the rate",
          "#block_enabled": true
        },
        "fields": [
          { "tag": "#header_image",  "type": "image" },
          { "tag": "#title_main",    "type": "text" },
          { "tag": "#body_desc",     "type": "textarea" },
          { "tag": "#cta_label",     "type": "text" },
          { "tag": "#block_enabled", "type": "toggle" }
        ]
      }
    ],
    "version": "9c2a…"
  }
}

A workspace with no live blocks yet returns the same shape with an empty blocks array. That is a valid 200, not an error.

5 · Paint the background

Access the image by tag. An image value arrives as a plain URL string, ready to hand to an image view or a background style.

HomeBanner
const hero = data.blocks.find(b => b.key === 'hero_banner')
if (!hero) return null   // nothing live for this device, render nothing

const bg    = hero.values['#header_image']  // '' when unset, never a broken URL
const title = hero.values['#title_main']
const cta   = hero.values['#cta_label']

banner.style.backgroundImage = bg ? `url(${bg})` : 'none'
banner.querySelector('.t').textContent = title
banner.querySelector('.cta').textContent = cta
i Image values are normalized for youThe dashboard editor previews images as CSS backgrounds, so a stored value can carry a url(...) wrapper. ContentFlow strips that wrapper and the quotes before your app sees it, so an image field always arrives as a plain URL. A placeholder that is not a real image reference, such as a gradient default, is delivered as an empty string, so guard on empty and render no image rather than a broken one.

That is the entire loop. The image is now marketer editable forever: they swap the file in the dashboard, publish, and the next sync paints the new background with no release.

Binding a Content Library upload to an image field

An image field value is a URL. Two sources work:

  • Your own CDN. Any HTTPS URL you host. Paste or set it as the field value.
  • The ContentFlow Content Library. Upload the asset, then use its stable public URL, https://api.contentflow.click/api/v1/content/<assetId>/raw. That route resolves the asset by its unguessable id and redirects to a freshly signed download URL on every request, so the value you store in a block field does not expire.
! Do not store a raw presigned URL in a block fieldPresigned download URLs are short lived by design. Store the stable /content/<assetId>/raw URL instead, or your banner will render fine on the day it is published and break days later.

Direct answer

Which block type exposes #header_image

Short answer

#header_image is not a platform reserved tag and it is not owned by any built in block type. It is simply the tag our demo workspace happens to give the image field on its discovery_card block type, and the tag our onboarding snippets use as an example.

Any workspace can create a field with that tag, or any other tag, using the two endpoints above. Nothing has to be requested from us and nothing is gated.

These are the demo block types that ship with image fields, and the tags they use:

Block typeImage field tagRole in the demo
discovery_card#header_imageHeader image on a home screen discovery card
benefits_screen#hero_imageHero image on a full benefits screen
promo_banner#bg_imageBackground image on a promotional banner
insurance_tile#tile_imageTile artwork in a grid of products
! These live in the demo workspace onlyThey are demo seed data, so they are present in the demo workspace and absent from yours. That is the reason a fresh workspace shows only welcome_banner and no image field anywhere. It is a seeding decision, not a capability boundary. Register your own equivalent with POST /cards/register or POST /cards/sync and your workspace has image fields immediately.

Copying the demo tags exactly is a reasonable starting point if you want your app code to match our onboarding snippets, but you are free to name every tag yourself.


Collections

A list is a wrapper around real card instances

A collection field holds an ordered list of references to other block instances. The block that owns the field is the wrapper, for example a carousel. Each referenced instance is a card. A card is an ordinary block instance, which is the entire reason this shape exists: it carries its own segment targeting, its own schedule, its own approval state, its own A/B variants and its own analytics, because all of that already lives at the instance level and a card is an instance.

Nothing in the list is a nested value. The wrapper stores instance ids, ContentFlow resolves them, and your app receives whole blocks.

1 · Define the card, then the wrapper

The card is an ordinary block definition with ordinary scalar fields. The wrapper declares which definition keys its collection may reference, and nothing about their contents.

reading_card.json, an ordinary block
{
  "key": "reading_card",
  "name": "Reading card",
  "screen": "Home screen",
  "fields": [
    { "id": "title", "tag": "#title", "type": "text",     "label": "Title" },
    { "id": "image", "tag": "#image", "type": "image",    "label": "Artwork" },
    { "id": "body",  "tag": "#body",  "type": "textarea", "label": "Body" }
  ]
}
reading_carousel.json, the wrapper
{
  "key": "reading_carousel",
  "name": "Reading carousel",
  "screen": "Home screen",
  "fields": [
    { "id": "heading", "tag": "#heading", "type": "text", "label": "Heading" },
    { "id": "slides",
      "tag": "#slides",
      "type": "collection",
      "label": "Slides",
      "allowedBlockKeys": ["reading_card"],
      "minItems": 0,
      "maxItems": 20 }
  ]
}

A wrapper may hold any number of ordinary scalar fields alongside its collection fields, and a collection may list several allowed keys, so a feed can mix article_card and promotion_card.

2 · The wrapper stores instance ids

Collection references live in the wrapper's existing value map, under the same key every other field uses: the field id, with the tag accepted as a fallback. The stored value is exactly an ordered array of instance id strings. It is never an array of objects and it never snapshots the referenced content.

stored wrapper values
{
  "heading": "Recommended for you",
  "slides": [ "card-a12f…", "card-b34c…", "card-c56d…" ]
}
  • Editing a card changes every wrapper that references it. That is the point of a reference, and it is why the sync validator is a digest of the whole resolved response rather than of the wrapper's own version.
  • Order lives only on the wrapper. Moving a card in one carousel does not move it in another.
  • Identity is the card's instanceId. Array position is never identity: a card moved from slot 2 to slot 5 keeps its id, and replacing a card produces a new one.
  • A locale overlay may not touch the list. References are structural source locale data, so a collection key inside an i18n overlay is rejected rather than silently applied. Translators still translate each card's own scalar values, on the card.

Collections

What delivery returns

Eligible cards are inlined under the collection's tag, each as the full delivered block shape: key, name, screen, instanceId, segment, numeric version, resolved values, and schema only fields. No second round trip, and no card model to learn. A card is a block.

GET /sdk/v1/sync, one wrapper
{
  "key": "reading_carousel",
  "name": "Reading carousel",
  "screen": "Home screen",
  "instanceId": "carousel-7df3…",
  "segment": "All users",
  "version": 4,
  "values": {
    "#heading": "Recommended for you",
    "#slides": [
      {
        "key": "reading_card",
        "name": "Reading card",
        "screen": "Home screen",
        "instanceId": "card-a12f…",
        "segment": "All users",
        "version": 6,
        "values": {
          "#title": "First article",
          "#image": "https://cdn.example.com/first.jpg",
          "#body":  "First article body"
        },
        "fields": [
          { "tag": "#title", "type": "text" },
          { "tag": "#image", "type": "image" },
          { "tag": "#body",  "type": "textarea" }
        ]
      }
      // card-b34c… is absent: it did not pass delivery eligibility for this device
    ]
  },
  "fields": [
    { "tag": "#heading", "type": "text" },
    { "tag": "#slides",  "type": "collection",
      "allowedBlockKeys": ["reading_card"], "minItems": 0, "maxItems": 20 }
  ]
}

A card that was inlined is suppressed from the top level

A card delivered inside a wrapper is removed from the top level blocks array, so you never render the same content twice and no client needs to know which blocks it already drew inside a carousel.

The suppression set is computed from what was actually emitted, never from the reference graph, and the difference matters to you:

  • A card whose wrapper was filtered out by targeting, paused, disabled, scheduled out, or omitted because it resolved to zero cards still appears at the top level. Content never silently vanishes because of a wrapper you never received.
  • A card referenced by two delivered wrappers appears in both. That duplication is intended: position and rendering context belong to each wrapper, and the card carries the same instanceId in both places.

Which cards you get, and in what order

RuleBehaviour
OrderThe wrapper's stored authoring order. Survivors keep their relative order after filtering.
priorityNever affects position inside a collection. It decides which instance wins when the platform is selecting among competing instances, and a collection names exact instances, so no selection happens inside it.
TargetingAn intersection. The wrapper is evaluated first; if it is ineligible, nothing under it is resolved. Each card is then evaluated independently against the same device. A card can narrow the wrapper's audience, never widen it.
LifecycleOnly deliverable instances are inlined. draft, review and paused cards are omitted, as are disabled and frozen ones and any card whose on or enabled toggle is false. On a _test key, test status cards are delivered alongside live ones, exactly as at the top level.
Empty wrapperA wrapper whose collections resolve to zero cards is omitted from the response entirely. This is fixed, not configurable, so no client ever draws carousel chrome around nothing.
Broken referenceSkipped, with a server side diagnostic. A missing instance, a missing definition, an instance whose key is not in allowedBlockKeys, or one that has itself acquired a collection are all skipped rather than failing the sync.
! minItems is an authoring bound, not a delivery guaranteeminItems: 3 stops an author saving fewer than three references. It says nothing about how many cards a given device receives after targeting, scheduling and lifecycle filtering. Write your renderer against whatever arrives, including one card, and remember that zero cards means the wrapper does not arrive at all.

Reading a collection in your app

The values map holds whole blocks, so the card loop is the same loop you already write for a top level block. Key it on instanceId.

reading a carousel
const carousel = data.blocks.find(b => b.key === 'reading_carousel')
if (!carousel) return null   // no eligible slides, render nothing

const slides = carousel.values['#slides'] || []

slides.forEach(card => {
  // a card IS a block: same values map, same fields schema
  render({
    key:   card.instanceId,                 // identity, never the index
    title: card.values['#title'],
    image: card.values['#image']
  })
})

Using the JavaScript SDK, block.getCollection('#slides') returns the same cards as parsed CFBlock objects and never throws. The native SDKs expose the same idea: Swift collection(_:) and Kotlin collection(tag).

Analytics need no extra work

A card's events are owned by the card's own instanceId, with the wrapper as a dimension. Send engagement for a card the way you send it for any block, and per card performance is available immediately. The metric owner is never rewritten to the wrapper, so a card used in three carousels reports against itself in all three and can still be broken down by placement.

i The sync ETag moves when a card changesThe /sdk/v1/sync validator is a SHA-256 digest over the whole delivered block array, recursively, including every inlined card's instanceId, version, localized values and field schema, and the post filtering collection order. Editing a card, reordering slides, or a card becoming eligible or ineligible all move the validator even though no wrapper was edited. Keep doing what you already do: store the validator, echo it back on If-None-Match, and treat it as opaque.

Collections

Rules, limits, and the errors you will meet

RuleWhat happens
allowedBlockKeys is requiredA collection field with no non empty allowedBlockKeys array is rejected with 400.
References must be real, same workspace instancesAn id that is not an instance of this workspace, or is an instance of a block key the field does not allow, is rejected on write with 400.
No self reference, no duplicatesAn instance may not reference itself, and the same id may not appear twice in one collection.
50 references per collection, hardThe platform ceiling, applied even when maxItems is omitted or set higher. A stricter maxItems wins.
Collections cannot nestMaximum depth is one, enforced in both directions: a collection may not allow a definition that contains a collection, and a definition already allowed by someone's collection may not gain one. Rejected with 400, message prefixed nested_collection_not_allowed.
A definition change may not strand referencesRemoving a collection field, renaming its tag, changing its id, or dropping a key from allowedBlockKeys while instances still reference cards of that key is rejected with 400 rather than quietly orphaning the references. Clear the collections first.

Deleting or archiving something a collection points at

Referential integrity is enforced, and the refusal names every referrer so it is actionable instead of a bare conflict.

409 Conflict
{
  "success": false,
  "code": "instance_referenced",
  "error": "Instance card-a12f… is referenced by 3 collections.",
  "references": [
    { "wrapperInstanceId": "carousel-7df3…", "fieldTag": "#slides" },
    { "wrapperInstanceId": "carousel-921a…", "fieldTag": "#slides" },
    { "wrapperInstanceId": "home-feed-184b…", "fieldTag": "#cards" }
  ]
}
  • Deleting a referenced instance answers 409 instance_referenced with a references array of { wrapperInstanceId, fieldTag }.
  • Archiving a referenced block definition answers 409 instance_referenced too, with a references array of { wrapperBlockKey, fieldTag }, because archiving it would make every wrapper pointing at its instances resolve to nothing.
  • Pausing and disabling stay available while referenced. They are reversible delivery controls, and ContentFlow already treats a paused card as simply absent, so nothing is orphaned.

Payload size fails at publication, not at delivery

A wrapper inlines every card in full, schema included, so it is the one shape whose payload can grow without an obvious authoring signal. The guard therefore runs when content is published, where a human is present to be told why.

  • A publish that would push a wrapper over the sync payload limit is rejected with 400 and code: "collection_payload_too_large". The limit defaults to 512 KiB per delivered wrapper.
  • Size is measured at its worst case: every stored reference inlined whether or not that card is live right now, and every locale on the wrapper or on any of its cards measured with the largest winning. Uncompressed, matching the bytes the validator is taken over. Gzip and Brotli on the wire are headroom, not budget.
  • Editing or publishing a card, or renaming a card definition, is measured too, because each of those grows every wrapper that inlines it without touching a wrapper document.
  • If one write would force re-verifying more than 200 wrappers or 2000 referenced cards, it is rejected with code: "collection_remeasure_scope_exceeded" rather than skipping the check.
  • There is a runtime backstop: should an oversized wrapper reach ContentFlow's delivery path anyway, trailing cards are dropped until it fits, in authored order, and the truncation is logged at error level. It exists so imports, restores and migrations cannot hand a device an unbounded body. It is not a mode to design against.

Status

Repeater fields were withdrawn, and replaced by collections

! The repeater field type does not existThe repeater type, which nested an array of { id, values } items under a tag with its own fields sub schema, was withdrawn and never shipped. A field that declares an inline fields sub schema (the old repeater shape) is rejected with a 400 telling you to declare a collection instead. Field types are not otherwise validated, so a bare "type": "repeater" is stored but never rendered by the dashboard or the SDKs: declare a collection. Its replacement is documented above: Collections.

The withdrawn design stored a list's items as nested values inside a single block instance. That made every item a value rather than an entity, so an individual card could not carry its own segment targeting, its own schedule, its own approval state, its own A/B variants, or its own analytics. This platform is instance centric and all of that machinery already exists at the instance level, so a list built out of values sat outside it.

A collection is that same list expressed as a wrapper holding an ordered set of real block instances, so every card inherits targeting, scheduling, approval, experiments and per card analytics for free. If you were waiting on the replacement, it is here and it is shipped.

Nothing carries over from the old shape at the wire level. A collection field declares allowedBlockKeys instead of a sub schema, an instance stores instance ids instead of nested item objects, and ContentFlow inlines whole blocks instead of { id, values } items.


Troubleshooting

My registration call returns 401

Wrong credential. POST /cards/register and POST /cards/sync need the workspace write key in X-CF-Write-Key. Your SDK (publishable) key authenticates delivery reads only and will never register a block type. See Which credential to use.

My workspace has no image field anywhere

Expected on a fresh workspace. It is seeded with only welcome_banner and its three text style fields. Register a block type with an image field and the dashboard renders an upload control for it immediately.

The block registered but /sdk/v1/sync returns nothing

Delivery returns live instances only, and the definition alone is not content. Create and publish an instance, or seed one through the manifest and publish it. A definition with zero live instances correctly delivers nothing.

The block was live and then disappeared

Check three things. The instance's enabled toggle, since an instance whose on or enabled toggle is false is not delivered. The instance's segment, since ContentFlow scopes what it returns to the segments the device belongs to. And your last manifest sync, since any active block type absent from a landed manifest is archived.

My image renders as a broken image

Guard on an empty string. An image field with no usable value is delivered as "" on purpose, so your app can render no image instead of a broken one. Also confirm you stored a stable URL rather than a short lived presigned one. See Content Library assets.

How do I build a carousel or a multi card row

Declare a collection. Register a card block type, register a wrapper whose collection field lists that key in allowedBlockKeys, then let a marketer add card instances to the wrapper in the order they want. ContentFlow inlines the eligible cards under the collection's tag as whole blocks. Key your list on each card's instanceId, never on array position.

Placing several independent instances of one block type on a screen still works and is still ordered by ContentFlow, but it gives you no per list ordering and no wrapper level chrome. Reach for a collection when the list is a list.

My carousel disappeared entirely

A wrapper whose collections resolve to zero eligible cards is omitted from the response on purpose, so no client draws chrome around an empty list. Check each card the way you would check any instance: status, the on or enabled toggle, schedule, and the segment it targets. Remember targeting is an intersection, so a card targeted at a segment the wrapper's audience never contains can never appear. See how targeting works for how a trait becomes a segment and how an instance is targeted at one.

I cannot delete or archive a card

Something still references it. The 409 body carries a references array naming every wrapper and field tag, so remove it from those collections first. Pausing the card is available immediately and is reversible: ContentFlow treats a paused card as simply absent.

My publish was rejected with collection_payload_too_large

The wrapper would deliver more than the sync payload limit, measured at its worst case: every stored reference inlined and the largest locale. Remove references or shorten card content. See Rules, limits, and errors.

API reference → Blocks vs instances