Skip to main content

Templates API

Most teams manage templates in the editor or with the CLI. The templates endpoints are what both of them use, and they are there for you too: to generate templates from your own system, to publish from a pipeline, or to show a template's fields in your UI.

Reading needs the template:read scope (one of a new key's defaults), writing template:write, which you pick when you create the key (scopes). POST /templates/{id}/validate only reads and needs template:read. Templates belong to one workspace: a key sees the templates of its own workspace only.

Naming a template. Wherever a path or a render expects a template, pass its slug (delivery-note), its id (tpl_ followed by 32 hex digits, as the API returns it) or the plain UUID. An unknown or archived template answers 404 template_not_found.

List and read templates​

curl "https://api-eu.formfeed.dev/v1/templates?kind=pdf&tag=billing&limit=50" \
-H "Authorization: Bearer $FORMFEED_API_KEY"

curl https://api-eu.formfeed.dev/v1/templates/delivery-note \
-H "Authorization: Bearer $FORMFEED_API_KEY"
{
"id": "tpl_3e8b0c1a5d2f4e6a9b7c8d0e1f2a3b4c",
"slug": "delivery-note",
"name": "Delivery note",
"description": null,
"kind": "pdf",
"engine": "liquid",
"tags": ["billing"],
"published_version": 3,
"latest_version": 4,
"created_at": "2026-09-12T10:00:00Z",
"updated_at": "2026-09-14T08:30:00Z"
}

published_version is the number of the version renders use by default (null while nothing is published), latest_version the newest version, published or not (0 before the first one). GET /templates answers { "data": [ … ], "next_cursor": … }, most recently changed first, without archived templates:

QueryMeaning
kindpdf, image, docx or pptx.
enginejinja2, liquid or handlebars.
tagTemplates that carry exactly this tag. The match is exact and case-sensitive: ?tag=billing does not find Billing.
qPart of the name or the slug, case-insensitive.
limitPage size, 1 to 200, default 50.
cursorThe next_cursor of the previous page; null on the last page.

Create a template​

curl https://api-eu.formfeed.dev/v1/templates \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Delivery note",
"slug": "delivery-note",
"kind": "pdf",
"engine": "liquid",
"html": "<h1>Delivery note {{ number }}</h1>",
"css": "h1 { font-family: sans-serif; }",
"settings": { "paper": { "format": "A4" } },
"sample_data": { "number": "DN-1001" },
"publish": true
}'

This creates the template and its first version, and answers 201 with the template. With "publish": true the version is published at once and can be rendered right away; otherwise it stays a draft.

FieldTypeMeaning
namestring, 1 to 200 characters, requiredShown in the app.
slugstring, requiredThe name renders and URLs use: 1 to 64 lowercase letters, digits and dashes, starting and ending with a letter or digit. Unique in the workspace, archived templates included; a slug in use answers 409 conflict.
kindpdf, image, docx or pptx, requiredWhat the template produces. docx and pptx are Word and PowerPoint templates, created from a file.
enginejinja2, liquid or handlebars, requiredThe template language.
descriptionstring up to 2,000 characters, or null
tagsarray of up to 20 strings of up to 40 charactersStored as you send them; see tags.
html, css, headstrings up to 2 MB, 1 MB and 256 KBThe template's body, stylesheet and extra <head> content. html is required for pdf and image.
settingsobjectPaper, margins, header, footer and the other settings.
sample_dataobjectThe data the editor previews with and the schema is inferred from. It never fills an API render.
data_schemaJSON Schema or nullThe stored data contract, which validate_data and the schema check hold data to.
i18nobject or nullTranslation dictionaries for the t helper (localisation).
partialsobject, name → sourceFragments the template includes; up to 100, each up to 1 MB, 2 MB together. A name is a relative path (letterhead, blocks/address) without ...
publishboolean, default falsePublish the first version at once.

Where the plan limits the number of templates (five on the Free plan, unlimited on the paid plans), a template beyond the limit answers 403 forbidden with "feature": "templates" and the plan's limit. The limit counts the templates of the whole organisation that are not archived; restoring an archived template counts as creating one. Archive a template or upgrade the plan to make room.

Versions​

Every change is a new version; versions are never edited in place.

# list versions, newest first
curl https://api-eu.formfeed.dev/v1/templates/delivery-note/versions -H "Authorization: Bearer $FORMFEED_API_KEY"

# read one with all its files: "published", "latest", a number or a channel name
curl https://api-eu.formfeed.dev/v1/templates/delivery-note/versions/latest -H "Authorization: Bearer $FORMFEED_API_KEY"

