Workflows

Sections & theme

API v115 August 2026·10 min read

The workflow section tree: every section endpoint, backgrounds, and the document's typographic theme.

Every endpoint below is under /api/v1/workflows. Read Workflows first for the object model; read Templates, publication & document for capabilities, the template resource, and publication. This page covers the section tree and the theme; Variables & data covers variables, preview datasets, and content images.

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

Theme

See The object model for what the theme is and why it travels inside each published snapshot. font-weight is deliberately not a writable property here: setting one on a heading would flatten the bold the renderer already gives it, and on a paragraph it would say nothing new.

Get the theme

GET/api/v1/workflows/templates/:id/theme
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/theme \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "theme": {
    "paragraph": { "font_family": "Arial", "font_size": 12, "color": "#1a1a1a", "line_height": 1.4 },
    "h1": { "font_family": "Arial", "font_size": 24, "color": "#000000" }
  },
  "blocks": ["paragraph", "h1", "h2", "h3"],
  "properties": ["font_family", "font_size", "color", "line_height"],
  "fonts": { "faces": [{ "family": "Arial", "weights": [400, 700] }], "substitutes": { "Calibri": "Liberation Sans" } },
  "font_size": { "min": 6, "max": 96, "unit": "pt" },
  "line_height": { "min": 1.0, "max": 2.5, "unit": null }
}

theme only carries blocks with an actual declaration: a block never set is simply absent, not defaulted in the response. Answers even while the edit lock is held.

StatusBodyWhen
200 body abovesuccess
404 {"error": "..."}not a workflow, archived, or another organization's
403 {"error": "..."}see Shared behaviour

Update the theme

PATCH/api/v1/workflows/templates/:id/theme
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/theme \
  --request PATCH \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{
    "theme": {
      "h1": { "font_family": "Arial", "font_size": 24, "color": "#000000" }
    }
  }'
200 OK · application/json
{
  "theme": {
    "paragraph": { "font_family": "Arial", "font_size": 12, "color": "#1a1a1a", "line_height": 1.4 },
    "h1": { "font_family": "Arial", "font_size": 24, "color": "#000000" }
  },
  "blocks": ["paragraph", "h1", "h2", "h3"],
  "properties": ["font_family", "font_size", "color", "line_height"],
  "fonts": { "faces": [{ "family": "Arial", "weights": [400, 700] }], "substitutes": {} },
  "font_size": { "min": 6, "max": 96, "unit": "pt" },
  "line_height": { "min": 1.0, "max": 2.5, "unit": null }
}

Merge by block, not by property

A block your body does not name is left exactly as stored. A block your body does name is written whole: sending h1 alone does not touch the stored paragraph, but it does replace every property h1 already had with only what you sent for h1 this time.

Any value that does not parse is dropped from the stored theme rather than refusing the whole request. The response you get back is always the freshly reloaded, re-normalised value, never an echo of what you sent, so reading the response is the only way to see a drop happen. An unprovisioned font family, a line_height carrying a unit, a font_size outside 6-96pt, a non-hex color, an unknown block or property, and any font_weight are all dropped this way. See Pitfalls for the complete list and what each drop looks like in the response.

StatusBodyWhen
200 body abovesuccess, even if some values were silently dropped
400 {"error": "..."}body missing the theme root key
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Sections

See The object model for what a group versus a content section carries, and why a broken reference survives instead of being swept away. The table below is this page's own reference: the tree is three levels deep at most (group, group, section) and capabilities.limits.tree_depth states it.

Kindchildrencontentplacementsimagerunning_titles
groupyesnononoroot group only
rich_contentnoyesnonono
image_with_variablenonoyesyesno

List sections

GET/api/v1/workflows/templates/:id/sections
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "tree": [
    {
      "id": 300100,
      "kind": "group",
      "title": "Investor identity",
      "condition": null,
      "children": [
        { "id": 300101, "kind": "rich_content", "title": "Identity paragraph", "content": "<p>...</p>" }
      ]
    }
  ]
}

