Workflows

Validating and publishing

API v115 August 2026·5 min read

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

POST/api/v1/workflows/templates/:id/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
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 '{}'
200 OK · application/json
{
  "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:

KeyWhat it tells you
publishableWhether POST .../publication would accept the workflow right now (the exact same rule, read in advance).
anomaliesStructural 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).
sanitisationA dry run of the content sanitiser, one entry per fragment you pass in the request body (nothing saved).
storedFindings 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
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"
  }'
200 OK · application/json (sanitisation entry)
{
  "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:

FailureStatus
content422
scope422

Anomaly types

Six types block publication; three are advisory and never do.

Blocking typeMeaning
broken_referenceA token, condition, placement, band, repeat_over, or data-loop names a variable or row field that does not exist.
out_of_loop_referenceA qualified collection.field is cited where no row loop puts that collection in scope.
nested_row_loopA data-loop sits inside a node that already repeats, or inside another loop's own row.
collection_comparisonA collection variable's own name is compared with an operator other than present/blank.
contradictionA 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_documentThe tree has no rich_content or image_with_variable node at all: only groups, or nothing. Targets no section.
Advisory typeMeaning
missing_backgroundAn image_with_variable section has no picture set yet.
empty_membershipAn in/not_in rule has an empty values list: decides nothing on its own.
optional_conditioningA condition rests on a variable that is not required, so an author's omission can silently shorten the document.

The payload contract

GET/api/v1/workflows/templates/:id/payload_contract

Read-only, no body. Returns exactly the variables a generation payload for this workflow needs, plus a ready-to-send example.

cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/payload_contract \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "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

POST/api/v1/workflows/templates/:id/publication
cURL
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:

422 Unprocessable Content · application/json
{
  "error": "<message>",
  "anomalies": [
    { "type": "broken_reference", "section_id": 8823, "variable": "investor_name" }
  ]
}

Otherwise:

200 OK · application/json
{ "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.

DELETE/api/v1/workflows/templates/:id/publication
cURL
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. 1POST .../validate: read publishable and anomalies.
  2. 2Fix every blocking anomaly the response names. Advisory ones are yours to act on or leave.
  3. 3POST .../validate again to confirm publishable: true.
  4. 4POST .../publication. A 422 here 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.