which in the path is published (the version renders use by default), latest (the newest version, published or not; draft means the same), a version number, or the name of a release channel, which stands for the version the channel points at. An unknown version or channel answers 404 (template_not_found or channel_not_found).

A version carries:

FieldMeaning
id, numberThe version's own id and its number, counted from 1 per template.
statusdraft, published or archived. Publishing a version archives the one that was published before.
checksumA hash of what is rendered: html, css, head, settings, partials, i18n and an office template's file. Sample data, data sets and the schema are not part of it.
change_note, created_at, published_at
html, css, head, settings, sample_data, data_schema, i18n, partialsThe files, as described under Create a template.
data_setsNamed sample data sets besides sample_data (name → data), as the editor shows them.
source_fileWord and PowerPoint templates only: sha256, bytes and format of the version's document; null otherwise.

The list leaves out the files and returns the other fields; read a version by its which to get them.

partials are the fragments the template includes (name → source), the ones formfeed templates push collects from the project's partials/ folder. They are rendered with the version, and the checksum covers them, so a changed partial is a new version. See Partials. Shared partials of the organisation are not part of a version: they resolve at render time, after the version's own partials.

Create and publish a version​

curl https://api-eu.formfeed.dev/v1/templates/delivery-note/versions \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Delivery note {{ number }}</h1><p>{{ customer.name }}</p>",
"change_note": "Add customer name",
"base_checksum": "9f2c…",
"publish": false
}'

curl -X POST https://api-eu.formfeed.dev/v1/templates/delivery-note/versions/4/publish \
-H "Authorization: Bearer $FORMFEED_API_KEY"

POST /templates/{id}/versions takes the files of Create a template (html required for pdf and image templates) and these fields, and answers 201 with the new version:

FieldMeaning
data_setsNamed sample data sets (name → data), up to 50.
change_noteUp to 500 characters, shown in the version history.
base_checksumThe checksum of the version you started from; see below.
publishtrue publishes the new version at once. Default false.
allow_breakingWith publish: publish although the schema check finds a breaking change.

Fields you leave out are empty in the new version, not copied from the previous one, so send the complete template. POST …/versions/{number}/publish publishes an existing version and answers 200 with it. Publishing archives the previously published version, and renders pick up the new one within a minute.

The schema check​

Publishing compares the data schema of the new version with the published one. When both versions store a data_schema and the new one would refuse data the published one accepts (a new required field, a narrower type, a removed enum value), the request answers 409 schema_breaking_change with the breaking changes, and nothing is published. Send "allow_breaking": true to publish anyway:

curl -X POST https://api-eu.formfeed.dev/v1/templates/delivery-note/versions/4/publish \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{ "allow_breaking": true }'

The same applies to POST /templates/{id}/versions with "publish": true. The response carries schema_check: its source (stored, inferred or none) and the breaking and safe changes. When either version has no stored schema, schemas inferred from the sample data are compared and the result is only a warning, never a refusal. none means there was nothing to compare, for example on the first publish. What counts as breaking lists every rule.

Avoid overwriting someone else's work​

Send the checksum of the version you started from as base_checksum. If someone created a newer version with different files in the meantime, in the editor or from another machine, the API answers 409 conflict and creates nothing, instead of silently replacing their work. Fetch the latest version, merge, and try again. formfeed templates push does exactly this. Without base_checksum the new version is created whatever happened in between.

The comparison is with the latest version's checksum, so a newer version that changed only sample data, data sets or the schema does not count as a conflict.

Word and PowerPoint templates​

A template of kind docx or pptx is a Word or PowerPoint file with tags in its text (Word and PowerPoint templates explains how to write one). Its versions hold that file instead of html, css, head and partials, so creating one sends the file as multipart/form-data:

curl https://api-eu.formfeed.dev/v1/templates \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-F name="Offer" \
-F slug=offer \
-F kind=docx \
-F engine=jinja2 \
-F 'sample_data={"customer": {"name": "Olvarest GmbH"}, "offer": {"number": "A-1001"}}' \
-F 'settings={"office": {"output": "pdf"}, "locale": "de-DE"}' \
-F file=@offer.docx
  • file is the document, up to 20 MB (413 file_too_large above that). Its type is read from its content: a docx template takes a Word document, a pptx template a PowerPoint presentation, and anything else, macro-enabled files included, answers 415 file_type_unsupported. A password-protected file answers 422 source_encrypted, a damaged one 422 office_document_invalid.
  • The other fields are form fields. Objects and arrays (settings, sample_data, data_schema, i18n, data_sets and tags) are JSON text, and publish and allow_breaking are true or false.
  • An office template cannot be created without its file, and a template of another kind takes none (400 invalid_request). Partials do not apply to office templates.