The whole tree, ordered and nested.

StatusBodyWhen
200 {"tree": [...]}success
404 {"error": "..."}not a workflow, archived, or another organization's
403 {"error": "..."}see Shared behaviour

Show a section

GET/api/v1/workflows/templates/:id/sections/:id
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections/300101 \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "id": 300101,
  "kind": "rich_content",
  "title": "Identity paragraph",
  "content": "<p>Dear <variable class=\"editor-text-variable non-editable-content editor-parsed\">investor_name</variable></p>",
  "layout": "inline",
  "page_break": "continue",
  "repeat_over": null,
  "parent_id": 300100,
  "position": 0,
  "condition": null,
  "running_titles": null,
  "placements": null,
  "image_url": null
}

One node only (no tree key here). reference (an export/import-only field) is deliberately never published: nothing reads it, so publishing it would put a field in the contract nobody could say what to do with.

StatusBodyWhen
200 sectionsuccess
404 {"error": "..."}section belongs to another workflow, or the workflow itself is out of scope
403 {"error": "..."}see Shared behaviour

Create a section

POST/api/v1/workflows/templates/:id/sections
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections \
  --request POST \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{
    "section": {
      "kind": "group",
      "title": "Investor identity",
      "position": 0,
      "children": [
        {
          "kind": "rich_content",
          "title": "Identity paragraph",
          "content": "<p>Dear <variable class=\"editor-text-variable non-editable-content editor-parsed\">investor_name</variable></p>"
        }
      ]
    }
  }'
201 Created · application/json
{
  "section": { "id": 300100, "kind": "group", "title": "Investor identity", "children": ["..."] },
  "tree": ["..."]
}

children is walked recursively: a group and its whole subtree can be created in one call, down to the third level, so a caller does not replay N creates and leave half a group behind when one of them fails. The response carries both the created node and the whole tree, so you see the renumbered siblings.

FieldTypeRequiredNotes
kindstringyessee Sections; read section.kinds from capabilities
titlestringyes
contentstringnorich_content only (refused on group/image_with_variable)
conditionobjectno{match: "all"|"any", rules: [...]}
layoutstringnoinline or full_page
page_breakstringnocontinue, new_page, or own_page
repeat_overstringnoname of a collection variable; refused if nested inside another repetition
placementsarraynoimage_with_variable only; each entry accepts id, kind, variable, value, x, y, width, height, align, font_size, color, font_family, font_weight
running_titlesobjectnoroot groups only
parent_idintegernomust name a group in this same workflow, at a legal depth
positionintegernosibling order
childrenarraynorecursive, same shape, up to the third level

kind on a placement entry selects the placement type, not the section kind above it: the current, authoritative list is capabilities.placement.keys_by_kind, e.g. text and checkbox (both carrying variable) and static_text (carrying value). It decides only whether the entry carries variable (bound to a declared variable) or value (a literal string); the positioning/style keys (x, y, width, height, align, font_size, color, font_family, font_weight) apply to every kind, and any of the geometry keys left absent is read as 0. Whether a radio/select variable can be placed is not stated by this reference. Read capabilities.placement.keys_by_kind live rather than assuming a fixed list.

A misspelled placement key is dropped, not refused

Only the exact keys listed above are kept. Anything else answers 201 and is silently absent from the stored section. The label prints as nothing at the position it should have occupied. See Pitfalls for this and every other write that answers 200 while quietly doing less than asked.

Content is sanitised on write against the allow-lists capabilities publishes: an off-list tag or attribute is stripped silently, same as the misspelled placement key above.

StatusBodyWhen
201 body abovesuccess
422 {"errors": ["..."]}blank title; content on a group or image section; placements on anything but image_with_variable; unknown/foreign/wrong-depth parent_id; nested repetition; running_titles on a non-root-group; a numbering start outside 1-100; the organization's section limit reached
400 {"error": "..."}body missing the section root key
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Update a section

