Quickstart
By the end of this page you will have rendered a PDF from raw HTML, saved that HTML as a template, and rendered the template with your own data. You need a Formfeed account and a terminal.
1. Get a test API key
- Sign in at app.formfeed.dev; a new address creates the account, with an organisation you own and a first workspace. The Free plan needs no card.
- Open API keys → Create key, choose the Test environment and keep the default scopes. Only owners and admins can create keys; as the account's creator you are the owner.
- Copy the key. It starts with
ff_test_and is shown only once.
export FORMFEED_API_KEY=ff_test_...
Test keys cost nothing: renders are not metered and do not count against your quota, outputs expire after 24 hours, and on the Free plan they carry a small test mark. Use them until you go to production. See Authentication and API keys and environments for the difference between test and live keys.
2. Render some HTML
No template needed yet: send HTML and a template language, and Formfeed fills in the data.
- curl
- TypeScript
- Python
curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice {{ number }}</h1><p>Total: {{ total | money: \"EUR\" }}</p>",
"engine": "liquid",
"data": { "number": "2026-0042", "total": 960 }
}'
npm install @formfeed/sdk
import { writeFile } from 'node:fs/promises';
import { Formfeed } from '@formfeed/sdk';
const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const render = await client.renders.create({
html: '<h1>Invoice {{ number }}</h1><p>Total: {{ total | money: "EUR" }}</p>',
engine: 'liquid',
data: { number: '2026-0042', total: 960 },
});
console.log(render.download_url);
await writeFile('invoice.pdf', await client.renders.download(render));
pip install formfeed
import os
from formfeed import Formfeed
client = Formfeed(os.environ["FORMFEED_API_KEY"])
render = client.renders.create(
html='<h1>Invoice {{ number }}</h1><p>Total: {{ total | money: "EUR" }}</p>',
engine="liquid",
data={"number": "2026-0042", "total": 960},
)
print(render.download_url)
with open("invoice.pdf", "wb") as f:
f.write(client.renders.download(render))
The answer is a render object (shortened; every field is described in The render object):
{
"id": "rnd_7f3c9a1e2b4d4c8f9e0a1b2c3d4e5f60",
"status": "succeeded",
"output": "pdf",
"download_url": "https://cdn.formfeed.dev/o/eu/…?exp=1789293600&sig=…",
"expires_at": "2026-09-13T10:00:00.000Z",
"bytes": 18734,
"page_count": 1,
"units": 0,
"region": "eu",
"environment": "test",
"deduplicated": false,
"timings": {
"queue_ms": 0,
"compile_ms": 4,
"render_ms": 612,
"post_ms": 0,
"upload_ms": 38,
"total_ms": 702
},
"error": null,
"created_at": "2026-09-12T10:00:00.000Z",
"completed_at": "2026-09-12T10:00:00.702Z"
}
Open download_url in a browser, which saves the file (it is served as a download), or fetch it with curl -o invoice.pdf "<download_url>". The link is signed and valid for up to seven days, never longer than the output itself is kept: with a test key that is 24 hours, which expires_at shows. GET /v1/renders/{id} hands out a fresh link while the file exists. units is 0 because a test key renders for free; the same render with a live key costs 1 unit.
If something is wrong with the request, the answer is a problem instead, for example 401 unauthorized when the key is missing or mistyped, or 422 template_syntax_error with the line and column when a template tag such as {{ number is not closed.
3. Save it as a template
Sending HTML with every request works, but a stored template gives you versioning, a live preview, autocomplete from your data and a data schema that integrations can read.
Templates → Examples holds the templates of formfeed.dev/examples: invoices, certificates, reports, labels and social images, each with its sample data. Pick one and Use this template puts a copy into the workspace, already published. Its API panel then has the request of step 4 for that copy, with its slug and its sample data.
-
In the app, open Templates → New template. Name it
Invoice; the slug becomesinvoice. Keep kind PDF, choose Liquid as the engine (the code below is Liquid) and click Create template. -
The editor opens. On the rail at the left, Code is selected; replace the content of its Template tab with:
<h1>Invoice {{ number }}</h1><table>{% for line in lines %}<tr><td>{{ line.description }}</td><td>{{ line.qty }} × {{ line.price | money: "EUR" }}</td></tr>{% endfor %}</table><p><strong>Total: {{ lines | sum: "total" | money: "EUR" }}</strong></p>(This is Liquid. The template languages page shows the same thing in Jinja2 and Handlebars. Liquid prints values without escaping HTML; where a value comes from your users, write
{{ line.description | escape }}.) -
Open Data and schema on the rail and paste this sample data. The preview and autocomplete use it; an API render never does, it always uses the data of the request:
{"number": "2026-0042","lines": [{ "description": "Consulting", "qty": 8, "price": 120, "total": 960 }]} -
Press
Ctrl+S(⌘S), add a note if you like and confirm with Save version, then click Publish. By default the API renders the published version.
4. Render the template
Now the request carries only the slug and the data:
- curl
- TypeScript
- Python
curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "invoice",
"data": { "number": "2026-0043", "lines": [{ "description": "Workshop", "qty": 1, "price": 1500, "total": 1500 }] },
"filename": "invoice-2026-0043.pdf"
}'
const data = {
number: '2026-0043',
lines: [{ description: 'Workshop', qty: 1, price: 1500, total: 1500 }],
};
const render = await client.renders.create({
template: 'invoice',
data,
filename: `invoice-${data.number}.pdf`,
});
await writeFile(
`invoice-${data.number}.pdf`,
await client.renders.download(render),
);
data = {"number": "2026-0043", "lines": [{"description": "Workshop", "qty": 1, "price": 1500, "total": 1500}]}
render = client.renders.create(
template="invoice",
data=data,
filename=f"invoice-{data['number']}.pdf",
)
with open(f"invoice-{data['number']}.pdf", "wb") as f:
f.write(client.renders.download(render))
filename is the name the file is stored and downloaded under. It is taken as it is, not filled with the data, so build it in your code; characters other than letters, digits, ., -, _, (, ) and spaces become _, and the extension of the output is added when it is missing.
If the template fails, the API answers 422 with the problem and the line it happened on, instead of a broken PDF: template_syntax_error for a tag that does not parse, template_runtime_error for one that fails with your data. A value missing from your data does not fail the render; it prints as empty. To have the data checked first, give the template a data schema and send "validate_data": true, which answers 422 data_validation_error listing every mismatch before anything is rendered or counted.
Open the template and click API on the editor's rail. The panel shows this request with the template's own slug in eight languages, says which version a call renders, and Send test request runs it and shows the answer. See the editor tour.
Next steps
- Make the template production-ready: paper size, margins, headers and footers with page numbers, fonts and images are in Writing templates.
- Harden the integration: add an
Idempotency-Keyto every render, handle429withRetry-After, and move long documents to async renders with webhooks. The SDKs do the first two for you. - Keep templates in git: after
npx formfeed login,npx formfeed init my-templates --from-workspacepulls your published templates into a folder with a local hot-reload preview. See Templates as code. - Go live: create a key in the Live environment and swap it in. Nothing else changes, except that renders now cost units and their files are kept for your plan's retention instead of 24 hours.
- Take a complete program: the language guides have one per language that reads its data from disk, handles the error answer and saves the PDF, for Node.js, Python, PHP, Go, C#, Java, Ruby and curl.