New versions work the same way: POST /templates/{id}/versions with a new file replaces the document. A version created without a file, as multipart or as plain JSON, keeps the latest version's file, so a change to sample data, schema or settings needs no upload. As for every version, the other fields you leave out are empty in the new version, so send the settings, sample data and schema along:

# a new document, published at once
curl https://api-eu.formfeed.dev/v1/templates/offer/versions \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-F change_note="New layout" -F publish=true -F file=@offer.docx \
-F 'sample_data={"customer": {"name": "Olvarest GmbH"}, "offer": {"number": "A-1001"}}' \
-F 'settings={"office": {"output": "pdf"}, "locale": "de-DE"}'

# Word by default from now on; the file stays
curl https://api-eu.formfeed.dev/v1/templates/offer/versions \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{
"settings": { "office": { "output": "docx" }, "locale": "de-DE" },
"sample_data": { "customer": { "name": "Olvarest GmbH" }, "offer": { "number": "A-1001" } },
"change_note": "Word by default"
}'

A version of an office template carries source_file with the file's sha256, bytes and format; the version's checksum includes the file's hash, so a new document is always a change. The file itself is served only by GET /templates/{id}/versions/{which}/file (scope template:read), with which as for reading a version. It comes as a download named <slug>-v<number>.docx (or .pptx). For a template that is not a Word or PowerPoint template, the route answers 404 not_found. The API never shows where the file is stored, and no public link to it exists.

curl -o offer.docx https://api-eu.formfeed.dev/v1/templates/offer/versions/published/file \
-H "Authorization: Bearer $FORMFEED_API_KEY"

The SDKs send multipart when you pass a file: client.templates.create({ …, kind: 'docx', file: { data, name: 'offer.docx' } }) and client.templates.versions.create(id, { file }) in TypeScript, client.templates.create(…, file=data, file_name="offer.docx") and create_version(id, file=data) in Python. client.templates.versions.file(id, 'published') and client.templates.version_file(id, "published") download the document as bytes.

POST /templates/{id}/validate checks the tags of the stored document, as the app's Tags panel and formfeed validate do, fills it once with the data and checks the data against the stored schema.

Release channels​

A release channel is a named pointer to a version, such as staging; published is one too. A render selects one with "version": "staging". Release channels explains the whole workflow.

MethodPathDoes
GET/templates/{id}/channelsLists published and the named channels with version, canary, previous_version, the renders of the last 24 hours and the plan's limits
PUT/templates/{id}/channels/{name}Creates or moves a channel: { "version": 8, "canary": { "version": 9, "percent": 10 } }; on published this publishes. 201 when created, 200 when moved
POST/templates/{id}/channels/{name}/promoteThe canary becomes the main version; 400 when there is no canary
POST/templates/{id}/channels/{name}/rollbackBack to previous_version; 400 when there is none
DELETE/templates/{id}/channels/{name}Deletes a named channel (published cannot be deleted); ?force=true when renders of the last hour used it, otherwise 409 channel_in_use

Moves, promotions and rollbacks run the schema check against the version the channel points at now, accept allow_breaking, and return schema_check. Channel names are 1 to 32 lowercase letters, digits and dashes, starting with a letter; latest and draft are reserved (422 channel_name_invalid). A move reaches renders within a minute. Named channels need the Starter plan (two per template; ten on Pro and Business) and canaries the Pro plan; beyond that the API answers 403 forbidden with "feature": "channels" or "canary" (plans).

Data schema​

curl https://api-eu.formfeed.dev/v1/templates/delivery-note/schema -H "Authorization: Bearer $FORMFEED_API_KEY"

Returns the JSON Schema (draft 2020-12, Content-Type: application/schema+json) of the data the template expects: the one you stored as data_schema, or the one inferred from the sample data. Use it to validate data in your application before you render, or to build a form. 404 not_found means the version has neither. Without ?version= it is the schema of the published version (else the latest one); ?version=staging, a version number or latest reads another version's, so a staging environment checks against what it renders. formfeed types turns the schemas into TypeScript or Python types; see Typed template data.

?format= chooses the shape of the answer:

formatAnswer
json-schema (default)The JSON Schema itself.
makeMake parameter definitions: one data collection, plus a version select listing the release channels when the template has any besides published. What the Make app loads as dynamic fields. A template with neither schema nor sample data gets a single JSON field instead of a 404.
swagger{ "schema": … } in the OpenAPI 2.0 dialect (one type per property, a label in x-ms-summary), what the Power Platform connector loads. Without schema or sample data it is an object without properties.

