Skip to main content

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"])
ArgumentDefaultNotes
api_keyrequiredff_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_urlregion hostFull 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_retries3Retries on 429, 503 and network errors; 0 turns retrying off.
timeout120.0Seconds; httpx's timeout for connecting, sending and reading each request. A request that times out counts as a network error and is retried.
transportnoneAn 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:

CodeWhen
network_errorThe request could not be sent or timed out on every attempt
timeoutrenders.wait_for or jobs.wait_for ran out of time; the render or job goes on
not_readyrenders.download of a render that has no output yet, or failed
download_failedThe download link answered an error (the status is the link's)
invalid_requestThe 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.