Brand and partials
Two things belong to the organisation rather than to a template, and every template picks them up at render time: the brand kit (logo, colours, fonts, legal footer) and the shared partials (letterheads, footers and other fragments included by name). These endpoints are what formfeed brand pull and formfeed partials use.
Both are organisation-wide: an API key of any workspace of the organisation reads and writes the same kit and the same partials. They are part of every plan.
| Endpoint | Scope |
|---|---|
GET /brand | template:read |
GET /partials | template:read |
GET /partials/{name} | template:read |
PUT /partials/{name} | template:write |
DELETE /partials/{name} | template:write |
template:read is one of a new key's default scopes; template:write has to be picked when you create the key (scopes). A key without the scope answers 403 forbidden.
Read the brand kit
curl https://api.formfeed.dev/v1/brand \
-H "Authorization: Bearer $FORMFEED_API_KEY"
{
"version": 7,
"name": "Fennlor Studio GmbH",
"colors": { "primary": "#0f766e", "accent": "#f59e0b" },
"fonts": { "heading": "Inter", "body": "Source Serif 4" },
"font_size": "10pt",
"logo": {
"primary": "https://cdn.formfeed.dev/a/brand/…/primary-3f9a1c2b7d4e.svg",
"inverse": null,
"mark": null
},
"legal_footer": "Fennlor Studio GmbH · Musterstraße 1 · 12345 Musterstadt · HRB 00000",
"page_defaults": {
"paper": { "format": "A4", "landscape": false },
"margin": {
"top": "25mm",
"right": "18mm",
"bottom": "20mm",
"left": "18mm"
}
},
"updated_at": "2026-09-13T09:12:00Z"
}
Everything except page_defaults and updated_at is exactly the brand object templates see. Unset values are null; colors is always an object, page_defaults {} when nothing is set.
| Field | Type | Meaning |
|---|---|---|
version | integer | Goes up with every change of the kit. Renders record it (see below). |
name | string or null | The company name, up to 120 characters. |
colors | object | Colour tokens (up to 24; lowercase letters, digits and dashes, starting with a letter) to #rgb, #rrggbb or #rrggbbaa. Each is also the CSS variable --brand-color-<token>. |
fonts.heading, fonts.body | string or null | Font family names. |
font_size | string or null | The base size, in pt or px (10pt). |
logo.primary, logo.inverse, logo.mark | URL or null | The three logo variants on the CDN. |
legal_footer | string or null | Up to 2,000 characters. |
page_defaults | object | paper (format: A3 to A6, Letter, Legal or Tabloid; landscape) and margin (top, right, bottom, left). Only the starting point for a new template created in the app; never applied to a render. |
updated_at | timestamp or null | When the kit last changed. |
Logos are stored under a content-addressed name, …/a/brand/<organisation id>/<variant>-<first 12 hex digits of the file's SHA-256>.<extension>: a new logo is a new URL, so no browser, CDN or render cache can keep showing the old one. Like library files, they are served without a signature to anyone who has the URL. Logos are PNG, JPEG, WebP or SVG up to 512 KB.
The brand kit is edited in the app (Brand in the organisation navigation) by owners and admins; editors and viewers see it read-only. The API has no write endpoint for it, and a logo cannot be uploaded with an API key.
List and read shared partials
curl https://api.formfeed.dev/v1/partials \
-H "Authorization: Bearer $FORMFEED_API_KEY"
{
"data": [
{
"name": "letterhead",
"engine": "jinja2",
"description": "Logo and sender line",
"version": 3,
"created_at": "2026-09-13T08:00:00Z",
"updated_at": "2026-09-13T09:30:00Z"
}
]
}
The list holds every shared partial of the organisation, sorted by name, in one answer (an organisation has at most 200, so there is no paging). It leaves out source; GET /partials/letterhead returns one partial with it, or 404 not_found. A partial has no id: its name is its address, and the name in the path is read in lowercase.
| Field | Type | Meaning |
|---|---|---|
name | string | Lowercase letters, digits, - and _. |
engine | jinja2, liquid or handlebars | Only templates of this engine find the partial. |
description | string or null | Up to 300 characters. |
version | integer | Goes up when the source or the engine changes; a new description alone keeps it. Renders record it. |
source | string | The template source. Only in GET /partials/{name} and in the answer to PUT. |
created_at, updated_at | timestamp |
Create or update a partial
curl -X PUT https://api.formfeed.dev/v1/partials/letterhead \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"engine": "jinja2",
"source": "<header><img src=\"{{ brand.logo.primary }}\" height=\"40\"> {{ brand.name }}</header>",
"description": "Logo and sender line",
"base_version": 3
}'
| Field | Required | Meaning |
|---|---|---|
engine | yes | jinja2, liquid or handlebars. |
source | yes | Up to 256 KB (measured in UTF-8 bytes). |
description | no | Up to 300 characters. Left out, the stored description stays; null removes it. |
base_version | no | The version you last read; see conflicts. |
PUT creates the partial (201) or replaces its engine and source, and the description when you send one (200). It returns the partial with its source and its current version. The next render of every template that includes it uses the new source, published templates included; no template version changes.
- Name: stored in lowercase (
Letterheadin the path becomesletterhead); 1 to 64 lowercase letters, digits, dashes and underscores, starting and ending with a letter or digit, unique in the organisation. Anything else is400 invalid_request. - Engine:
jinja2,liquidorhandlebars. Only templates of that engine find the partial. - Source: up to 256 KB. A larger one, or a body that is not valid, answers
400 invalid_request.
Conflicts with base_version
Send the version you last read as base_version. If someone changed the partial in the meantime, the API answers 409 conflict and writes nothing; the problem carries the stored partial as current (null when no partial of that name exists, because it was deleted or never created):
{
"type": "https://docs.formfeed.dev/errors/conflict",
"status": 409,
"code": "conflict",
"detail": "The partial is at version 4, not 3; pull it first",
"current": {
"name": "letterhead",
"engine": "jinja2",
"version": 4,
"…": "…"
}
}
Read the partial again, merge your change and retry with the new version. Leave base_version out to overwrite unconditionally, and to create a new partial. This is the same pattern as base_checksum for template versions.
The 200 partials limit
An organisation holds at most 200 shared partials, on every plan. Creating one more answers 400 invalid_request with "feature": "partials"; delete partials you no longer use first. Updating an existing partial is always possible.
Delete a partial
curl -X DELETE https://api.formfeed.dev/v1/partials/letterhead \
-H "Authorization: Bearer $FORMFEED_API_KEY"
204 on success, 404 not_found when there is no partial of that name. Templates that still include it fail with a template error from their next render on, until the include is removed or a partial of that name exists again. The Partials page in the app shows which templates use a partial before you delete it.
How renders use them
- Which renders. A render of a stored HTML template (PDF or image), or an ad-hoc
htmlrender with anengine, gets the current brand kit and the shared partials it includes. A Word or PowerPoint template gets the brand kit only, since its document cannot include partials. Plain HTML without anengineandurlrenders get neither. - Which partials. Only those the body, the header or the footer include, directly or through other shared partials, travel with the render. An include matches a shared partial of the template's engine case-insensitively and with one file extension ignored (
.html,.htm,.j2,.njk,.jinja,.liquid,.hbs,.handlebars), so{% include "Letterhead.html" %}findsletterhead. A partial stored with the template version under the same name wins over the shared one (how a name is resolved). The shared partials of one render may be 2 MB together; more answers400 invalid_request. - When changes arrive. The gateway caches the kit and the partials for up to 60 seconds. A write through these endpoints clears that cache where it ran, so it usually applies at once; a change made in the app reaches API renders within a minute.
- When they cannot be read. A render does not go ahead without them, because a missing kit would drop the legal footer. If they cannot be loaded, the request fails with
500 worker_exhaustedbefore a render is recorded, and costs nothing; retry it with the same idempotency key. - What is recorded. Every render records the brand kit version (
brand_version) and the versions of the shared partials it used (shared_partials) in its reproducibility record, which the render's details on the Renders page of the app show. Deduplication only reuses a result when both are unchanged.
POST /templates/{id}/validate checks a version with the same brand kit and shared partials a render would get.
With the SDKs
The TypeScript and Python SDKs wrap these endpoints as brand.get() and partials.list(), get, put and delete. put returns the partial and whether it was created, and a conflict raises the SDK's error with the stored partial in its problem:
import { Formfeed, FormfeedError } from '@formfeed/sdk';
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const current = await formfeed.partials.get('letterhead');
try {
const { partial, created } = await formfeed.partials.put('letterhead', {
engine: current.engine,
source: current.source!.replace('height="40"', 'height="48"'),
base_version: current.version,
});
console.log(created ? 'created' : `now version ${partial.version}`);
} catch (e) {
if (e instanceof FormfeedError && e.code === 'conflict')
console.log(e.problem?.['current']);
else throw e;
}
import os
from formfeed import Formfeed, FormfeedError
client = Formfeed(os.environ["FORMFEED_API_KEY"])
current = client.partials.get("letterhead")
try:
result = client.partials.put(
"letterhead",
engine=current.engine,
source=current.source.replace('height="40"', 'height="48"'),
base_version=current.version,
)
print("created" if result.created else f"now version {result.partial.version}")
except FormfeedError as e:
if e.code != "conflict":
raise
print(e.problem["current"])