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 seven field types: six scalars, 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.


Reference

Field types

Seven types, all first class, all editable in the dashboard and all delivered over GET /sdk/sync. Six are scalars. The seventh, 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
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 the delivery layer 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 delivery 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/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.
  • 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.
nameHuman label shown in the dashboard.
screenWhere the block appears in your app, for example Home screen.
crumbBreadcrumb shown to editors, for example home_screen / hero_banner.
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.

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 seven types above.
labelWhat an editor sees next to the input in the dashboard.
defaultStarting value for a new instance.
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/sync.

Definition fieldsDelivered fields
WhereYour POST /blocks or POST /cards/sync bodyThe GET /sdk/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

This is the single most common integration mistake, so read it before you write the call. ContentFlow has four different credentials and they are not interchangeable.

CredentialHeaderWhat it can do
Portal JWTAuthorization: Bearer plus X-Tenant-IdFull workspace administration, including POST /blocks. Requires the admin or editor role. X-Tenant-Id is required on every portal call.
Write key wk_live_X-CF-Write-KeyCards as code from CI. It opens exactly three routes and nothing else on the platform accepts it: POST /cards/register, which upserts one block definition; POST /cards/sync, which reconciles the workspace against a manifest of many; and POST /strings/sync. Both cards routes write block types. Neither is a content-only endpoint. Issued from Dashboard, Developers, "Your keys" at app.contentflow.click, visible to admin and editor roles only.
SDK key ws_a1b2c3d4_app (live) / ws_a1b2c3d4_test (test)X-CF-Key (or X-API-Key)Read only delivery. Ships in your app. It can call /sdk/*. X-Tenant-Id is optional here, the key already names the workspace. It can never define a block type or a field.
Read key rk_live_X-CF-Read-KeyReads records back from your server or CI. Admin or editor only. It cannot write anything. See the REST API reference.
! A legacy key fails quietly, not loudlyThe gateway resolves a workspace out of the SDK key by stripping a trailing _app, only when the key is longer than 4 characters, or a trailing _test, only when it is longer than 5. Failing both, it takes the text before the last underscore. 401 INVALID_SDK_KEY comes back only when nothing can be parsed out at all, which means the key carries no underscore at any position after its first character. Containing an underscore somewhere is not enough to avoid it. So an old cf_live_xxx key does not return 401: it resolves to a workspace literally named cf_live, which does not exist, and the call returns 404 with {"code":"TENANT_NOT_FOUND","message":"Tenant not found: cf_live"}. If a 404 names a workspace you do not recognize, you are sending an old-format key. Switch to the current ws_a1b2c3d4_app / ws_a1b2c3d4_test shape.

The discriminating cases below were measured against the live gateway on 16 August 2026. The pair worth reading twice is _app against __app: both contain an underscore, and only one of them is a 401.

Sent as X-CF-KeyMeasured replyWhy
_app401 INVALID_SDK_KEYThe _app strip needs a key longer than 4 characters, and this is exactly 4. Nothing is left to fall back on, because the only underscore is the first character.
__app404 Tenant not found: _One character longer, so _app is stripped and the workspace id _ parses out cleanly. It simply does not exist.
cf_live_xxx404 Tenant not found: cf_liveNo known suffix, so the text before the last underscore is taken.
d5aaa_app_app404 Tenant not found: d5aaa_appOne suffix is stripped, not both.
_, app, nounderscore401 INVALID_SDK_KEYNothing parses out of any of them.

The 401 message is verbatim SDK key is not in the expected <tenantId>_app or <tenantId>_test form.

! Do not send the SDK key to a write endpointThe publishable ws_a1b2c3d4_app / ws_a1b2c3d4_test SDK key authenticates /sdk/* delivery reads only. Sending it to POST /blocks or POST /cards/sync fails authentication, and it is the most likely reason a first registration attempt returns 401. Registration is an authorized workspace action performed by an admin or editor, or by CI holding that workspace's write key.
! 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.

Fetch the workspace write key with a portal JWT. Only an admin or editor can read or rotate it, because whoever holds it can create and overwrite this workspace's block definitions from anywhere.

curl
$ curl https://app.contentflow.click/api/v1/settings/write-key \
     -H "Authorization: Bearer $CF_PORTAL_JWT" \
     -H "X-Tenant-Id: your_tenant_id"
! 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. Rotation is an HTTP call and not a screen: there is no rotation control anywhere in the dashboard. Dashboard, Developers, "Your keys" shows the write key with a Copy control and nothing else, so nobody can rotate it by clicking.

Rotate with POST /api/v1/settings/write-key/rotate. It takes a portal JWT plus X-Tenant-Id, requires the admin or editor role, and takes no request body. The response carries the replacement key. See how to obtain a portal JWT in the REST API reference.

write-key/rotate
$ curl -X POST https://app.contentflow.click/api/v1/settings/write-key/rotate \
     -H "Authorization: Bearer $CF_PORTAL_JWT" \
     -H "X-Tenant-Id: your_tenant_id"

// 200 OK, writeKey is wk_live_ followed by 24 hex characters
{ "success": true,
  "data": { "writeKey": "wk_live_<24 hex characters>" } }
! The old write key stops working the instant the new one is issuedThere is no grace period and no overlap window, so anything still holding the old key breaks immediately. Plan the swap: know where the value is stored, rotate, and land the replacement in your CI secret store in the same change. Any build that runs in between fails. The read key behaves the same way at POST /api/v1/settings/read-key/rotate, same credential, same roles, same immediate cutover, and also with no dashboard control; it is documented in the REST API reference.

Path 1 · POST /blocks, register one block type

POST/blocks, register or upsert a single block definition

Portal JWT with the admin or editor role, plus X-Tenant-Id. Upserts by key, so running it twice is safe and the second run updates the definition in place.

curl
$ curl -X POST https://app.contentflow.click/api/v1/blocks \
     -H "Authorization: Bearer $CF_PORTAL_JWT" \
     -H "X-Tenant-Id: your_tenant_id" \
     -H "Content-Type: application/json" \
     -d '{
       "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" },
         { "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 path for a one off registration, a quick experiment, or an admin script. Use the manifest below for anything that runs in CI.

i POST /blocks and POST /cards/register are two doors to the same tableThey are two separate routes writing the same block definitions, not two names for one route, and they differ in three ways. POST /blocks takes a portal JWT with the admin or editor role; POST /cards/register takes the workspace write key. POST /cards/register enforces the key pattern /^[a-z0-9_]+$/, and POST /blocks does not enforce it. POST /cards/register also seeds one blank starter draft instance for the definition by default, and POST /blocks seeds no instance at all. A POST /cards/register body needs key, and optionally takes name, which defaults to the key, plus screen, crumb, fields, blank, inCampaigns and seedInstance.

Path 2 · 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.
  • 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://app.contentflow.click/api/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

From CI, with the write key and the manifest endpoint. @hero_banner.json is the file above.

curl
$ curl -X POST https://app.contentflow.click/api/v1/cards/sync \
     -H "X-CF-Write-Key: $CF_WRITE_KEY" \
     -H "Content-Type: application/json" \
     -d "{\"blocks\":[$(cat hero_banner.json)]}"

// 200 OK
{ "success": true,
  "data": { "active": ["hero_banner"], "created": ["hero_banner"],
            "archived": [], "instancesSeeded": 0, "errors": [] } }

Or, for a one off from an admin session, the same definition through POST /blocks with a portal JWT as shown above.

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 and not the portal JWT. The key alone is enough here: the tenant is resolved from the key's own suffix, so there is no X-Tenant-Id header to set.

curl
$ curl https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-CF-Device: device_x"
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. The delivery layer 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://app.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 /blocks 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, delivery 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/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/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 delivery 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 delivery 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. Sending "type": "repeater" in a block definition is rejected with a 400 telling you to declare a collection instead. 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 delivery inlines whole blocks instead of { id, values } items.


Troubleshooting

My registration call returns 401

Wrong credential. POST /blocks needs a portal JWT from an admin or editor plus X-Tenant-Id. POST /cards/sync needs the workspace write key in X-CF-Write-Key. The publishable SDK key (ws_a1b2c3d4_app / ws_a1b2c3d4_test) 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/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 delivery is scoped 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. Delivery 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 delivery, 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: delivery 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