Rendering
POST /v1/renders is the endpoint you will call most. It takes one source, some data and options, and returns a render with a download link. It needs the render:create scope. The API reference lists every field; this page explains how they work together.
Request fields at a glance
| Field | Type | Default | Meaning |
|---|---|---|---|
template | string | – | A stored template: its slug, tpl_… id or UUID. Send exactly one of template, html and url (sources). |
html | string | – | Ad-hoc HTML, at most 5 MB. |
url | string | – | A public https:// page, at most 2,048 characters. |
engine | jinja2, liquid, handlebars or null | null | The template language of html. null prints the HTML as it is. |
version | string | published | Which version of template renders: published, a channel name, latest (test keys only) or a version number (which version). |
data | object | {} | The data the template is filled with. |
output | pdf, png, jpg, webp, docx, pptx | the source's own format | The file format (output format). |
settings | object | – | Overrides of the template's settings for this request (override settings). |
locale | string | the template's | Shortcut for settings.locale, at most 20 characters, such as de-DE. |
validate_data | boolean | false | Check data against the template's stored schema before rendering (check the data). |
filename | string | document.<format> | The name of the output file, at most 200 characters (file name). |
access | signed or public | the workspace's Output URLs setting, signed unless changed | Kind of download link (download links). |
expires_in | integer (seconds) | the retention | Delete the output earlier than the retention says. It can only shorten it. |
mode | sync or async | sync | Wait for the document, or get 202 at once (sync or async). |
webhook_url | string | – | A public https:// address that also receives this render's events (per-render webhook URLs). |
webhook_secret | string, 16–200 characters | – | Signs the deliveries to webhook_url; without it they are unsigned. Needs webhook_url, and is never returned. |
meta | object | {} | Your own fields, echoed in the render and its webhooks (metadata). |
dedupe | boolean | true | false renders even when an identical render can be reused (deduplication). |
canary_key | string, 1–200 characters | – | Decides which side of a channel's canary a render lands on (who gets the canary). |
post | object | – | Merge, watermark, password and e-invoice steps on the PDF (post-processing). |
storage | false or { "key": "…" } | – | The copy in the workspace's own bucket. |
region | eu, us or apac | the workspace's region | Leave it out. Only the EU region renders today (data residency). |
Choose a source
Every request has exactly one of template, html or url; none or two answer 400 invalid_request.
- A stored template
- Ad-hoc HTML
- A web page
The normal case: the template's published version plus your data.
{
"template": "invoice",
"data": { "invoice": { "number": "2026-0042", "lines": [ … ] } }
}
template is the slug, the tpl_… id or the UUID. The API renders the published version unless version selects another one; a template without a published version answers 404 template_not_found. The template sees the organisation's brand kit as brand and can include shared partials.
HTML you build yourself, optionally with a template language. Useful for one-off documents and for trying things out.
{
"html": "<h1>Hello {{ name }}</h1>",
"engine": "liquid",
"data": { "name": "Jane Doe" },
"settings": { "paper": { "format": "A5" } }
}
Without engine (or with "engine": null) the HTML is printed as it is. Up to 5 MB. With an engine, all helpers, the brand kit and shared partials work as in a stored template; the organisation's fonts and translation dictionaries belong to stored templates and are not loaded for html, so load fonts with your own @font-face rules. With "tailwind": true in the settings, the Tailwind classes of the HTML are compiled with or without an engine; a complete document (one that begins with a doctype or an html tag) gets the stylesheet at the front of its head, before its own styles.
Prints a public web page as Chromium sees it.
{
"url": "https://reports.fennlor.example/2026-09",
"settings": { "paper": { "format": "A4", "landscape": true } }
}
The page is loaded as an anonymous visitor sees it: the request carries none of your cookies or headers, so a page behind a login renders its login screen. Render such a page from html instead, after fetching it yourself.
- Refused at once with
400 invalid_request: an address longer than 2,048 characters or with a user name or password in it,localhost, private, loopback, link-local and other reserved IP addresses (IPv4 and IPv6), numeric shorthands such as2130706433, cloud metadata hosts, and every name under.internal,.local,.localhostand.fly.dev. - Fails with
422 navigation_failed: anhttp://address (onlyhttps://pages are loaded), a name that does not resolve or resolves to a private address, a page that does not load within 30 seconds, and a page that answers with an HTTP status of 400 or above. - Images, fonts and scripts the page loads from blocked addresses are left out; the page renders without them.
- A
urlrender is never deduplicated: every request loads the page again.
Output format
output is pdf, png, jpg, webp, docx or pptx. Which values a render may ask for depends on its source:
| Source | output | Without output |
|---|---|---|
Template of kind pdf | pdf, png, jpg, webp | pdf |
Template of kind image | pdf, png, jpg, webp | its settings.image.format, PNG when it has none |
Word template (kind docx) | docx, pdf | its settings.office.output, else docx |
PowerPoint template (kind pptx) | pptx, pdf | its settings.office.output, else pptx |
Ad-hoc html, url | pdf, png, jpg, webp | pdf |
Any other combination answers 400 invalid_request with the allowed values in allowed. For images the viewport comes from settings.image (settings reference); a template of kind image already has it. Without settings an image is the 1200 × 630 px viewport at a device scale factor of 2, so the file measures 2400 × 1260 pixels.
{
"template": "social-card",
"data": { "title": "Release 1.4" },
"output": "png"
}
Word and PowerPoint templates render with a live key from the Starter plan on; on the Free plan a live render answers 403 forbidden with feature: "office". On the Free plan, their test renders produce only the PDF, with the test mark: a test render that asks for docx or pptx, or leaves output out so that the template's own format applies, answers 403 test_output_unavailable. Send "output": "pdf" with a test key there.
A PDF longer than the plan's page limit fails with 422 page_limit_exceeded, a file larger than the plan's size limit with 422 output_too_large; see Plan limits. Neither costs anything.
The file instead of the JSON
POST /v1/renders?output=binary answers with the document itself: the bytes, Content-Type and Content-Disposition: attachment with the file name, with the render's id, page count and expiry in X-Render-Id, X-Render-Pages and X-Render-Expires-At, and the usual usage headers. Handy for a shell script or a proxy that passes the file straight on:
curl -X POST "https://api-eu.formfeed.dev/v1/renders?output=binary" \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{ "template": "invoice-de", "data": { "invoice": { "number": "2026-0042" } } }' \
-o invoice.pdf
A render that goes to the background answers 202 with the JSON render, whatever the query says, because there is no file yet. Its Location asks for the file again: GET /renders/{id}/result?output=binary answers 202 until the render has finished, then with the file. A file answer is not stored for idempotent replays.
Which version renders
By default the published version renders. version also takes a version number, and latest renders the newest version, published or not (usually the draft the editor works on), which only a test key may do (a live key gets 400 invalid_request). Use it to try a change before publishing:
{
"template": "invoice-de",
"version": "latest",
"data": { "invoice": { "number": "2026-0042" } }
}
version also takes the name of a release channel such as staging, which live and test keys may render. A channel is a pointer someone deliberately moves to a version, so a staging environment renders what was released to it instead of following every save in the editor. Release channels start with the Starter plan; on the Free plan a channel name answers 403 forbidden with feature: "channels".
{
"template": "invoice-de",
"version": "staging",
"data": { "invoice": { "number": "2026-0042" } }
}
The published version and channels are cached for up to a minute: a newly published version or a moved channel renders within that minute. A version number or latest is never cached. A channel with a canary sends its share of renders to the canary version, chosen by canary_key when the request sends one, else by the Idempotency-Key, else at random (who gets the canary). A channel the template does not have answers 404 channel_not_found, a version number it does not have 404 template_not_found.
Override settings per request
settings is deep-merged over the template's settings, so you send only what differs. null removes a section:
{ "template": "invoice", "data": { … }, "settings": { "paper": { "format": "Letter" }, "footer": null } }
locale is a shortcut for settings.locale: it switches date, number and currency formatting and the translation dictionary for this one document.
{ "template": "invoice", "data": { … }, "locale": "fr-FR" }
settings.pdf.ua makes the file a PDF/UA-1 document: a tagged PDF that veraPDF holds to the machine-checkable rules of PDF/UA-1 before it is delivered. The render then carries the validator's verdict as accessibility.
{ "template": "notice", "data": { … }, "settings": { "pdf": { "ua": true } } }
Check the data first
Formfeed does not check your data unless you ask. With "validate_data": true, data is checked against the template's stored data schema before anything is rendered or counted:
{
"template": "invoice",
"data": { "invoice": { "number": 42 } },
"validate_data": true
}
- Data that does not match answers
422 data_validation_error; itserrorslist every mismatch with itspathandmessage. Nothing is rendered or charged. - A template without a stored schema, and an
htmlorurlsource, answer400 invalid_request: there is nothing to check against. - Without
validate_data, a missing field simply renders empty. To check a template and data without rendering, usePOST /templates/{id}/validate.
File name
filename names the output file. The name appears at the end of the download URL and in the Content-Disposition header, so browsers save the file under that name. A PDF or an office file downloads rather than opening in the tab; an image (PNG, JPG, WebP) opens in the tab, where an og:image or a link a person clicks is shown, and saves under its name. Add the query parameter download=1 to an image's link to download it instead.
{
"template": "invoice",
"data": { "invoice": { "number": "2026-0042" } },
"filename": "invoice-2026-0042.pdf"
}
The name is used as you send it, not as a template: build it in your code. Characters other than letters A–Z and a–z, digits, _ . - ( ) and spaces become _ (so Rechnung März.pdf is saved as Rechnung M_rz.pdf, and invoice-{{ invoice.number }}.pdf as invoice-_ invoice.number _.pdf), the name is cut to 150 characters, and the output's extension is added when it is missing. Without filename the file is document.pdf, document.png and so on.
Download links, access and retention
A succeeded render has a download_url:
- Signed links (
"access": "signed") carry an expiry and a signature. They are valid for at most seven days, and never longer than the file itself is kept.GET /renders/{id}issues a fresh link whenever you need one, so store the render id, not the URL. - Public links (
"access": "public") have no signature and work until the file is deleted. Use them only for documents that are not confidential. - Without
access, the workspace's setting under Settings → Output URLs applies, which is Signed unless an admin changed it. - Retention: the plan sets how long outputs are kept: 1 day on Free, 30 days on Starter, 365 days on Pro, and without a limit on Business. An admin can shorten it for a workspace under Settings → Output retention (days), and
expires_in(seconds) shortens it for one render. Neither lengthens it: a longer period in the workspace's settings counts as the plan's, and a longerexpires_inis cut to the retention. Outputs of test renders are deleted after 24 hours at the latest, whatever the plan. - Early deletion:
DELETE /renders/{id}/outputsremoves the files right away (scopefile:delete). The render's metadata stays in the log, withdownload_url: null.
If you archive documents yourself, keep them in your own storage; do not rely on Formfeed as the archive. Bring your own storage (Pro plan and above) copies every document into your Amazon S3, Cloudflare R2 or S3-compatible bucket as it is rendered; without it, download each file once and store it yourself.
Sync or async
By default the request waits for the document (sync) and answers 200 with the finished render.
Set "mode": "async" to get 202 Accepted immediately with the render in status queued, then learn about the result through a webhook or by polling GET /renders/{id}. Use async for long documents, anything that feeds a queue, and whenever the caller should not wait.
A sync request waits at most a minute. It first needs a free render slot: when all slots are busy it waits up to 30 seconds for one, then goes to the queue. A sync request that has found no slot within 30 seconds, or has no answer after 60 seconds, is not aborted. It turns into an async render: the answer is 202 with X-Render-Async-Fallback: true, a Location header and Retry-After: 5, and the render finishes in the background. Your code should therefore handle a 202 even when it asked for sync: follow Location, or call the SDKs' waitFor (wait_for in Python) with the render's id. See the automatic fallback.
More, including batches of up to 1,000 documents, in Async and batch.
How long a render may take
| Step | Limit | When it is exceeded |
|---|---|---|
| Filling the template with the data | 5 seconds, at most 20 MB of HTML | 504 render_timeout, or 422 output_too_large for the size |
Loading the document in Chromium with everything it loads, until none of its requests is running (waitFor.networkIdle says how) | 30 seconds | 504 render_timeout; for a url render 422 navigation_failed |
Waiting for settings.waitFor.selector | waitFor.timeoutMs, 5 seconds by default, at most 30 | 504 render_timeout |
settings.waitFor.delayMs | at most 10 seconds | a longer value is cut to 10 seconds |
| Printing the PDF | 120 seconds | 504 render_timeout |
These limits are per render, not per request: a sync request that runs longer than 60 seconds in all goes to the background and the render goes on. See Waiting for content for the waitFor settings.
Post-processing
post runs PDF steps on the finished document as part of the same render: append other PDFs, stamp a watermark, add a password, make an e-invoice.
{
"template": "contract",
"data": { … },
"post": {
"merge_after": ["rnd_0f3c9a2e7b1d4e5f8a6c3b2d1e0f9a8b", "terms.pdf"],
"watermark": { "text": "DRAFT", "opacity": 0.15 },
"password": { "user": "open-sesame", "owner": "only-for-us", "permissions": ["print"] }
}
}
| Step | Options |
|---|---|
merge_after | 1 to 49 PDFs appended after this document in order: render ids (rnd_…) of succeeded PDF renders in the same workspace whose file still exists, or PDFs of the file library by name (sources). The appended pages are not metered again, but page_count includes them. |
watermark | text (1–200 characters) or image (a PNG or JPEG of the file library, by name), opacity (0–1, default 0.2), rotation (degrees clockwise, −360 to 360; default -45 for text, from bottom left to top right, and 0 for an image), color (hex such as #cc0000, text only). Text is set in Noto Sans Bold, embedded in the file, which covers Latin, Greek and Cyrillic letters; a text with other characters is refused with 400 invalid_request. An image is scaled to fit 60 % of the page. |
password | user (needed to open, 1–127 characters), owner (needed to lift the restrictions, 1–127 characters; a random one when left out, so nobody can lift them), permissions: what a reader may do without the owner password, from print, copy, modify, annotate. Left out, all four are allowed; [] allows none. Send at least one of the three. AES-256. |
einvoice | Makes the PDF a ZUGFeRD or Factur-X e-invoice, built from the _invoice block of data: profile, flavour, xml, display_check, all optional, or false to switch off what the template declares. |
- The steps run in the order merge, watermark, password, whatever order you write them in.
- Each of merge, watermark and password costs 0.5 units on live keys and nothing on test keys. They need the Starter plan or higher; on Free they answer
403 forbiddenwithfeature: "post_processing". einvoiceis a step of its own: it runs last, costs 1 unit on live keys, and cannot be combined withpassword(PDF/A forbids encryption). Live e-invoices need the Starter plan (403withfeature: "einvoice"on Free); test renders make a limited number a day on every plan, always with the test mark (plans and units). Withsettings.pdf.uathe same file is a PDF/UA-1 document too and the render carries both reports.- It works on PDF output only; any other
outputanswers400 invalid_request. Unknown keys inpostare refused with400, so a typo never silently produces an unprotected PDF. - Post-processed renders are not deduplicated.
To process documents you have already rendered, use the PDF tools endpoints: POST /v1/pdf/merge, /pdf/protect and /pdf/watermark return a new render, and POST /v1/pdf/info returns the page count, page sizes and metadata of a PDF render for free. See PDF tools.
Metadata
meta is a free-form object that Formfeed stores with the render and echoes in the response, in GET /renders/{id} and in every webhook for it. Put your own identifiers there so a webhook handler knows which record the document belongs to:
{ "template": "invoice", "data": { … }, "mode": "async", "meta": { "order_id": "8812", "tenant": "fennlor" } }
Deduplication
A live render is answered from an earlier one instead of rendering again when the same workspace rendered the same thing in the last 24 hours and that file still exists. "The same" means the same template version (or the same html), engine, data, output, settings, locale, region and access, with the same brand kit and shared partial versions. Such an answer costs 0 units, arrives in milliseconds, and is marked with "deduplicated": true and the X-Render-Deduplicated: true header.
The answer is the earlier render: its id, its file and so its file name and expires_at, with the meta of your request and template: null. No new entry appears in the render log. filename, expires_in, meta and mode are not compared, so a request that differs only in those gets the earlier file under its earlier name.
That is usually what you want: a user who clicks "download invoice" twice gets the same file for free. Send "dedupe": false when every request must produce a new file. Never deduplicated are: test renders, url sources, renders with post (e-invoices included), items of a batch, and a render that names its own object in the workspace's bucket (storage.key, see Bring your own storage).
Deduplication is not the same as idempotency: deduplication compares the content of two requests, idempotency the key you send. Use idempotency keys for safe retries either way.
The render object
{
"id": "rnd_0f3c9a2e7b1d4e5f8a6c3b2d1e0f9a8b",
"status": "succeeded",
"output": "pdf",
"download_url": "https://cdn.formfeed.dev/o/eu/…/invoice-2026-0042.pdf?exp=…&sig=…",
"expires_at": "2026-09-18T10:00:01.000Z",
"bytes": 48213,
"page_count": 2,
"units": 1,
"region": "eu",
"environment": "live",
"template": {
"id": "tpl_7c1e4b9a2d5f4c8e9b3a6d0f2e8c4a1b",
"slug": "invoice",
"version": 7,
"channel": "published",
"canary": false
},
"engine_version": "chromium 140.0.7339.16; engine jinja2",
"template_checksum": "9f2c…",
"output_sha256": "b41e…",
"deduplicated": false,
"storage": null,
"einvoice": null,
"accessibility": null,
"timings": {
"queue_ms": 0,
"compile_ms": 9,
"render_ms": 734,
"post_ms": 12,
"upload_ms": 41,
"total_ms": 812
},
"error": null,
"meta": { "order_id": "8812" },
"created_at": "2026-09-11T10:00:00.000Z",
"completed_at": "2026-09-11T10:00:01.000Z"
}
| Field | Meaning |
|---|---|
id | The render's id, rnd_ and 32 hexadecimal characters |
status | queued, rendering, succeeded or failed |
output | The file format |
download_url, expires_at | Link to the file and when the link expires; null until the render succeeds and after the file is deleted |
page_count, bytes | Pages (PDF) and file size |
units | Units charged, in steps of 0.5; 0 for test keys and deduplicated renders |
region, environment | Where it rendered (eu) and whether a live or test key asked |
template | null for html and url sources. id (tpl_…), slug, version (the version that was rendered), channel (the release channel it came from, null for a version number or latest) and canary (whether the channel's canary share picked it) |
engine_version, template_checksum, output_sha256 | Reproducibility record |
storage | The copy in the workspace's own bucket, with its status, bucket and key; null when there is none |
einvoice | The report of an e-invoice: what was made, what the validator found and whether the PDF shows the values of the XML; null when the render made none |
accessibility | The validator's verdict on the file as PDF/UA-1, with the rules that failed and their elements; null unless the render set settings.pdf.ua |
timings | Milliseconds spent waiting in the queue, filling the template (compile_ms), in Chromium (render_ms), on post-processing (post_ms), uploading, and in all; null until it finished |
error | When status is failed: { code, message, … }, the code and message of the problem, not the full problem |
meta | Your metadata |
created_at, completed_at | When the render was created and finished |
The response headers repeat the essentials: X-Units-Charged, X-Units-Remaining (what the organisation has left in the period, prepaid units included), X-Render-Region, X-Render-Engine (the template language, none for plain HTML and URLs, dedup for a deduplicated answer), X-Render-Deduplicated, Server-Timing with the timings, and the RateLimit-* headers described in Limits and usage. Where the answer is the file of a render that set settings.pdf.ua, X-Formfeed-PDF-UA says conformant or not-conformant.
A render that fails while the request waits answers with its problem and the HTTP status of its code (for example 422 template_runtime_error), with the failed render's id in render_id, so you find it in the render log and under GET /renders/{id}.
Reproducibility
Every render records the Chromium build and the template language it used (engine_version, for example chromium 140.0.7339.16; engine jinja2), a checksum of the template version (template_checksum) and a SHA-256 of the output file (output_sha256). The same inputs rendered with the same Chromium build give the same document. The file itself carries the time it was made, so two renders of the same request have different output_sha256 values. Store both checksums next to a contract or an invoice: template_checksum proves later which template version produced it, output_sha256 that this file was not changed.
The render log keeps a fuller record of each render: besides these three it holds a hash of the data and of the settings, the fonts, the brand kit version and the versions of the shared partials the template read, and the region that rendered. The app shows it in the render's details under Reproducibility; the API returns only the three fields above.
A complete request
curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: order-8812-invoice" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice",
"data": { "invoice": { "number": "2026-0042", "lines": [{ "description": "Consulting", "qty": 8, "price": 120 }] } },
"output": "pdf",
"filename": "invoice-2026-0042.pdf",
"locale": "de-DE",
"settings": { "margin": { "top": "30mm" } },
"access": "signed",
"meta": { "order_id": "8812" }
}'