Workflows
Validating and publishing
Validate a workflow before publishing it: read blocking and advisory anomalies, use the payload contract, and control publication and unpublication.
A workflow only generates documents once it is published. Between the two lies validation: a read-only check that tells you exactly what is wrong, before you ask the API to publish and get refused.
Workflows are a private beta, available on request at [email protected]; see Workflows.
Validate
Reads the current state of the workflow. It never writes anything. Calling it repeatedly, or while another editor holds the builder lock, is always safe.
curl https://app.doclift.io/api/v1/workflows/templates/100050/validate \
--request POST \
--header "X-Api-Key: <your-api-key>" \
--header "Content-Type: application/json" \
--data '{}'
{
"publishable": false,
"anomalies": [
{
"type": "broken_reference",
"blocking": true,
"variable": "investor_name",
"section": { "id": 8823, "title": "Introduction", "kind": "rich_content" }
},
{
"type": "missing_background",
"blocking": false,
"variable": null,
"section": { "id": 8830, "title": "Cover", "kind": "image_with_variable" }
}
],
"sanitisation": [],
"stored": {
"inert_tokens": [],
"unreachable_images": [],
"clipped_bands": [],
"unused_variables": ["country_code"],
"layout_tables": [],
"palette": ["#1a1a1a", "#0057ff"],
"page_breaks": { "declared": [8825], "sections": 6, "content_length": 4210 },
"typography": {
"sizes_pt": [12, 14, 18],
"weights": [400, 700],
"sizes_off_the_editor_scale": [],
"weights_no_family_carries": []
},
"last_render": { "at": "2026-07-30T09:12:00+02:00", "stale": true }
}
}
The body carries three independent readings of the same workflow, all computed against the same stored tree that generation and publication read from:
| Key | What it tells you |
|---|---|
| publishable | Whether POST .../publication would accept the workflow right now (the exact same rule, read in advance). |
| anomalies | Structural problems in the saved tree: broken references, contradictions, an empty document. Each has a type, a blocking flag, the variable it names (or null), and the section it targets ({id, title, kind}, or null for a document-wide anomaly). |
| sanitisation | A dry run of the content sanitiser, one entry per fragment you pass in the request body (nothing saved). |
| stored | Findings about content already saved: inert tokens, unreachable images, clipped running-title bands, unused variables, non-standard layout tables, the palette in use, page-break usage, and typography (sizes and weights actually in use, and which ones no font family carries). |
To dry-run content before saving it, send fragments:
curl https://app.doclift.io/api/v1/workflows/templates/100050/validate \
--request POST \
--header "X-Api-Key: <your-api-key>" \
--header "Content-Type: application/json" \
--data '{
"content": ["<p onclick=\"x()\">Hello <variable>investor_name</variable></p>"],
"scope": "content"
}'
{
"scope": "content",
"given": "<p onclick=\"x()\">Hello <variable>investor_name</variable></p>",
"sanitised": "<p>Hello <variable>investor_name</variable></p>",
"removed_tags": [],
"removed_attributes": ["onclick"],
"unknown_references": [],
"inert_tokens": [],
"unreachable_images": [],
"ejected_from_paragraph": [],
"removed_css_declarations": [],
"clean": false
}
clean is false the moment any of removed_tags, removed_attributes,
inert_tokens, unreachable_images, ejected_from_paragraph, or
removed_css_declarations is non-empty: this is the only place that tells
you a save would lose something, before you save it. See
Failures that answer 200 for what each of these
fields actually catches.
content, if present, must be an array of strings; scope (content or
running_title) picks which sanitiser runs. Either mistake answers 422
before anything else executes:
| Failure | Status |
|---|---|
| content | 422 |
| scope | 422 |
Anomaly types
Six types block publication; three are advisory and never do.
| Blocking type | Meaning |
|---|---|
| broken_reference | A token, condition, placement, band, repeat_over, or data-loop names a variable or row field that does not exist. |
| out_of_loop_reference | A qualified collection.field is cited where no row loop puts that collection in scope. |
| nested_row_loop | A data-loop sits inside a node that already repeats, or inside another loop's own row. |
| collection_comparison | A collection variable's own name is compared with an operator other than present/blank. |
| contradiction | A condition no payload can ever satisfy (e.g. the same variable required to equal two different values at once). Reported once, on the highest ancestor where the contradiction holds. |
| empty_document | The tree has no rich_content or image_with_variable node at all: only groups, or nothing. Targets no section. |
| Advisory type | Meaning |
|---|---|
| missing_background | An image_with_variable section has no picture set yet. |
| empty_membership | An in/not_in rule has an empty values list: decides nothing on its own. |
| optional_conditioning | A condition rests on a variable that is not required, so an author's omission can silently shorten the document. |
The payload contract
Read-only, no body. Returns exactly the variables a generation payload for this workflow needs, plus a ready-to-send example.
curl https://app.doclift.io/api/v1/workflows/templates/100050/payload_contract \
--header "X-Api-Key: <your-api-key>"
{
"variables": [
{
"name": "investor_name",
"description": "Full legal name",
"required": true,
"field_type": "text",
"allowed_values": [],
"seed_value": "Jane Doe",
"fields": null
}
],
"required": ["investor_name"],
"collections": ["investments"],
"limits": { "collection_rows": 200 },
"example": {
"document_request": {
"document_generations": [
{
"template_id": 100050,
"variables": {
"investor_name": "Jane Doe",
"investments": [{ "product": "SCPI", "amount": "10 000 €" }]
},
"tag": ""
}
],
"tag": ""
}
}
}
example is a real, satisfying payload: every declared variable present,
seeded from its seed_value where one is set, each collection rendered as
one flat-row array using its declared field names. Copy it into
POST /api/v1/document_requests and expect it to succeed, without guessing
shapes. See
Workflow templates
for how that endpoint enforces the required-variable contract this example
already satisfies.
Publish and unpublish
curl https://app.doclift.io/api/v1/workflows/templates/100050/publication \
--request POST \
--header "X-Api-Key: <your-api-key>"
Runs the same integrity check as validate, against the same stored tree.
If any blocking anomaly stands, publication is refused and the body lists
every one of them, not just the first:
{
"error": "<message>",
"anomalies": [
{ "type": "broken_reference", "section_id": 8823, "variable": "investor_name" }
]
}
Otherwise:
{ "published": true }
Advisory anomalies never block this call: a workflow with a
missing_background or an empty_membership publishes cleanly. 409 if
the builder's edit lock is held.
curl https://app.doclift.io/api/v1/workflows/templates/100050/publication \
--request DELETE \
--header "X-Api-Key: <your-api-key>"
204 No Content. Unpublication is never gated on integrity. A
workflow whose references broke after it was published can still be
withdrawn, on purpose: that is exactly the one that has to be withdrawable.
Still 409 if the edit lock is held.
A published workflow is what makes it eligible for
document generation:
only a published workflow can be targeted by
POST /api/v1/document_requests.
The loop
- 1
POST .../validate: readpublishableandanomalies. - 2Fix every blocking anomaly the response names. Advisory ones are yours to act on or leave.
- 3
POST .../validateagain to confirmpublishable: true. - 4
POST .../publication. A422here means step 3 missed something. Its body lists exactly what.
Anomalies never block a section or variable write; they only block
publication. You can save a broken reference, keep editing, and only find
out at validate or publication time. See
Failures that answer 200 for the failures that
do not even get that far.