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:
| Query | Meaning |
|---|---|
kind | pdf, image, docx or pptx. |
engine | jinja2, liquid or handlebars. |
tag | Templates that carry exactly this tag. The match is exact and case-sensitive: ?tag=billing does not find Billing. |
q | Part of the name or the slug, case-insensitive. |
limit | Page size, 1 to 200, default 50. |
cursor | The 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.
| Field | Type | Meaning |
|---|---|---|
name | string, 1 to 200 characters, required | Shown in the app. |
slug | string, required | The 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. |
kind | pdf, image, docx or pptx, required | What the template produces. docx and pptx are Word and PowerPoint templates, created from a file. |
engine | jinja2, liquid or handlebars, required | The template language. |
description | string up to 2,000 characters, or null | |
tags | array of up to 20 strings of up to 40 characters | Stored as you send them; see tags. |
html, css, head | strings up to 2 MB, 1 MB and 256 KB | The template's body, stylesheet and extra <head> content. html is required for pdf and image. |
settings | object | Paper, margins, header, footer and the other settings. |
sample_data | object | The data the editor previews with and the schema is inferred from. It never fills an API render. |
data_schema | JSON Schema or null | The stored data contract, which validate_data and the schema check hold data to. |
i18n | object or null | Translation dictionaries for the t helper (localisation). |
partials | object, name → source | Fragments the template includes; up to 100, each up to 1 MB, 2 MB together. A name is a relative path (letterhead, blocks/address) without ... |
publish | boolean, default false | Publish 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:
| Field | Meaning |
|---|---|
id, number | The version's own id and its number, counted from 1 per template. |
status | draft, published or archived. Publishing a version archives the one that was published before. |
checksum | A 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, partials | The files, as described under Create a template. |
data_sets | Named sample data sets besides sample_data (name → data), as the editor shows them. |
source_file | Word 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:
| Field | Meaning |
|---|---|
data_sets | Named sample data sets (name → data), up to 50. |
change_note | Up to 500 characters, shown in the version history. |
base_checksum | The checksum of the version you started from; see below. |
publish | true publishes the new version at once. Default false. |
allow_breaking | With 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
fileis the document, up to 20 MB (413 file_too_largeabove that). Its type is read from its content: adocxtemplate takes a Word document, apptxtemplate a PowerPoint presentation, and anything else, macro-enabled files included, answers415 file_type_unsupported. A password-protected file answers422 source_encrypted, a damaged one422 office_document_invalid.- The other fields are form fields. Objects and arrays (
settings,sample_data,data_schema,i18n,data_setsandtags) are JSON text, andpublishandallow_breakingaretrueorfalse. - 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.
| Method | Path | Does |
|---|---|---|
GET | /templates/{id}/channels | Lists 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}/promote | The canary becomes the main version; 400 when there is no canary |
POST | /templates/{id}/channels/{name}/rollback | Back 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:
format | Answer |
|---|---|
json-schema (default) | The JSON Schema itself. |
make | Make 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)