Workflows

Templates, publication & document

API v115 August 2026·11 min read

Capabilities, the workflow template resource, validate, the payload contract, publish/withdraw, and whole-document export/import.

Every endpoint below is under /api/v1/workflows. Read Workflows first for the object model and the order these calls are meant to run in. This page and its two siblings are the field-by-field reference: this one covers capabilities, the template resource, publication, and whole-document export/import; Sections & theme covers the section tree, backgrounds, 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.

Shared behaviour

Every call below carries the same X-Api-Key header as the rest of the API (see API basics), plus three rules specific to this namespace:

  • The organization must be enabled for workflows. Otherwise every call (reads included) answers 403 {"error": "<message>"}. This is a per-organization flag turned on by Doclift, not a plan tier a key can unlock itself.
  • Every :id scopes to the calling organization's non-archived workflow templates. A custom or fillable-form template's id, an archived workflow's id, or another organization's workflow id are all indistinguishable from an id that does not exist: 404 {"error": "<message>"}.
  • A write is refused while the dashboard's builder holds the edit lock. It is a three-minute session lock taken by a human editing the same workflow in the browser: 409 {"error": "<message>"}. Reads are never blocked by it; the lock going stale (older than three minutes) lets the write through again without you doing anything.

A request body missing its expected root key (template, section, variable, dataset, image, background, theme, or document, depending on the endpoint) answers 400 {"error": "<message>"} before any of the rules above are even checked: the same pattern as the rest of the API, see Response codes.


Capabilities

Get capabilities

GET/api/v1/workflows/capabilities
cURL
curl https://app.doclift.io/api/v1/workflows/capabilities \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "contract_version": 1,
  "section": {
    "kinds": ["group", "rich_content", "image_with_variable"],
    "carries": {
      "group": { "children": true, "content": false, "placements": false, "image": false, "running_titles": "root only" },
      "rich_content": { "children": false, "content": true, "placements": false, "image": false, "running_titles": false },
      "image_with_variable": { "children": false, "content": false, "placements": true, "image": true, "running_titles": false }
    }
  },
  "condition": {
    "operators": {
      "single_value": ["eq", "not_eq", "contains"],
      "multi_value": ["in", "not_in"],
      "valueless": ["blank", "present"]
    },
    "collection_operators": ["present", "blank"]
  },
  "placement": {
    "keys_by_kind": { "text": ["variable"], "checkbox": ["variable"], "static_text": ["value"] },
    "unknown_keys": "dropped without error: the write answers 200"
  },
  "variable": {
    "field_types": ["text", "checkbox", "radio", "select", "collection"],
    "collection": { "fields": { "keys": ["name", "description", "required", "seed_value"] } }
  },
  "theme": {
    "blocks": ["paragraph", "h1", "h2", "h3"],
    "properties": ["font_family", "font_size", "color", "line_height"],
    "font_size": { "min": 6, "max": 96, "unit": "pt" },
    "line_height": { "min": 1.0, "max": 2.5, "unit": null }
  },
  "page": {
    "size_mm": { "portrait": [210, 297], "landscape": [297, 210] },
    "margin_sides": ["top", "right", "bottom", "left"]
  },
  "content": {
    "allowed_tags": ["..."],
    "allowed_attributes": ["..."],
    "tokens": {
      "text": "<variable class=\"editor-text-variable non-editable-content editor-parsed\">investor_name</variable>"
    }
  },
  "authoring": {
    "fonts": { "faces": ["..."], "substitutes": {} },
    "font_sizes_pt": [8, 10, 12, 14, 16, 18, 24, 36, 48],
    "table": { "layout_row_class": "wf-layout-row" }
  },
  "anomalies": [{ "type": "broken_reference", "blocking": true }],
  "limits": {
    "sections": 200,
    "image_bytes": 10485760,
    "document_length": 800000,
    "tree_depth": 3,
    "numbering_start_max": 100,
    "dataset_name_length": 60
  }
}

No parameters, 200 always once authenticated and gated for the organization. Nothing in this response is a fixed product value except the constant-shaped keys (section.kinds, condition.operators, placement.keys_by_kind, page.size_mm, variable.field_types, theme.blocks/properties, anomalies). limits.sections, limits.image_bytes, and limits.document_length are this organization's own ceilings, and content.allowed_tags/allowed_attributes and authoring.fonts are read from the running application rather than frozen at boot. See Capabilities are authoritative, not a copy.

content.tokens.text is the exact, literal HTML a text-variable token must be written as inside section content. Anything close but not identical (missing a class, wrong tag) is an inert token that survives sanitising and prints its own variable name. See Pitfalls for the full list of writes that silently do less than you asked.


Templates

A workflow is a template resource with category: "workflow", forced server-side: the classic POST /api/v1/templates cannot create one, and none of the classic write endpoints ever touch it (see Writes only reach custom templates). It never carries the legacy content field.

List templates

