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.
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.
Block types
Every block picks a design type; the renderer and dashboard badge follow it. What the block holds is its fields, described next.
banner | Full-width strip. |
card | Boxed content unit. |
tile | Compact grid item. |
hero | Large lead unit. |
carousel | Swipeable set. |
list | Stacked rows. |
popup | Modal overlay. |
tooltip / toast | Transient hints. |
panel | Docked container. |
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.
| 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 |
url | A destination | Validated string: https://…, mailto:, tel:, an app deep link (myapp://path) or an /in-app/path. javascript:, data: and other unsafe schemes are rejected on save | CTA target, deep link |
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 ContentFlow 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 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.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. 1 to 100 characters. |
name | Human label shown in the dashboard. 1 to 150 characters. |
screen | Where the block appears in your app, for example Home screen. Up to 150 characters. |
crumb | Breadcrumb shown to editors, for example home_screen / hero_banner. Up to 150 characters. |
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. |
interruptive | Optional boolean. When true the block renders as a modal, popup or interstitial. Absent means not interruptive. |
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 eight types above. The server stores an unknown type string without complaint, but the SDKs will not render it. |
label | What an editor sees next to the input in the dashboard. |
default | Starting value for a new instance. |
options | Optional array of choices for a select field. |
maxLength | Optional positive integer: the character limit for a text field. |
required | Optional boolean: the field must not be empty. |
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/v1/sync.
Definition fields | Delivered fields | |
|---|---|---|
| Where | Your POST /cards/register or POST /cards/sync body | The 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 |
| 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
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.
| Key | Header | What 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-Key | Server 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-Key | Server only. Reads back what a user or device resolved to. It cannot write anything. See the REST guide. |
/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."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.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.Register one block type: POST /cards/register
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 -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
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. - There is no dry-run mode. A
dryRunflag in the body is ignored and the sync is applied, so readarchivedin the response. - 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://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.
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://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.
/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 /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.
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.
{
"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/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.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 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
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 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.
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. 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.