Files
Every workspace has a file library: the images a template shows and the PDFs it merges. A file is addressed by its name, not by an id, so a template can write
- Jinja2
- Liquid
- Handlebars
<img src="{{ asset('logo.png') }}" alt="" />
<img src="{{ 'logo.png' | asset }}" alt="" />
<img src="{{asset 'logo.png'}}" alt="" />
and keep working when you upload a new logo.png. asset() returns the file's public CDN URL. Relative
paths resolve against the same library, so <img src="logo.png"> in a template, or in html you send
as a fragment, finds the same file. (A complete HTML document you send, one that starts with
<!doctype or <html>, gets no such base; use the full URL there.)
The library holds images and PDFs only. Word and PowerPoint files belong elsewhere: a Word or PowerPoint template keeps its document with the template version, and a document you convert to PDF is not stored at all.
Upload
curl -X POST https://api.formfeed.dev/v1/files \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-F file=@logo.png
{
"id": "fil_5b0000000000400080000000000000a1",
"name": "logo.png",
"content_type": "image/png",
"bytes": 8421,
"sha256": "3f786850e387550fdab836ed7e6dc881de23001b…",
"url": "https://cdn.formfeed.dev/a/f0000000-0000-4000-8000-000000000001/logo.png",
"created_at": "2026-09-12T10:00:00Z",
"updated_at": "2026-09-12T10:00:00Z"
}
The request is multipart/form-data with two parts:
| Part | Required | Meaning |
|---|---|---|
file | yes | The file. Its content type is taken from the part's type when that is one the library accepts, otherwise from the name's extension. |
name | no | The library name. Without it, the uploaded file's name is used, cleaned up: every character a name may not contain becomes -. A name you send has to be valid as it is (400 invalid_request otherwise). |
A name is up to 200 characters of letters, digits, dot, dash and underscore, with / for folders
(brand/header.svg); each part between slashes starts with a letter or digit. Names are case-sensitive:
Logo.png and logo.png are two files.
The response describes the file: id (fil_…, used to read and delete it), name, content_type,
bytes, the sha256 of the content, the public url, and when it was created and last replaced.
Uploading the same name replaces that file in place and answers 200 instead of 201. The id,
the URL and every reference in every template stay the same, so templates pick up a new logo without a
new version. The new file does not reach every document at once, though; see
Caching.
| Types | PNG, JPEG, WebP, GIF, SVG, AVIF, PDF |
| Size | 10 MB per file (413 file_too_large above that) |
| Number | 1,000 files per workspace, on every plan; one more answers 400 invalid_request with "feature": "files". Replacing a file is always possible. |
| Plans | Every plan |
| Scopes | file:read to list and read, file:write to upload (it includes file:read), file:delete to delete. None of them is a default scope: pick them when you create the key (scopes). |
| In the app | The Files page. Editors upload and replace, owners and admins delete, viewers see the list. |
| Units | Free. Uploads are not metered. |
A file whose bytes do not match its content type is refused with 400 invalid_request: the library is
served from the CDN, so "an HTML page called logo.png" must not get in. An empty file and a type
outside the list are refused the same way.
Organisation fonts are not part of this library. They have their own page in the app, because a font is attached to the organisation and installed into every render (see writing templates).
With the SDKs
import { readFile } from 'node:fs/promises';
import { Formfeed } from '@formfeed/sdk';
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const logo = await formfeed.files.upload({
data: await readFile('logo.png'),
name: 'brand/logo.png',
});
console.log(logo.url);
import os
from formfeed import Formfeed
client = Formfeed(os.environ["FORMFEED_API_KEY"])
with open("logo.png", "rb") as f:
logo = client.files.upload(f.read(), "brand/logo.png")
print(logo.url)
Both SDKs send the name you pass as name, so it has to be a valid library name. They also have
files.list(), files.all() (every page), files.get(id) and files.delete(file).
Use in post-processing
A PDF in the library is a merge source, and a PNG or JPEG is a watermark:
{
"template": "invoice",
"data": { "...": "..." },
"post": {
"merge_after": ["terms.pdf"],
"watermark": { "image": "draft-stamp.png", "opacity": 0.15 }
}
}
Name library files here by their name or by their file id (fil_…); the same fields also take
render ids (rnd_…) of earlier renders. Merges take PDFs only, watermarks PNG and JPEG only;
another type answers 422 source_invalid, a file the library does not
hold 422 source_not_found. See PDF tools.
List, read and delete
curl "https://api.formfeed.dev/v1/files?prefix=brand/&limit=50" \
-H "Authorization: Bearer $FORMFEED_API_KEY"
curl https://api.formfeed.dev/v1/files/fil_5b0000000000400080000000000000a1 \
-H "Authorization: Bearer $FORMFEED_API_KEY"
curl -X DELETE https://api.formfeed.dev/v1/files/fil_5b0000000000400080000000000000a1 \
-H "Authorization: Bearer $FORMFEED_API_KEY"
| Query | Default | Meaning |
|---|---|---|
prefix | none | Only names that start with it, such as a folder: brand/. |
limit | 50 | Page size, 1 to 100 (400 invalid_request outside that). |
cursor | none | The next_cursor of the previous page. |
Listing answers { "data": [ … ], "next_cursor": … }, newest first; next_cursor is null on the
last page. GET /files/{id} reads one file and DELETE /files/{id} deletes it,
both by its fil_… id, not by its name; an unknown id answers 404 not_found.
Deleting answers 204 and removes the bytes from storage at once. Templates that reference the name
render a missing image afterwards, once the caches below have let go of it.
Caching
A file is cached in two places, and both keep it under its URL, which does not change when you replace the file:
- The CDN answers with
Cache-Control: public, max-age=3600. Browsers and the edge keep a file for up to an hour: a replacement reaches every viewer, the editor's preview included, within that hour, and a deleted file can still be served until it expires there. - The render worker keeps the images a document loads (through
asset(), a relative path or an<img>with the library URL) in its own cache for up to a week. A replaced or deleted image can therefore still appear in rendered documents for up to a week.
Watermark images and merge sources are read straight from storage and are always current. When a
change has to show in the very next document, upload it under a new name (logo-2026.png) and change
the template to use it.
What the library is not
Library files are served without a signature: anyone who has the URL can fetch it, and the URL is made of your workspace id and the file's name. Keep customer documents out of the library — rendered outputs are the signed, expiring side of Formfeed.
The CDN serves library files with Content-Security-Policy: sandbox, so an SVG or other file opened
directly runs no script, and with Access-Control-Allow-Origin: *, so any page may load them.