Skip to main content

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

<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:

PartRequiredMeaning
fileyesThe file. Its content type is taken from the part's type when that is one the library accepts, otherwise from the name's extension.
namenoThe 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.

TypesPNG, JPEG, WebP, GIF, SVG, AVIF, PDF
Size10 MB per file (413 file_too_large above that)
Number1,000 files per workspace, on every plan; one more answers 400 invalid_request with "feature": "files". Replacing a file is always possible.
PlansEvery plan
Scopesfile: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 appThe Files page. Editors upload and replace, owners and admins delete, viewers see the list.
UnitsFree. 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"
QueryDefaultMeaning
prefixnoneOnly names that start with it, such as a folder: brand/.
limit50Page size, 1 to 100 (400 invalid_request outside that).
cursornoneThe 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​

caution

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.