GET/api/v1/workflows/templates
cURL
curl https://app.doclift.io/api/v1/workflows/templates \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
[
  {
    "id": 100050,
    "title": "Subscription bulletin",
    "description": "Investor onboarding document",
    "published": false,
    "orientation": "portrait",
    "created_at": "2026-01-10 09:00:00 +0100",
    "updated_at": "2026-02-03 11:15:00 +0100"
  }
]

The calling organization's non-archived workflow templates, newest updated first, paginated at 30 per page (same headers as Pagination).

StatusBodyWhen
200 arraysuccess
403 {"error": "..."}missing/invalid API key, or workflows not enabled for this organization

Show a template

GET/api/v1/workflows/templates/:id
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050 \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "id": 100050,
  "title": "Subscription bulletin",
  "description": "Investor onboarding document",
  "published": false,
  "orientation": "portrait",
  "margin_top": 10,
  "margin_right": 10,
  "margin_bottom": 10,
  "margin_left": 10,
  "created_at": "2026-01-10 09:00:00 +0100",
  "updated_at": "2026-02-03 11:15:00 +0100",
  "sections_count": 14,
  "variables_count": 6,
  "being_edited": false
}

being_edited is true while the dashboard's builder holds the edit lock: the one reason a write to this workflow can be refused for a cause the payload itself gives no hint of. Check it before retrying a 409.

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

Create a template

POST/api/v1/workflows/templates
cURL
curl https://app.doclift.io/api/v1/workflows/templates \
  --request POST \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{
    "template": {
      "title": "Subscription bulletin",
      "description": "Investor onboarding document",
      "orientation": "portrait",
      "margin_top": 10,
      "margin_right": 10,
      "margin_bottom": 10,
      "margin_left": 10
    }
  }'
201 Created · application/json
{
  "id": 100050,
  "title": "Subscription bulletin",
  "description": "Investor onboarding document",
  "published": false,
  "orientation": "portrait",
  "margin_top": 10,
  "margin_right": 10,
  "margin_bottom": 10,
  "margin_left": 10,
  "created_at": "2026-01-10 09:00:00 +0100",
  "updated_at": "2026-01-10 09:00:00 +0100",
  "sections_count": 0,
  "variables_count": 0,
  "being_edited": false
}

category is always forced to "workflow" regardless of what you send.

FieldTypeRequiredNotes
titlestringyes
descriptionstringyes
orientationstringnoportrait or landscape, default portrait
margin_topintegernoeach >= 5, default 10
StatusBodyWhen
201 templatesuccess
422 {"errors": ["..."]}blank title/description, or a margin below 5
400 {"error": "..."}body missing the template root key
403 {"error": "..."}see Shared behaviour

Update a template

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

Same fields as create. 200 with the same shape on success; the stored title is left unchanged on a 422.

StatusBodyWhen
200 templatesuccess
422 {"errors": ["..."]}same validations as create
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Delete a template

DELETE/api/v1/workflows/templates/:id
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050 \
  --request DELETE \
  --header "X-Api-Key: <your-api-key>"

204 No Content. This archives the template rather than deleting it. Generations already produced keep pointing at the template that made them.

StatusBodyWhen
204 nonesuccess
404 {"error": "..."}not a workflow, already archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Validate a workflow

POST/api/v1/workflows/templates/:id/validate

A POST that writes nothing: it is not gated on the edit lock, and answers even while the builder holds it. Body is entirely optional: content (array of HTML fragment strings) and scope ("content" or "running_title") run a dry-run of the sanitiser against fragments you have not saved yet. anomalies and stored describe what is already saved on this workflow, independent of any content you pass in. See Validate for the full response shape, the blocking/advisory anomaly-type tables, and the dry-run walkthrough (this is the same endpoint, documented there in full so it is not repeated on every page that mentions it).

FieldTypeRequiredNotes
contentarray of stringsnodry-run fragments; 422 if present but not an array of strings
scopestringnocontent or running_title; 422 if any other value
StatusBodyWhen
200 see Validatealways, once the request itself is well-formed
422 {"errors": ["..."]}content present but not an array of strings, or scope outside its two values
404 {"error": "..."}not a workflow, archived, or another organization's
403 {"error": "..."}see Shared behaviour

Get the payload contract

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

Read-only, no parameters. Returns exactly the variables a generation payload for this workflow needs, plus a ready-to-send example. See The payload contract for the full response shape and how to use example.document_request.

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

Publication

A singleton resource under the template: published is a plain boolean field on the template, but only this route is allowed to flip it for a workflow. See Publish and unpublish for the full request/response walkthrough and the loop that gets you there (validate → fix → validate again → publication).

Publish a workflow

POST/api/v1/workflows/templates/:id/publication

Runs the same integrity check validate reads from. If any blocking anomaly stands, the write is refused and nothing changes; every blocking anomaly is listed, not just the first. Advisory anomalies (missing_background, empty_membership, optional_conditioning) never block this: a workflow with an unset background publishes cleanly.

