Python-SDK
formfeed ist der Python-Client für die REST-API, gebaut auf httpx mit pydantic-Modellen, für Python 3.10 und neuer. Er verhält sich genau wie das TypeScript-SDK: 429, 503 und Netzwerkfehler werden wiederholt, jedes Rendering trägt einen Idempotency-Key, und API-Probleme lösen einen FormfeedError aus. Verwenden Sie ihn auf einem Server: Die API weist einen API-Schlüssel ab, der von einer Webseite kommt.
pip install formfeed
Client einrichten
import os
from formfeed import Formfeed
client = Formfeed(os.environ["FORMFEED_API_KEY"])
| Argument | Standard | Hinweise |
|---|---|---|
api_key | erforderlich | ff_live_… oder ff_test_…; das erste Argument, die übrigen sind Schlüsselwortargumente |
region | "eu" | "eu" (https://api-eu.formfeed.dev/v1) oder "us" (https://api-us.formfeed.dev/v1) |
base_url | Host der Region | Vollständige URL inklusive /v1, statt des Hosts der Region. Nur nötig für eine andere Adresse als die öffentliche API, etwa eine, die Ihnen unser Support nennt. Hat Vorrang vor region. |
max_retries | 3 | Wiederholungen bei 429, 503 und Netzwerkfehlern; 0 schaltet das Wiederholen ab. |
timeout | 120.0 | Sekunden; der httpx-Timeout für Verbinden, Senden und Lesen jeder Anfrage. Eine Anfrage, die in den Timeout läuft, zählt als Netzwerkfehler und wird wiederholt. |
transport | keiner | Ein httpx-Transport, für Tests (httpx.MockTransport) oder Proxys |
Verwenden Sie den Client als Kontextmanager (with Formfeed(key) as client:) oder rufen Sie client.close() auf, wenn Sie fertig sind, um den Verbindungspool freizugeben; bei AsyncFormfeed mit async with oder await client.aclose().
Wiederholungen und Idempotenz
Die Regel ist die des TypeScript-SDK: 429, 503 und Netzwerkfehler werden bis zu max_retries-mal erneut gesendet, nach den Sekunden aus Retry-After, sonst nach 0,5 s, 1 s, 2 s und so weiter plus bis zu 0,25 s Zufall, nie mehr als 30 s. renders.create, renders.batch und die PDF-Tools merge, protect, watermark und convert senden einen Idempotency-Key, einen zufälligen, wenn Sie kein idempotency_key= übergeben; ihre Wiederholungen verwenden ihn erneut, also rendert und berechnet die API nur einmal. Um über Prozesse hinweg sicher zu wiederholen, übergeben Sie einen eigenen Schlüssel, etwa order-8812-invoice. Siehe Idempotenz.
Rendern
invoice = {"number": "2026-0042", "lines": [{"description": "Beratung", "qty": 8, "price": 120}]}
render = client.renders.create(
template="invoice",
data={"invoice": invoice},
filename=f"invoice-{invoice['number']}.pdf",
idempotency_key="order-8812-invoice",
)
# ein synchrones Rendering, das nach 60 Sekunden noch läuft, kommt eingereiht zurück; darauf warten
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))
Schlüsselwortargumente entsprechen eins zu eins dem Body von POST /renders: template, oder html mit engine, oder url; version; data; output (pdf, png, jpg, webp sowie docx oder pptx für Word- und PowerPoint-Vorlagen; ohne Angabe entscheidet die Vorlage); settings; locale; filename (der Name, den der Download bekommt, so übernommen, wie er ist: andere Zeichen als die Buchstaben A–Z, Ziffern, _, ., -, (, ) und Leerzeichen werden zu _, und die Endung wird ergänzt, wenn sie fehlt); expires_in; access; mode; webhook_url und webhook_secret; meta; region; dedupe; canary_key; validate_data; post; storage. Die API-Referenz beschreibt jedes Feld; idempotency_key gehört dem SDK. version wählt published (der Standard), eine Versionsnummer, latest (nur mit Test-Schlüsseln) oder einen Release Channel wie staging.
Ein fehlgeschlagenes Rendering löst einen FormfeedError aus. Ein synchrones Rendering, das nach 60 Sekunden keine Antwort hat, ist kein Fehler: Es kommt mit dem Status queued oder rendering zurück und wird im Hintergrund fertig (der automatische Fallback); prüfen Sie also status, bevor Sie herunterladen. client.renders.get(render_id) holt ein Rendering erneut, mit einem frischen Download-Link; client.renders.download(render) liefert die Bytes der Datei und löst not_ready aus, solange das Rendering keine Ausgabe hat.
Typisierte Daten
formfeed types --lang python erzeugt formfeed_templates.py mit einem TypedDict pro Vorlage und einem Wrapper mit einer Methode pro Vorlage, benannt nach dem Slug mit Unterstrichen statt Bindestrichen (aus delivery-note wird delivery_note); siehe Typisierte Vorlagendaten:
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)
Die Methoden reichen alle weiteren Keyword-Argumente an renders.create weiter und liefern ein Render. mypy und Pyright prüfen die Daten; zur Laufzeit sind sie ein gewöhnliches dict.
Asynchrone Renderings und 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) fragt alle interval Sekunden ab, bis das Rendering erfolgreich war oder fehlschlug, und liefert es in beiden Fällen; nach timeout Sekunden löst es FormfeedError mit dem Code timeout aus, während das Rendering weiterläuft. jobs.wait_for tut dasselbe für einen Batch (Standard: timeout=600, interval=2) und kehrt zurück, sobald der Job completed, partially_failed oder failed ist. Für alles, was in Produktion länger als ein paar Sekunden dauert, sind Webhooks dem Abfragen vorzuziehen.
asyncio
AsyncFormfeed hat dieselben Methoden, als Awaitables:
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)
Renderings auflisten und Ausgaben löschen
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") # folgt dem Cursor
client.renders.delete_outputs(render.id) # braucht den Scope file:delete
kept = client.renders.input(render.id) # die aufbewahrte Anfrage (render:input); 410 render_input_expired, wenn nicht aufbewahrt
PDF-Tools
# im selben Rendering: die Schritte laufen in der Reihenfolge merge, watermark, password
contract = client.renders.create(template="contract", data=data, post={"watermark": {"text": "ENTWURF"}})
# auf vorhandenen Renderings (ein Render oder seine ID); jeder Aufruf liefert ein neues Rendering
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; kostenlos
# Office-Dokumente in PDF: ein Upload (Word, Excel, PowerPoint, OpenDocument, RTF oder HTML, bis 20 MB, nicht gespeichert) …
with open("report.xlsx", "rb") as f:
report = client.pdf.convert(file=f, single_page_sheets=True, filename="report.pdf")
# … oder die Ausgabe eines Renderings einer Word- oder PowerPoint-Vorlage
offer_pdf = client.pdf.convert(offer_render, page_ranges="1-3")
Ab dem Starter-Plan, 0,5 Einheiten pro Schritt mit Live-Schlüsseln; eine Umwandlung zählt wie ein PDF-Rendering. info ist in jedem Plan kostenlos. Die Ausgabeoptionen von merge, protect, watermark und convert sind Schlüsselwortargumente wie bei einem Rendering: filename, expires_in, access, meta und storage. protect nimmt user_password, owner_password (zufällig, wenn weggelassen) und permissions (print, copy, modify, annotate; alles nicht Genannte ist verboten); watermark nimmt text (das zweite Positionsargument) oder image=, nie beides, dazu opacity (Standard 0,2), rotation (Grad im Uhrzeigersinn, Standard -45) und color. convert nimmt entweder file (Bytes oder ein binäres Dateiobjekt, optional mit file_name) oder ein Rendering, dazu page_ranges, landscape, single_page_sheets und die Ausgabeoptionen. Siehe PDF-Tools.
Dateibibliothek
from pathlib import Path
# derselbe Name ersetzt die Datei überall; Vorlagen verwenden {{ 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)
# Dateien der Bibliothek im Post-Processing: ein Bild-Wasserzeichen und ein PDF, über den Namen angehängt
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)
Scopes file:read, file:write und file:delete. Dateien der Bibliothek werden ohne Signatur ausgeliefert; siehe Dateien.
Brand Kit und geteilte Partials
brand = client.brand.get() # colors, fonts, Logo-URLs, legal_footer, page_defaults
partials = client.partials.list() # ohne 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, wenn es inzwischen jemand geändert hat
)
print(result.created, result.partial.version)
client.partials.delete("old-footer")
Vorlagen binden ein geteiltes Partial über seinen Namen ein ({% include "letterhead" %}) und sehen das Kit als brand; jedes Rendering verwendet die aktuelle Version von beidem. Lesen braucht template:read, Schreiben template:write.
Vorlagen
for t in client.templates.all(kind="pdf"):
print(t.slug, t.published_version)
schema = client.templates.schema("invoice") # JSON-Schema der erwarteten Daten
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"),
# eine neue Version ersetzt jede Datei, also übernehmen Sie, was Sie nicht ändern
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="Überschrift umbenennen",
)
client.templates.publish("invoice", draft.number) # allow_breaking=True veröffentlicht eine brechende Schema-Änderung
Eine neue Version enthält genau das, was Sie senden: Ein weggelassenes Feld ist in ihr leer (kein CSS, keine Beispieldaten, keine Partials). Mit base_checksum antwortet die API mit 409 conflict, wenn seit der gelesenen Version eine andere gespeichert wurde. templates.version(slug, which) nimmt "published" (der Standard), "latest", eine Nummer oder einen Channel-Namen; templates.schema(slug, version=…) liefert das gespeicherte Datenschema, sonst eines, das aus den Beispieldaten abgeleitet ist. Schreibzugriffe brauchen einen Schlüssel mit template:write. Versionen und Konflikte beschreibt die Vorlagen-API.
Word- und PowerPoint-Vorlagen
# das Dokument geht als Multipart: Bytes oder ein binäres Dateiobjekt, file_name optional
with open("offer.docx", "rb") as f:
client.templates.create(
name="Angebot", slug="offer", kind="docx", engine="jinja2", # kind="pptx" für PowerPoint
file=f, sample_data={"customer": {"name": "Olvarest GmbH"}}, publish=True,
)
# eine neue Version mit neuem Dokument (Einstellungen und Daten mitsenden, um sie zu behalten) …
with open("offer-v2.docx", "rb") as f:
client.templates.create_version("offer", file=f, change_note="Neue Konditionen")
# … oder ohne: Das Dokument der letzten Version bleibt, jedes andere Feld ist das, was Sie senden
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,
)
# das Dokument einer Version ("published", "latest", eine Nummer oder ein 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) # oder output="pdf"
Eine Word- oder PowerPoint-Vorlage hat kein html, css, head und keine partials; ihre Versionen tragen stattdessen source_file (Hash, Größe und Format). Live-Renderings dieser Vorlagen brauchen den Starter-Plan oder höher; im Free-Plan liefern Test-Renderings nur das markierte PDF, und docx oder pptx löst einen FormfeedError mit dem Code test_output_unavailable aus.
Release Channels
channels = client.templates.channels.list("invoice") # .data und .limits
client.templates.channels.set("invoice", "staging", version=8)
client.templates.channels.set("invoice", "published", version=7, canary={"version": 8, "percent": 10}) # ab Pro
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") # die Version des Channels mit ihren Dateien
set, promote und rollback nehmen allow_breaking=True und liefern einen Channel mit seinem schema_check; eine brechende Änderung zwischen gespeicherten Schemas löst sonst FormfeedError mit dem Code schema_breaking_change aus. set auf published veröffentlicht. delete weist einen Channel ab, den Renderings der letzten Stunde verwendet haben (channel_in_use), außer Sie übergeben force=True. Benannte Channels brauchen den Starter-Plan (2 je Vorlage; 10 bei Pro und Business, unbegrenzt bei Enterprise), Canaries Pro oder höher; list liefert das Kontingent des Plans als .limits. AsyncFormfeed hat dieselben Methoden. Siehe 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":
...
Übergeben Sie den rohen Anfrage-Body (bytes oder str), kein geparstes JSON: Die Signatur deckt die Bytes auf der Leitung ab. parse_webhook_event liefert ein WebhookEvent (id, type, created_at, workspace_id, data) und löst ValueError aus, wenn die Signatur nicht passt oder ihr Zeitstempel mehr als fünf Minuten von der aktuellen Zeit abweicht (tolerance_seconds= ändert die fünf Minuten). verify_webhook_signature(secret, header, raw_body) liefert einen Wahrheitswert, statt eine Ausnahme auszulösen. Endpunkte verwalten Sie mit client.webhooks.create(url, events=[…]), list(), update(), delete() und test(); create und update nehmen environments=["test"] an. client.webhooks.resend(event_id, endpoint_id) sendet ein gespeichertes Ereignis erneut, und client.webhooks.listen.start(events=…, live=…) / end(session_id) verwalten die Sitzungen, die formfeed listen verwendet. Ein vollständiger Flask-Handler steht auf der Seite Webhooks.
Verbrauch und Konto
usage = client.account.usage() # aktuelle Periode
august = client.account.usage("2026-08") # die Periode, die im August 2026 begann
print(usage.used, usage.included, usage.by_template)
account = client.account.get() # Workspace, Plan, Limits (ein dict)
client.workspaces.delete(preview_workspace_id) # braucht den Scope workspace:delete
Einheiten zählen je Organisation, in Abrechnungsperioden, die sich am Tag des Abschlusses des Abonnements erneuern (im Free-Plan ohne Abonnement Kalendermonate). Beides braucht account:read.
Fehler
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) # der vollständige Problem-Body
e.code ist der Problemcode der API-Antwort und e.status ihr HTTP-Status; e.problem ist der Problem-Body als dict und e.request_id die X-Request-Id, die Sie dem Support nennen. Fehler auf der Seite des Clients haben den Status 0 und diese Codes:
| Code | Wann |
|---|---|
network_error | Die Anfrage ließ sich bei keinem Versuch senden oder lief jedes Mal in den Timeout |
timeout | renders.wait_for oder jobs.wait_for hatte keine Zeit mehr; Rendering oder Job laufen weiter |
not_ready | renders.download eines Renderings, das noch keine Ausgabe hat oder fehlschlug |
download_failed | Der Download-Link antwortete mit einem Fehler (der Status ist der des Links) |
invalid_request | Der Client wurde ohne API-Schlüssel erzeugt, oder pdf.convert oder pdf.watermark bekam beide oder keine ihrer Alternativen |
Eine Fehlerantwort ohne Problemcode bekommt http_<status>, etwa http_502. parse_webhook_event löst ValueError aus, keinen FormfeedError.