content[], and in the rich content of checklists and banners. (The
resource center has its own block vocabulary — see
Type-specific data.) A block is a
small tagged object:
{ "object": "block", "id": "bk_…", "type": "text", "markdown": "Hello" }
idis the field-merge write handle. Echo a block’sidto update it in place (styling and other unmodeled details are preserved); omitidto create a new block. (object: "block"is returned on read; you don’t send it.)typeselects the block shape below.
text
A rich-text paragraph block.| Field | Type | Required | Description |
|---|---|---|---|
id | string | Field-merge write handle. | |
type | "text" | yes | |
markdown | string | yes | A small markdown subset: paragraphs, # /## headings (h1/h2 only — no h3+), -/* and 1. lists, ``` code fences; inline **bold**, *italic*, [text](url), and {{ attribute_code | default: "x" }} for user attributes. Anything outside this subset is SILENTLY normalized, not rejected: h3+ → h2; blockquotes flatten to paragraphs; tables, horizontal rules, strikethrough, inline images/code, and liquid filters other than default are dropped. Unsupported syntax won’t round-trip — don’t rely on it. |
{ "type": "text", "markdown": "Welcome **{{ first_name | default: \"there\" }}** 👋" }
image
{
"type": "image",
"url": "https://example.com/logo.png",
"alt": "Logo",
"width": { "unit": "percent", "value": 80 },
"link": { "url": "https://example.com", "newTab": true }
}
button
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Field-merge write handle. | |
type | "button" | yes | |
text | string | yes | Button label. |
actions | Action[] | What clicking does (e.g. go to a step, dismiss). | |
disabledWhen | Condition[] | REACTIVE slot — polled live in the browser (the button disables the moment the conditions match). Client-evaluable condition types only: attribute / current_url / element / text_input / text_filled / time_window; event / segment / content_state are rejected (E1017). | |
hiddenWhen | Condition[] | REACTIVE slot — polled live in the browser (the button shows/hides as conditions change). Same client-evaluable-only rule as disabledWhen. | |
variant | primary | secondary | Visual style. | |
margin | Spacing | Outer margin. |
text and at least one action to be publishable.
{
"type": "button",
"text": "Next",
"variant": "primary",
"actions": [{ "type": "goto_step", "step": "pricing" }]
}
embed
An embedded URL (video, iframe-able page, …).| Field | Type | Required | Description |
|---|---|---|---|
id | string | Field-merge write handle. | |
type | "embed" | yes | |
url | string | yes | Paste the page’s normal URL (e.g. a youtube.com/watch link) — on write it is resolved through the standard oEmbed provider registry (YouTube, Vimeo, Loom, Figma, …) and the provider’s official embed markup is stored, so you never hand-build /embed/ URLs. A URL no provider claims is iframed as-is — it renders only if that site allows being framed (no X-Frame-Options/CSP block). |
width | Dimension | Width. | |
height | Dimension | Embed height. Optional ONLY for provider embeds (YouTube/Vimeo/… size themselves by aspect ratio); a URL with NO oEmbed provider has no ratio, and omitting a pixel height leaves the iframe at the browser’s built-in default — a strip ~150px tall, almost never the intended size (validate warns). For plain-iframe URLs always set { "unit": "pixels", "value": … }. | |
margin | Spacing | Outer margin. |
{ "type": "embed", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "width": { "unit": "percent", "value": 100 } }
question
A survey question. Thequestion field is one of the four Question
shapes below; actions run after the user answers.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Field-merge write handle. | |
type | "question" | yes | |
question | Question | yes | The question definition. |
actions | Action[] | Actions that fire when this question is answered (on pick for nps/rating/single-select; on its Submit button for text/multi-select). Put a goto_step HERE to advance to the next step — without it the question records the answer but the flow does NOT advance (validate flags the next step “not reachable”). Do NOT add a separate button block just to advance: it doubles up with the question’s own submit affordance. |
{
"type": "question",
"question": { "kind": "rating", "name": "Satisfaction", "style": "star", "range": { "low": 1, "high": 5 } }
}
Questions
Every question needs aname (used for analytics). bindAttribute (optional)
stores the answer on a user attribute. cvid is server-owned (returned on read).
nps
| Field | Type | Required | Description |
|---|---|---|---|
kind | "nps" | yes | |
name | string | yes | The question’s internal name / analytics label (it is the questionName on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a text block in the SAME step before the question block; a question with only a name renders as bare options with no question text. |
cvid | string | ||
lowLabel | string | ||
highLabel | string | ||
bindAttribute | string | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
rating
| Field | Type | Required | Description |
|---|---|---|---|
kind | "rating" | yes | |
name | string | yes | The question’s internal name / analytics label (it is the questionName on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a text block in the SAME step before the question block; a question with only a name renders as bare options with no question text. |
cvid | string | ||
style | star | scale | yes | star = star rating; scale = a numeric scale. A “scale” question IS a rating with style:“scale” — there is no separate “scale” kind. |
range | object{ low, high } | yes | Numeric range, e.g. { low: 1, high: 5 }. |
lowLabel | string | ||
highLabel | string | ||
bindAttribute | string | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
text
| Field | Type | Required | Description |
|---|---|---|---|
kind | "text" | yes | |
name | string | yes | The question’s internal name / analytics label (it is the questionName on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a text block in the SAME step before the question block; a question with only a name renders as bare options with no question text. |
cvid | string | ||
multiline | boolean | yes | |
placeholder | string | ||
buttonText | string | ||
required | boolean | Require an answer before submit. ONLY text supports this — nps / rating / choice cannot be marked required. | |
bindAttribute | string | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
choice
| Field | Type | Required | Description |
|---|---|---|---|
kind | "choice" | yes | |
name | string | yes | The question’s internal name / analytics label (it is the questionName on captured responses). It is NOT rendered to the user — the widget shows only the input (scale / options / text field), not this string. To show a visible question prompt, add a text block in the SAME step before the question block; a question with only a name renders as bare options with no question text. |
cvid | string | ||
options | object{ label, value }[] | yes | Each option has a human-facing label and a stored value — the value is what gets recorded/bound as the answer. |
allowMultiple | boolean | yes | false = single-select, true = multi-select. A multi-select answer needs a list-typed bound attribute. |
enableOther | boolean | ||
otherPlaceholder | string | ||
shuffle | boolean | ||
buttonText | string | ||
bindAttribute | string | Optional: codeName of an EXISTING attribute (create the attribute definition first) to ALSO save this answer onto the user for targeting/segmentation — use the codeName, NOT the id. The write does not check it, but the version validation WARNS when the attribute is missing or its dataType mismatches the answer — read warnings. A wrong code that slips through silently captures nothing at runtime. Match the attribute dataType to the answer: number (nps / rating), string (single-select choice), list (multi-select choice). Leaving it unset still records the answer as a response event — bind only when you need to target/segment on it. |
columns
A row of side-by-side columns, each holding its own blocks — the only nesting in the block model.| Field | Type | Required | Description |
|---|---|---|---|
id | string | Field-merge write handle. | |
type | "columns" | yes | |
columns | Column[] | yes | One entry per column, laid out left-to-right. Each column is a mini vertical stack of blocks (usually one). |
{
"type": "columns",
"columns": [
{ "width": { "unit": "percent", "value": 50 }, "blocks": [{ "type": "text", "markdown": "Left" }] },
{ "blocks": [{ "type": "image", "url": "https://example.com/x.png" }] }
]
}
Shared shapes
Small objects reused across blocks. Dimension — a width/height.| Field | Type | Required | Description |
|---|---|---|---|
unit | percent | pixels | fill | yes | fill ignores value, and is a column-only unit — an image/embed width or height with fill is rejected. |
value | number | ≥ 0. For percent, values over 100 are allowed (overflow). |
| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Whether spacing is applied. | |
top / bottom / left / right | number | Per-side pixels (may be negative). |
columns block.
| Field | Type | Required | Description |
|---|---|---|---|
width | Dimension | Column width. | |
justify | start | center | end | between | around | evenly | Horizontal distribution. | |
align | start | center | end | baseline | Vertical alignment. | |
padding | Spacing | Inner padding. | |
blocks | Block[] | yes | The column’s blocks. |