StatusBodyWhen
200 {"published": true}no blocking anomaly stands
422 {"error": "<message>", "anomalies": [...]}a blocking anomaly stands; nothing is changed
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Withdraw a workflow

DELETE/api/v1/workflows/templates/:id/publication

204 No Content. Never gated on integrity: a workflow whose references broke after it was published is exactly the one that has to be withdrawable without first having to pass a check it may no longer pass.

Publishing a workflow is what makes it visible to GET /api/v1/templates and generatable from POST /api/v1/document_requests; see Template types.

StatusBodyWhen
204 nonesuccess, whether or not it currently passes integrity
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour

Document (whole-workflow export/import)

A singleton resource: the entire workflow (theme, margins, variables, datasets, content images, and the whole section tree, pictures inlined as base64) as one JSON object.

Export the document

GET/api/v1/workflows/templates/:id/document
cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/document \
  --header "X-Api-Key: <your-api-key>"
200 OK · application/json
{
  "document": {
    "format_version": 1,
    "key": "subscription-bulletin",
    "template": {
      "title": "Subscription bulletin",
      "description": "Investor onboarding document",
      "orientation": "portrait",
      "margin_top": 10,
      "margin_right": 10,
      "margin_bottom": 10,
      "margin_left": 10,
      "workflow_theme": { "paragraph": { "font_family": "Arial", "font_size": 12 } }
    },
    "variables": [{ "name": "investor_name", "field_type": "text", "required": true }],
    "datasets": [{ "name": "sample_natural_person", "values": { "investor_name": "Jane Doe" } }],
    "content_images": [{ "token": "a1b2c3", "file": "content-1.png" }],
    "sections": [{ "kind": "group", "title": "Cover page", "children": [] }],
    "pictures": {
      "background-1.png": "<base64>",
      "content-1.png": "<base64>"
    }
  },
  "unresolved_tokens": []
}

unresolved_tokens names any token cited in content that resolves to no stored picture: reported here rather than silently producing a broken image. Answers even while the builder holds the edit lock, since it is a read.

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

Replace the document

PUT/api/v1/workflows/templates/:id/document

A destructive full replace, not a merge

The target's sections, variables, and preview datasets are wiped and rebuilt from the body: anything the document does not carry is gone. If a section fails to save partway through the rebuild, none of the write is applied: the target is left exactly as it was before the PUT, never half-rebuilt.

cURL
curl https://app.doclift.io/api/v1/workflows/templates/100050/document \
  --request PUT \
  --header "X-Api-Key: <your-api-key>" \
  --header "Content-Type: application/json" \
  --data '{
    "document": {
      "format_version": 1,
      "key": "subscription-bulletin",
      "template": { "title": "Subscription bulletin", "description": "..." },
      "variables": [],
      "datasets": [],
      "content_images": [],
      "sections": [],
      "pictures": {}
    }
  }'
200 OK · application/json
{
  "replacement": true,
  "notice": "<message>",
  "template": { "id": 100050, "title": "Subscription bulletin", "published": false },
  "tree": [],
  "datasets": 0,
  "content_images": "<count or list, not pinned by this API's test suite>",
  "backgrounds": "<count or list, not pinned by this API's test suite>",
  "dangling_tokens": [],
  "discarded_images": []
}

The document body is permitted whole, with no field-by-field allow-list: a theme, a condition, and a placement are shaped by whatever the dashboard's builder produces, not a fixed schema, and a permit list here would just be a second copy of that shape to keep in sync. Two behaviours worth knowing before you rely on this:

  • document.template.published is read but never applied. The target keeps its own current published value regardless of what the body says. This closes a door around publication's own integrity check, which this endpoint does not run.
  • Content images cited from content get new stored copies with new tokens on the target; content is rewritten onto the copies rather than pointing back at the source workflow's images.
FieldTypeRequiredNotes
format_versionintegeryesmust be a version this API still supports
keystringyes
templateobjectyesworkflow_theme and the four margins live here
variablesarrayyesreplaces every variable on the target
datasetsarrayyesreplaces every preview dataset on the target
content_imagesarrayyeseach entry's file must have a matching key in pictures
sectionsarrayyesthe whole tree, replacing the target's
picturesobjectyes{filename => base64} map for every picture cited above

The response also reports content_images and backgrounds from the import; whether each is a count or a list is not pinned by this API's test suite, so treat them as informational rather than a typed contract.

StatusBodyWhen
200 body abovesuccess
422 {"errors": ["..."]}unsupported format_version; a content_images entry naming a file absent from pictures; a pictures value that is not valid base64; the body is not a Hash; a required key is missing; the document's category does not match the target's; a section fails validation on rebuild (rolled back)
404 {"error": "..."}not a workflow, archived, or another organization's
409 {"error": "..."}edit lock held
403 {"error": "..."}see Shared behaviour