Workflows
Templates, publication & document
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
:idscopes 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
curl https://app.doclift.io/api/v1/workflows/capabilities \
--header "X-Api-Key: <your-api-key>"
{
"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
curl https://app.doclift.io/api/v1/workflows/templates \
--header "X-Api-Key: <your-api-key>"
[
{
"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).
| Status | Body | When |
|---|---|---|
| 200 | array | success |
| 403 | {"error": "..."} | missing/invalid API key, or workflows not enabled for this organization |
Show a template
curl https://app.doclift.io/api/v1/workflows/templates/100050 \
--header "X-Api-Key: <your-api-key>"
{
"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.
| Status | Body | When |
|---|---|---|
| 200 | template | success |
| 404 | {"error": "..."} | not a workflow, archived, or another organization's |
| 403 | {"error": "..."} | see Shared behaviour |
Create a template
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
}
}'
{
"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.
| Field | Type | Required | Notes |
|---|---|---|---|
| title | string | yes | |
| description | string | yes | |
| orientation | string | no | portrait or landscape, default portrait |
| margin_top | integer | no | each >= 5, default 10 |
| Status | Body | When |
|---|---|---|
| 201 | template | success |
| 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
Same fields as create. 200 with the same shape on success; the stored
title is left unchanged on a 422.
| Status | Body | When |
|---|---|---|
| 200 | template | success |
| 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
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.
| Status | Body | When |
|---|---|---|
| 204 | none | success |
| 404 | {"error": "..."} | not a workflow, already archived, or another organization's |
| 409 | {"error": "..."} | edit lock held |
| 403 | {"error": "..."} | see Shared behaviour |
Validate a workflow
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).
| Field | Type | Required | Notes |
|---|---|---|---|
| content | array of strings | no | dry-run fragments; 422 if present but not an array of strings |
| scope | string | no | content or running_title; 422 if any other value |
| Status | Body | When |
|---|---|---|
| 200 | see Validate | always, 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
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.
| Status | Body | When |
|---|---|---|
| 200 | see The payload contract | success |
| 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
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.
| Status | Body | When |
|---|---|---|
| 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
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.
| Status | Body | When |
|---|---|---|
| 204 | none | success, 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
curl https://app.doclift.io/api/v1/workflows/templates/100050/document \
--header "X-Api-Key: <your-api-key>"
{
"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.
| Status | Body | When |
|---|---|---|
| 200 | body above | success |
| 404 | {"error": "..."} | not a workflow, archived, or another organization's |
| 403 | {"error": "..."} | see Shared behaviour |
Replace the 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 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": {}
}
}'
{
"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.publishedis read but never applied. The target keeps its own currentpublishedvalue regardless of what the body says. This closes a door aroundpublication'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.
| Field | Type | Required | Notes |
|---|---|---|---|
| format_version | integer | yes | must be a version this API still supports |
| key | string | yes | |
| template | object | yes | workflow_theme and the four margins live here |
| variables | array | yes | replaces every variable on the target |
| datasets | array | yes | replaces every preview dataset on the target |
| content_images | array | yes | each entry's file must have a matching key in pictures |
| sections | array | yes | the whole tree, replacing the target's |
| pictures | object | yes | {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.
| Status | Body | When |
|---|---|---|
| 200 | body above | success |
| 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 |