Zum Hauptinhalt springen

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"])
ArgumentStandardHinweise
api_keyerforderlichff_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_urlHost der RegionVollstä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_retries3Wiederholungen bei 429, 503 und Netzwerkfehlern; 0 schaltet das Wiederholen ab.
timeout120.0Sekunden; 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.
transportkeinerEin 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:

CodeWann
network_errorDie Anfrage ließ sich bei keinem Versuch senden oder lief jedes Mal in den Timeout
timeoutrenders.wait_for oder jobs.wait_for hatte keine Zeit mehr; Rendering oder Job laufen weiter
not_readyrenders.download eines Renderings, das noch keine Ausgabe hat oder fehlschlug
download_failedDer Download-Link antwortete mit einem Fehler (der Status ist der des Links)
invalid_requestDer 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.