PATCH/api/v1/workflows/templates/:id/sections/:id

Same fields as create, minus the structural ones (kind, parent_id, position are move's job). Sending repeat_over: "" is read as "no repetition."

200 OK · application/json
{
  "section": { "id": 300101, "title": "Identity paragraph", "content": "<p>...</p>" },
  "tree": ["..."]
}
StatusBodyWhen
200 body abovesuccess
422 {"errors": ["..."]}same validations as create where relevant
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Move a section

PATCH/api/v1/workflows/templates/:id/sections/:id/move
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections/300101/move \
  --request PATCH \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{ "section": { "parent_id": 300100, "position": 1 } }'
200 OK · application/json
{
  "section": { "id": 300101, "parent_id": 300100, "position": 1 },
  "tree": ["..."]
}

Changes parent and/or rank; siblings on both the old and new parent are renumbered.

FieldTypeRequiredNotes
parent_idintegernomust name a group in this workflow at a legal depth
positionintegernosibling rank
StatusBodyWhen
200 body abovesuccess
422 {"errors": ["..."]}unknown parent_id, or dragging a group that already holds a group into another group
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Duplicate a section

POST/api/v1/workflows/templates/:id/sections/:id/duplicate
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections/300100/duplicate \
  --request POST \
  --header "X-Api-Key: <your-api-key>"
201 Created · application/json
{
  "section": { "id": 300200, "kind": "group", "title": "Investor identity" },
  "tree": ["..."]
}

Deep-copies the node and its whole subtree inside the same template, backgrounds included. The copy is appended to the tree.

StatusBodyWhen
201 body abovesuccess
422 {"errors": ["..."]}the organization's section limit would be exceeded by the copy
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Delete a section

DELETE/api/v1/workflows/templates/:id/sections/:id
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections/300101 \
  --request DELETE \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{ "tree": ["..."] }

Deleting a group takes its whole subtree with it. No "section" key in the response: the node named in the URL no longer exists. Siblings after it are renumbered.

StatusBodyWhen
200 {"tree": [...]}success
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Set a section's background

PATCH/api/v1/workflows/templates/:id/sections/:id/background

Only valid on an image_with_variable section. Two upload shapes: a multipart upload under background[file], or a JSON body with filename, content_type, and data (raw base64 or a full data: URI).

cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/sections/300150/background \
  --request PATCH \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{
    "background": {
      "filename": "cover.png",
      "content_type": "image/png",
      "data": "data:image/png;base64,<...>"
    }
  }'
200 OK · application/json
{
  "section": { "id": 300150, "kind": "image_with_variable", "image_url": "/workflows/images/9f2a1c..." },
  "tree": ["..."]
}

Every upload goes through the same pipeline: re-encoded to WebP, resized to at most 2480px (backgrounds) or 1654px (content images), and checked against the organization's byte ceiling. Backgrounds additionally enforce a minimum width (the organization's workflow_image_min_width, default 800px) since a background is stretched to page width; a content image is placed at whatever size you give it, so it has no floor.

StatusBodyWhen
200 body abovesuccess
422 {"errors": ["..."]}not an image_with_variable section; missing (no file/bytes); too_large (over the organization's byte ceiling); wrong_type (checked against the re-encoded bytes, not the declared content-type); too_narrow (under the minimum width)
400 {"error": "..."}body missing the background root key
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Remove a section's background

DELETE/api/v1/workflows/templates/:id/sections/:id/background
200 OK · application/json
{
  "section": { "id": 300150, "kind": "image_with_variable", "image_url": null },
  "tree": ["..."]
}

This sets the reference to nothing rather than changing anything: the underlying file is left in storage, never deleted, because a published snapshot has to keep rendering the way it looked the day it was published.

StatusBodyWhen
200 body abovesuccess
422 {"errors": ["..."]}not an image_with_variable section
404 {"error": "..."}section or workflow out of scope
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour