Skip to main content

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.

EndpointScope
GET /brandtemplate:read
GET /partialstemplate: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.

FieldTypeMeaning
versionintegerGoes up with every change of the kit. Renders record it (see below).
namestring or nullThe company name, up to 120 characters.
colorsobjectColour 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.bodystring or nullFont family names.
font_sizestring or nullThe base size, in pt or px (10pt).
logo.primary, logo.inverse, logo.markURL or nullThe three logo variants on the CDN.
legal_footerstring or nullUp to 2,000 characters.
page_defaultsobjectpaper (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_attimestamp or nullWhen 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.

FieldTypeMeaning
namestringLowercase letters, digits, - and _.
enginejinja2, liquid or handlebarsOnly templates of this engine find the partial.
descriptionstring or nullUp to 300 characters.
versionintegerGoes up when the source or the engine changes; a new description alone keeps it. Renders record it.
sourcestringThe template source. Only in GET /partials/{name} and in the answer to PUT.
created_at, updated_attimestamp

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
}'
FieldRequiredMeaning
engineyesjinja2, liquid or handlebars.
sourceyesUp to 256 KB (measured in UTF-8 bytes).
descriptionnoUp to 300 characters. Left out, the stored description stays; null removes it.
base_versionnoThe 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 (Letterhead in the path becomes letterhead); 1 to 64 lowercase letters, digits, dashes and underscores, starting and ending with a letter or digit, unique in the organisation. Anything else is 400 invalid_request.
  • Engine: jinja2, liquid or handlebars. 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 html render with an engine, 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 an engine and url renders 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" %} finds letterhead. 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 answers 400 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_exhausted before 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"])