Workflows

Failures that answer 200

API v115 August 2026·8 min read

How to notice and avoid the workflow API failures that never return an error: sanitised content, dropped placement keys, frozen theme overrides, and silently substituted fonts.

Most of this API refuses what it cannot do. A handful of writes instead accept the request, quietly do less than you asked, and answer 200 or 201 as if nothing happened. None of these are bugs (each is a deliberate, documented choice), but every one of them can only be caught by reading a specific field back, never by reading the status code.

Workflows are a private beta, available on request at [email protected]; see Workflows.


Scan for your symptom

SymptomCauseWhere it shows up
Content you wrote is missing after a reloadSanitiser strips it on savevalidate's sanitisation/stored, removed_tags/removed_attributes
A placement's label never appears on the pagePlacement key misspelledNothing (the key is gone from the stored section)
A token prints the variable's own name instead of its valueInert <variable> tokensanitisation/stored's inert_tokens
A picture prints as an empty frameImage cited by external URLsanitisation/stored's unreachable_images
One paragraph becomes two or three in the outputBlock element ejected from a <p>sanitisation's ejected_from_paragraph
A CSS rule you set has no visible effectDeclaration dropped from stylesanitisation/stored's removed_css_declarations
A header or footer is missing its first linesBand clipped at the marginstored's clipped_bands, validate only
Editing the theme stops changing a sectionInline value frozen against the themeNothing (read the section's own content)
PATCHUnparseable value droppedRe-read the theme after writing it
Text renders at an unexpected weightNo provisioned family carries that weightworkflow_selfcheck only (MCP), not a REST field

1. Content is sanitised on save

You write: a rich-content or running-title fragment containing a <script> tag, an onclick attribute, or anything else outside the sanitiser's allow-list.

What happens: POST/PATCH on the section answers 200/201 with the fragment already stripped. The disallowed tag or attribute is gone from what gets stored; nothing in the response says so.

How you notice: dry-run the exact fragment through POST .../validate with content: ["<fragment>"] before saving it. See Validate. The section create/update endpoints themselves give no signal either way.

Avoid it: treat validate's sanitisation.clean as the gate before any write that carries new HTML, not the section endpoint's status code.


2. A misspelled placement key is dropped

You write: a placement object on an image_with_variable section with a key that is not exactly one of id, kind, variable, value, x, y, width, height, align, font_size, color, font_family, font_weight.

What happens: the unknown key is silently dropped. The write still answers 200/201; the label simply never renders at the position it should have occupied.

How you notice: nothing reports this after the fact. The key is gone, not flagged. Compare what you sent against capabilities.placement.keys_by_kind/.keys before you send it.

Avoid it: spell placement keys exactly as the live capabilities endpoint lists them; never hardcode a remembered list.


3. An inert token prints its own name

You write: a <variable> element with the right tag but missing the editor-parsed class (or any class capabilities.content.tokens requires).

What happens: it survives sanitising and passes the integrity check (it is not a broken_reference), but at render time it prints the variable's own name instead of its value.

How you notice: validate's sanitisation/stored report it as inert_tokens. Nothing else in the API flags it.

Avoid it: always write the exact token HTML capabilities.content.tokens gives for the field type, e.g. for text: <variable class="editor-text-variable non-editable-content editor-parsed">field_name</variable>.


4. An external image prints an empty frame

You write: an <img> citing an absolute or external URL instead of one returned by POST .../images or .../background.

What happens: the rendering engine issues no network request while producing the PDF, so the picture prints as nothing: an empty frame, not an error.

How you notice: validate's sanitisation/stored report it as unreachable_images.

Avoid it: upload every picture through the images or background endpoints first, and cite only the token/URL they return.


5. A block element is ejected from a paragraph

You write: a div, table, or another <p> nested inside a <p>.

What happens: the HTML5 parser moves it out, splitting the paragraph into up to three siblings. Nothing is removed, so even a before/after tag count reads clean.

How you notice: validate's sanitisation reports it as ejected_from_paragraph.

Avoid it: never nest a block-level element inside a paragraph.


6. A CSS declaration is dropped from style

You write: a style="..." attribute where one declaration is not on the sanitiser's allowed property list.

What happens: the attribute itself survives (so removed_attributes stays empty), but that one declaration inside it is dropped. The fragment still "reads clean" by the attribute count alone.

How you notice: validate's sanitisation/stored report it separately, as removed_css_declarations.

Avoid it: check removed_css_declarations specifically; do not infer safety from removed_attributes being empty.


7. A running-title band is clipped

You write: a header or footer band whose rendered content is longer than the margin reserved for that side.

What happens: the renderer clips it with no error anywhere. It clips from the opening lines, not the end.

How you notice: only validate's stored.clipped_bands, with an estimated line count. The dry-run sanitisation preview does not check length at all; this one only shows up once the band is actually saved.

Avoid it: keep bands short enough for the page's margins; check stored.clipped_bands after any change to a header/footer or to the page margins themselves.


8. An inline value freezes against the theme

You write: a theme property directly on a section, with a value that already matches what the template's theme currently declares for that block.

What happens: it renders identically today. It stops following the theme from that point on. A later edit to the theme (a new font, a new size) will not reach that section anymore, because it now carries its own, frozen value.

How you notice: nothing reports this: no anomaly, no stored field. It is a modelling discipline, not a checked rule.

Avoid it: set the document's theme first, before writing section content, and only declare a property inline when you deliberately mean to override the theme for that one section.


9. An unparseable theme write is dropped

You write: PATCH .../theme with a value that does not parse under that property's rules: a font family the renderer does not provision, a line_height carrying a unit (e.g. "1.45em"), a font_size in a unit other than pt, a size or line-height outside its bounds, a non-hex color, an unknown block or property, or any font_weight at all (deliberately never a writable property).

What happens: the offending value is dropped (per property, not per request), and the write still answers 200. A known family name that resolves to another is instead silently rewritten (e.g. "Arial" is stored as its metric substitute, "Liberation Sans").

How you notice: the response is always the stored, re-normalised theme read back after the write, never an echo of what you sent. Reading that response is the only way to see a drop happen.

Avoid it: after every PATCH .../theme, compare the response against what you sent, property by property, rather than trusting the 200.

ValueBounds/unit
font_size6-96, unit pt
line_height1.0-2.5, no unit
color3- or 6-digit hex
font_weightnever a writable property, always dropped

A fixed instance, for context

A payload whose conditions excluded every section, or left only sections that rendered no visible content, used to be delivered as a blank document with a successful status. This is closed: both cases are refused today with a 422 (excluded_sections/printing_nothing) at POST /api/v1/document_requests. See Workflow templates. It is listed here only because older integration code, or older documentation, may still assume the blank-page behaviour; nothing about it is a live trap.


10. A font weight no family carries

You write: a weight in content or in the theme that no provisioned font family actually carries.

What happens: no write refuses it. The rendering engine rounds or synthesises the closest weight it has and prints that instead: a thickness nobody asked for.

How you notice: validate does not check this. Only the MCP layer's workflow_selfcheck surfaces it, as weights_no_family_carries, computed from stored.typography against capabilities.authoring.fonts.

Avoid it: pick weights from capabilities.authoring.fonts's declared families, not from what an editor menu happens to offer.