Skip to main content

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​

FieldTypeDefaultMeaning
templatestring–A stored template: its slug, tpl_… id or UUID. Send exactly one of template, html and url (sources).
htmlstring–Ad-hoc HTML, at most 5 MB.
urlstring–A public https:// page, at most 2,048 characters.
enginejinja2, liquid, handlebars or nullnullThe template language of html. null prints the HTML as it is.
versionstringpublishedWhich version of template renders: published, a channel name, latest (test keys only) or a version number (which version).
dataobject{}The data the template is filled with.
outputpdf, png, jpg, webp, docx, pptxthe source's own formatThe file format (output format).
settingsobject–Overrides of the template's settings for this request (override settings).
localestringthe template'sShortcut for settings.locale, at most 20 characters, such as de-DE.
validate_databooleanfalseCheck data against the template's stored schema before rendering (check the data).
filenamestringdocument.<format>The name of the output file, at most 200 characters (file name).
accesssigned or publicthe workspace's Output URLs setting, signed unless changedKind of download link (download links).
expires_ininteger (seconds)the retentionDelete the output earlier than the retention says. It can only shorten it.
modesync or asyncsyncWait for the document, or get 202 at once (sync or async).
webhook_urlstring–A public https:// address that also receives this render's events (per-render webhook URLs).
webhook_secretstring, 16–200 characters–Signs the deliveries to webhook_url; without it they are unsigned. Needs webhook_url, and is never returned.
metaobject{}Your own fields, echoed in the render and its webhooks (metadata).
dedupebooleantruefalse renders even when an identical render can be reused (deduplication).
canary_keystring, 1–200 characters–Decides which side of a channel's canary a render lands on (who gets the canary).
postobject–Merge, watermark, password and e-invoice steps on the PDF (post-processing).
storagefalse or { "key": "…" }–The copy in the workspace's own bucket.
regioneu, us or apacthe workspace's regionLeave 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.

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.

Output format​

output is pdf, png, jpg, webp, docx or pptx. Which values a render may ask for depends on its source:

SourceoutputWithout output
Template of kind pdfpdf, png, jpg, webppdf
Template of kind imagepdf, png, jpg, webpits settings.image.format, PNG when it has none
Word template (kind docx)docx, pdfits settings.office.output, else docx
PowerPoint template (kind pptx)pptx, pdfits settings.office.output, else pptx
Ad-hoc html, urlpdf, png, jpg, webppdf

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; its errors list every mismatch with its path and message. Nothing is rendered or charged.
  • A template without a stored schema, and an html or url source, answer 400 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, use POST /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.

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 longer expires_in is cut to the retention. Outputs of test renders are deleted after 24 hours at the latest, whatever the plan.
  • Early deletion: DELETE /renders/{id}/outputs removes the files right away (scope file:delete). The render's metadata stays in the log, with download_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​

StepLimitWhen it is exceeded
Filling the template with the data5 seconds, at most 20 MB of HTML504 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 seconds504 render_timeout; for a url render 422 navigation_failed
Waiting for settings.waitFor.selectorwaitFor.timeoutMs, 5 seconds by default, at most 30504 render_timeout
settings.waitFor.delayMsat most 10 secondsa longer value is cut to 10 seconds
Printing the PDF120 seconds504 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"] }
}
}
StepOptions
merge_after1 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.
watermarktext (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.
passworduser (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.
einvoiceMakes 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 forbidden with feature: "post_processing".
  • einvoice is a step of its own: it runs last, costs 1 unit on live keys, and cannot be combined with password (PDF/A forbids encryption). Live e-invoices need the Starter plan (403 with feature: "einvoice" on Free); test renders make a limited number a day on every plan, always with the test mark (plans and units). With settings.pdf.ua the same file is a PDF/UA-1 document too and the render carries both reports.
  • It works on PDF output only; any other output answers 400 invalid_request. Unknown keys in post are refused with 400, 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"
}
FieldMeaning
idThe render's id, rnd_ and 32 hexadecimal characters
statusqueued, rendering, succeeded or failed
outputThe file format
download_url, expires_atLink to the file and when the link expires; null until the render succeeds and after the file is deleted
page_count, bytesPages (PDF) and file size
unitsUnits charged, in steps of 0.5; 0 for test keys and deduplicated renders
region, environmentWhere it rendered (eu) and whether a live or test key asked
templatenull 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_sha256Reproducibility record
storageThe copy in the workspace's own bucket, with its status, bucket and key; null when there is none
einvoiceThe 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
accessibilityThe 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
timingsMilliseconds 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
errorWhen status is failed: { code, message, … }, the code and message of the problem, not the full problem
metaYour metadata
created_at, completed_atWhen 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" }
}'