Any other value answers 400 invalid_request.

Validate​

curl -X POST https://api-eu.formfeed.dev/v1/templates/delivery-note/validate \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{"version": "latest", "data": {"order": {"number": 1042}}}'

Checks a version with data without rendering, and is free. It reports what the editor and formfeed validate report, plus what only shows on the server: syntax errors and warnings in the body, header and footer, includes without a stored partial, errors that only happen when the template runs with this data, and every place where the data does not match the stored data schema. The version is checked with the organisation's current brand kit and shared partials, as a render would get them. version is published (the default), latest, a number or the name of a release channel; data is an object, and without it the version's sample data is checked.

For a Word or PowerPoint template, the tags of the version's document take the place of the body, header and footer. Its diagnostics name the document part (word/document.xml, ppt/slides/slide2.xml) and the paragraph in it, counted from 1 as the Tags panel lists them. Pictures are not loaded, so a picture that would not load only shows in a render.

{
"ok": false,
"template": {
"id": "tpl_3e8b0c1a5d2f4e6a9b7c8d0e1f2a3b4c",
"slug": "delivery-note",
"version": 4
},
"diagnostics": [
{
"severity": "error",
"code": "data-validation",
"path": "data.order.number",
"message": "data.order.number: must be string"
}
]
}

Each diagnostic has a severity (error, warning or info), a code and a message; template problems add a location (line, column), data problems the path into the data, office templates the part and paragraph. ok is false when at least one diagnostic is an error. A template with problems is still a 200; only an unknown template or version answers 404.

To check the data of a real render instead, send "validate_data": true with it. The render then answers 422 data_validation_error before anything is rendered or counted when the data does not match the template's stored schema, and 400 when the template has none. In a batch, nothing is queued unless every item that asks for it fits.

Update and archive​

curl -X PUT https://api-eu.formfeed.dev/v1/templates/delivery-note \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Delivery note (EU)", "tags": ["billing", "eu"] }'

curl -X DELETE https://api-eu.formfeed.dev/v1/templates/delivery-note \
-H "Authorization: Bearer $FORMFEED_API_KEY"

PUT /templates/{id} changes the name, the description (null removes it) and the tags, and answers 200 with the template; send at least one of them. The API does not change a template's slug, kind or engine. tags replaces the whole list. Tags are stored exactly as you send them, and the list's ?tag= filter matches them exactly; the app writes tags in lowercase with dashes (billing-eu), so use that form when the app and your code should find the same templates.

DELETE /templates/{id} archives the template and answers 204: it disappears from lists, can no longer be rendered or read through the API (404 template_not_found), and its render history stays. Its slug stays taken. Archived templates do not count against the plan's template limit; restore one in the app's templates list with Restore.

With the SDKs​

import { Formfeed } from '@formfeed/sdk';

const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });

const version = await client.templates.versions.get('delivery-note', 'latest');
const next = await client.templates.versions.create('delivery-note', {
...version, // keep every file: css, head, settings, sample data, schema, i18n, partials, data sets
html: (version.html ?? '').replace('Delivery note', 'Packing slip'),
base_checksum: version.checksum,
change_note: 'Rename',
});
await client.templates.versions.publish('delivery-note', next.number);
import os

from formfeed import Formfeed

client = Formfeed(os.environ["FORMFEED_API_KEY"])

version = client.templates.version("delivery-note", "latest")
next_version = client.templates.create_version(
"delivery-note",
html=version.html.replace("Delivery note", "Packing slip"),
css=version.css, head=version.head, settings=version.settings, sample_data=version.sample_data,
data_schema=version.data_schema, i18n=version.i18n, partials=version.partials,
base_checksum=version.checksum, change_note="Rename",
)
client.templates.publish("delivery-note", next_version.number)

In Python, name every file you want to keep, as above; a template with named data sets also needs data_sets=version.model_extra["data_sets"], because the version model has no field of its own for them. Listing works with client.templates.list(…) and client.templates.all(…) in both SDKs (with tag, kind, engine and q), the schema with client.templates.schema(id) and validation with client.templates.validate(id, …).

Publishing takes { allowBreaking: true } in TypeScript and allow_breaking=True in Python to go past the schema check. Channels live under client.templates.channels:

await client.templates.channels.set('delivery-note', 'staging', {
version: next.number,
});
client.templates.channels.set("delivery-note", "staging", version=next_version.number)