Python SDK
formfeed is the Python client for the REST API, built on httpx with pydantic models, for Python 3.10 and later. It behaves exactly like the TypeScript SDK: 429, 503 and network errors are retried, every render carries an Idempotency-Key, and API problems raise FormfeedError. Use it on a server: the API refuses an API key sent from a web page.
pip install formfeed
Set up a client
import os
from formfeed import Formfeed
client = Formfeed(os.environ["FORMFEED_API_KEY"])
| Argument | Default | Notes |
|---|---|---|
api_key | required | ff_live_… or ff_test_…; the first argument, the others are keyword arguments |
region | "eu" | "eu" (https://api-eu.formfeed.dev/v1) or "us" (https://api-us.formfeed.dev/v1) |
base_url | region host | Full URL including /v1, used instead of the region's host. You need it only for an address other than the public API, such as one our support gives you. Wins over region. |
max_retries | 3 | Retries on 429, 503 and network errors; 0 turns retrying off. |
timeout | 120.0 | Seconds; httpx's timeout for connecting, sending and reading each request. A request that times out counts as a network error and is retried. |
transport | none | An httpx transport, for tests (httpx.MockTransport) or proxies |
Use the client as a context manager (with Formfeed(key) as client:) or call client.close() when you are done, to release the connection pool; for AsyncFormfeed, async with or await client.aclose().
Retries and idempotency
The rule is the one of the TypeScript SDK: 429, 503 and network errors are sent again up to max_retries times, after the seconds of Retry-After, else 0.5 s, 1 s, 2 s and so on plus up to 0.25 s of jitter, never more than 30 s. renders.create, renders.batch and the PDF tools merge, protect, watermark and convert send an Idempotency-Key, a random one unless you pass idempotency_key=; its retries reuse it, so the API renders and charges once. To retry safely across processes, pass a key of your own, such as order-8812-invoice. See Idempotency.
Render
invoice = {"number": "2026-0042", "lines": [{"description": "Consulting", "qty": 8, "price": 120}]}
render = client.renders.create(
template="invoice",
data={"invoice": invoice},
filename=f"invoice-{invoice['number']}.pdf",
idempotency_key="order-8812-invoice",
)
# a sync render still running after 60 seconds comes back queued; wait for it
done = render if render.status == "succeeded" else client.renders.wait_for(render.id)
print(done.download_url, done.page_count, done.units)
with open("invoice.pdf", "wb") as f:
f.write(client.renders.download(done))
Keyword arguments map one-to-one to the body of POST /renders: template, or html with engine, or url; version; data; output (pdf, png, jpg, webp, and docx or pptx for Word and PowerPoint templates; left out, the template decides); settings; locale; filename (the name the download gets, taken as it is: characters other than the letters A–Z, digits, _, ., -, (, ) and spaces become _, and the extension is added when it is missing); expires_in; access; mode; webhook_url and webhook_secret; meta; region; dedupe; canary_key; validate_data; post; storage. The API reference describes each field; idempotency_key is the SDK's own. version selects published (the default), a version number, latest (test keys only) or a release channel such as staging.
A failed render raises FormfeedError. A sync render that has no answer after 60 seconds is not an error: it comes back with the status queued or rendering and finishes in the background (the automatic fallback), so check status before you download. client.renders.get(render_id) fetches a render again, with a fresh download link; client.renders.download(render) returns the file's bytes and raises not_ready while the render has no output.
Typed data
formfeed types --lang python generates formfeed_templates.py with a TypedDict per template and a wrapper with one method per template, named after the slug with dashes as underscores (delivery-note becomes delivery_note); see Typed template data:
from formfeed_templates import InvoiceData, typed, typed_async
data: InvoiceData = {"number": "RE-2026-001", "status": "sent", "customer": {"name": "Fennlor"}, "items": []}
render = typed(client).invoice(data, filename="invoice.pdf")
async def render_async(async_client):
return await typed_async(async_client).invoice(data)
The methods pass every other keyword argument to renders.create and return a Render. mypy and Pyright check the data; at runtime it is an ordinary dict.
Async renders and batches
queued = client.renders.create(template="annual-report", data=payload, mode="async")
done = client.renders.wait_for(queued.id, timeout=120)
job = client.renders.batch(
[{"data": {"invoice": o}, "filename": f"{o['number']}.pdf", "meta": {"order_id": o["id"]}} for o in orders],
template="invoice",
zip=True,
)
finished = client.jobs.wait_for(job.id)
print(finished.status, finished.succeeded, finished.failed, finished.zip_url)
renders.wait_for(render_id, timeout=120, interval=1) asks every interval seconds until the render succeeded or failed and returns it either way; after timeout seconds it raises FormfeedError with the code timeout, while the render goes on. jobs.wait_for does the same for a batch (defaults: timeout=600, interval=2) and returns once the job is completed, partially_failed or failed. For anything that takes more than a few seconds in production, prefer webhooks over polling.
asyncio
AsyncFormfeed has the same methods, awaitable:
from formfeed import AsyncFormfeed
async with AsyncFormfeed(api_key) as client:
render = await client.renders.create(template="invoice", data=data)
pdf = await client.renders.download(render)
List renders and delete outputs
page = client.renders.list(template="invoice", status="failed", limit=20)
for r in page.data:
print(r.id, r.error)
august = client.renders.all(since="2026-08-01T00:00:00Z", until="2026-09-01T00:00:00Z") # follows the cursor
client.renders.delete_outputs(render.id) # needs the file:delete scope
kept = client.renders.input(render.id) # the kept request (render:input); 410 render_input_expired when not kept
PDF tools
# in the same render: steps run as merge, watermark, password
contract = client.renders.create(template="contract", data=data, post={"watermark": {"text": "DRAFT"}})
# on renders you already have (a Render or its id); each returns a new render
bundle = client.pdf.merge([invoice, terms], filename="bundle.pdf")
locked = client.pdf.protect(bundle, user_password="open-sesame", permissions=["print"])
info = client.pdf.info(locked) # page_count, pages, encrypted, metadata; free
# office documents to PDF: an upload (Word, Excel, PowerPoint, OpenDocument, RTF or HTML, up to 20 MB, not kept)…
with open("report.xlsx", "rb") as f:
report = client.pdf.convert(file=f, single_page_sheets=True, filename="report.pdf")
# …or the output of a Word or PowerPoint template render
offer_pdf = client.pdf.convert(offer_render, page_ranges="1-3")
Starter plan and higher, 0.5 units per step on live keys; a conversion is counted like a PDF render. info is free on every plan. The output options of merge, protect, watermark and convert are keyword arguments as on a render: filename, expires_in, access, meta and storage. protect takes user_password, owner_password (random when left out) and permissions (print, copy, modify, annotate; everything not listed is forbidden); watermark takes text (the second positional argument) or image=, never both, and opacity (default 0.2), rotation (degrees clockwise, default -45) and color. convert takes either file (bytes or a binary file object, with an optional file_name) or a render, plus page_ranges, landscape, single_page_sheets and the output options. See PDF tools.
File library
from pathlib import Path
# the same name replaces the file everywhere; templates use {{ asset('brand/logo.png') }}
logo = client.files.upload(Path("logo.png").read_bytes(), "brand/logo.png", content_type="image/png")
print(logo.url)
brand = client.files.all(prefix="brand/")
client.files.delete(logo)
# library files in post-processing: an image watermark and a PDF merged by name
client.renders.create(template="contract", data=data, post={"watermark": {"image": "draft.png"}, "merge_after": ["terms.pdf"]})
client.pdf.watermark(render, image="draft.png", opacity=0.15)
file:read, file:write and file:delete scopes. Library files are served without a signature; see Files.
Brand kit and shared partials
brand = client.brand.get() # colors, fonts, logo URLs, legal_footer, page_defaults
partials = client.partials.list() # without source
letterhead = client.partials.get("letterhead")
result = client.partials.put(
"letterhead",
engine="jinja2",
source=letterhead.source.replace("Fennlor", "Fennlor Studio GmbH"),
base_version=letterhead.version, # 409 conflict when someone changed it in between
)
print(result.created, result.partial.version)
client.partials.delete("old-footer")
Templates include a shared partial by name ({% include "letterhead" %}) and see the kit as brand; every render uses the current version of both. Reading needs template:read, writing template:write.
Templates
for t in client.templates.all(kind="pdf"):
print(t.slug, t.published_version)
schema = client.templates.schema("invoice") # JSON Schema of the expected data
latest = client.templates.version("invoice", "latest") # html, css, settings, sample_data, partials…
draft = client.templates.create_version(
"invoice",
html=latest.html.replace("Invoice", "Tax invoice"),
# a new version replaces every file, so carry over what you do not change
css=latest.css, head=latest.head, settings=latest.settings, sample_data=latest.sample_data,
data_sets=latest.data_sets, data_schema=latest.data_schema, i18n=latest.i18n, partials=latest.partials,
base_checksum=latest.checksum, change_note="Rename heading",
)
client.templates.publish("invoice", draft.number) # allow_breaking=True publishes a breaking schema change
A new version holds exactly what you send: a field you leave out is empty in it (no CSS, no sample data, no partials). With base_checksum, the API answers 409 conflict when another version was saved since the one you read. templates.version(slug, which) takes "published" (the default), "latest", a number or a channel name; templates.schema(slug, version=…) returns the stored data schema, else one inferred from the sample data. Writing needs a key with template:write. See Templates API for versions and conflicts.
Word and PowerPoint templates
# the document is sent as multipart: bytes or a binary file object, file_name optional
with open("offer.docx", "rb") as f:
client.templates.create(
name="Offer", slug="offer", kind="docx", engine="jinja2", # kind="pptx" for PowerPoint
file=f, sample_data={"customer": {"name": "Olvarest GmbH"}}, publish=True,
)
# a new version with a new document (send settings and data along to keep them)…
with open("offer-v2.docx", "rb") as f:
client.templates.create_version("offer", file=f, change_note="New terms")
# …or without one: the latest version's document is kept, every other field is what you send
current = client.templates.version("offer", "latest")
client.templates.create_version(
"offer",
settings={**(current.settings or {}), "office": {"output": "pdf"}},
sample_data=current.sample_data, data_sets=current.data_sets, data_schema=current.data_schema, i18n=current.i18n,
)
# the document of a version ("published", "latest", a number or a channel name)
with open("offer.docx", "wb") as f:
f.write(client.templates.version_file("offer", "latest"))
filled = client.renders.create(template="offer", output="docx", data=data) # or output="pdf"
A Word or PowerPoint template has no html, css, head or partials; its versions carry source_file (hash, size and format) instead. Live renders of these templates need the Starter plan or above; on the Free plan, test renders produce the marked PDF only, and docx or pptx raises FormfeedError with the code test_output_unavailable.
Release channels
channels = client.templates.channels.list("invoice") # .data and .limits
client.templates.channels.set("invoice", "staging", version=8)
client.templates.channels.set("invoice", "published", version=7, canary={"version": 8, "percent": 10}) # Pro and above
client.templates.channels.promote("invoice", "published")
client.templates.channels.rollback("invoice", "published")
client.templates.channels.delete("invoice", "staging", force=True)
staging = client.templates.version("invoice", "staging") # the channel's version with its files
set, promote and rollback take allow_breaking=True and return a Channel with its schema_check; a breaking change between stored schemas otherwise raises FormfeedError with the code schema_breaking_change. set on published publishes. delete refuses a channel that renders of the last hour used (channel_in_use) unless you pass force=True. Named channels need the Starter plan (2 per template; 10 on Pro and Business, unlimited on Enterprise), canaries Pro and above; list returns the plan's allowance as .limits. AsyncFormfeed has the same methods. See Release channels.
Webhooks
from formfeed import parse_webhook_event
event = parse_webhook_event(secret, request.headers.get("Webhook-Signature"), request.get_data())
if event.type == "render.completed":
...
Pass the raw request body (bytes or str), not parsed JSON: the signature covers the bytes on the wire. parse_webhook_event returns a WebhookEvent (id, type, created_at, workspace_id, data) and raises ValueError when the signature does not match or its timestamp is more than five minutes from the current time (tolerance_seconds= changes the five minutes). verify_webhook_signature(secret, header, raw_body) returns a boolean instead of raising. Endpoints are managed with client.webhooks.create(url, events=[…]), list(), update(), delete() and test(); create and update take environments=["test"]. client.webhooks.resend(event_id, endpoint_id) sends a stored event again, and client.webhooks.listen.start(events=…, live=…) / end(session_id) manage the sessions formfeed listen uses. A complete Flask handler is on the Webhooks page.
Usage and account
usage = client.account.usage() # current period
august = client.account.usage("2026-08") # the period that started in August 2026
print(usage.used, usage.included, usage.by_template)
account = client.account.get() # workspace, plan, limits (a dict)
client.workspaces.delete(preview_workspace_id) # needs the workspace:delete scope
Units are counted per organisation, in billing periods that renew on the day the subscription started (calendar months on the Free plan without a subscription). Both need account:read.
Errors
from formfeed import FormfeedError
try:
client.renders.create(template="missing")
except FormfeedError as e:
print(e.code, e.status, e.request_id) # template_not_found 404 req_…
print(e.problem) # the full problem body
e.code is the problem code of the API's answer and e.status its HTTP status; e.problem is the problem body as a dict and e.request_id the X-Request-Id to quote to support. Failures on the client's side have the status 0 and these codes:
| Code | When |
|---|---|
network_error | The request could not be sent or timed out on every attempt |
timeout | renders.wait_for or jobs.wait_for ran out of time; the render or job goes on |
not_ready | renders.download of a render that has no output yet, or failed |
download_failed | The download link answered an error (the status is the link's) |
invalid_request | The client was created without an API key, or pdf.convert or pdf.watermark got both or neither of their alternatives |
An error answer without a problem code gets http_<status>, such as http_502. parse_webhook_event raises ValueError, not FormfeedError.