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.
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.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.
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
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.
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.
| Type | Holds | Delivered as | Typical use |
|---|---|---|---|
text | Single line string | String | Headline, CTA label, badge |
textarea | Multi line string | String | Body copy, description, terms |
image | An asset URL | Plain URL string | Background image, hero, tile art, logo |
number | Numeric value | Number | Minimum salary, rate, price, count |
select | One of a fixed set | The chosen value | Theme, variant, icon name, tier |
toggle | Boolean | Boolean | Show or hide the CTA, enable the block |
collection | An ordered list of references to other block instances | An array of whole delivered blocks, inlined | Carousel slides, a feed, a stacked row of cards |
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.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.Block definition shape
A block definition is a small JSON object. This is the whole contract.
| Property | Meaning |
|---|---|
key | Block type key, unique per workspace. Lowercase letters, digits, and underscores. |
name | Human label shown in the dashboard. |
screen | Where the block appears in your app, for example Home screen. |
crumb | Breadcrumb shown to editors, for example home_screen / hero_banner. |
fields | Array of field descriptors. Each is { id, tag, type, label, default }. |
blank | Optional object of defaults a fresh instance starts from, keyed by field id. |
inCampaigns | Whether this block type can be picked as a campaign channel. |
And a single field:
| Field property | Meaning |
|---|---|
id | Stable internal id for the slot. Instance values are stored against it. |
tag | The public address your app reads, for example #header_image. |
type | One of the seven types above. |
label | What an editor sees next to the input in the dashboard. |
default | Starting value for a new instance. |
allowedBlockKeys | collection only, and required there. The block definition keys this collection may reference. Validated against immutable keys, never display names. |
minItems, maxItems | collection 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.
{
"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 }
}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 fields | Delivered fields | |
|---|---|---|
| Where | Your POST /blocks or POST /cards/sync body | The 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 |
| Purpose | Authoring: names the slot, labels it for editors, seeds a default | Rendering: tells your app how to render each tag |
| Content | Not content. default is a starting value, not a live value. | Not content either. Content lives in values. |
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.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.
| Credential | Header | What it can do |
|---|---|---|
| Portal JWT | Authorization: Bearer plus X-Tenant-Id | Full 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-Key | Cards 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-Key | Reads records back from your server or CI. Admin or editor only. It cannot write anything. See the REST API reference. |
_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-Key | Measured reply | Why |
|---|---|---|
_app | 401 INVALID_SDK_KEY | The _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. |
__app | 404 Tenant not found: _ | One character longer, so _app is stripped and the workspace id _ parses out cleanly. It simply does not exist. |
cf_live_xxx | 404 Tenant not found: cf_live | No known suffix, so the text before the last underscore is taken. |
d5aaa_app_app | 404 Tenant not found: d5aaa_app | One suffix is stripped, not both. |
_, app, nounderscore | 401 INVALID_SDK_KEY | Nothing parses out of any of them. |
The 401 message is verbatim SDK key is not in the expected <tenantId>_app or <tenantId>_test form.
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."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 https://app.contentflow.click/api/v1/settings/write-key \ -H "Authorization: Bearer $CF_PORTAL_JWT" \ -H "X-Tenant-Id: your_tenant_id"
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.
$ 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>" } }
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
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 -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.
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
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
externalIdfor 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/registerbody, plus an optionalinstancesarray carrying content. - The manifest array is
blocks. The aliascardsis accepted for the same array, and inside an entry the aliasitemsis accepted forinstances. - 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 -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.
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.{
"prune": true,
"blocks": []
}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.
/content/<assetId>/raw URL instead, or your banner will render fine on the day it is published and break days later.Which block type exposes #header_image
#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 type | Image field tag | Role in the demo |
|---|---|---|
discovery_card | #header_image | Header image on a home screen discovery card |
benefits_screen | #hero_image | Hero image on a full benefits screen |
promo_banner | #bg_image | Background image on a promotional banner |
insurance_tile | #tile_image | Tile artwork in a grid of products |
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.
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.
{
"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" }
]
}{
"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.
{
"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
i18noverlay is rejected rather than silently applied. Translators still translate each card's own scalar values, on the card.
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.
{
"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
instanceIdin both places.
Which cards you get, and in what order
| Rule | Behaviour |
|---|---|
| Order | The wrapper's stored authoring order. Survivors keep their relative order after filtering. |
priority | Never 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. |
| Targeting | An 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. |
| Lifecycle | Only 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 wrapper | A 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 reference | Skipped, 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: 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.
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.
/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.Rules, limits, and the errors you will meet
| Rule | What happens |
|---|---|
allowedBlockKeys is required | A collection field with no non empty allowedBlockKeys array is rejected with 400. |
| References must be real, same workspace instances | An 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 duplicates | An instance may not reference itself, and the same id may not appear twice in one collection. |
| 50 references per collection, hard | The platform ceiling, applied even when maxItems is omitted or set higher. A stricter maxItems wins. |
| Collections cannot nest | Maximum 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 references | Removing 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.
{
"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_referencedwith areferencesarray of{ wrapperInstanceId, fieldTag }. - Archiving a referenced block definition answers
409 instance_referencedtoo, with areferencesarray 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
400andcode: "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.
Repeater fields were withdrawn, and replaced by collections
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.