Asynchron und Batch
Synchrone Renderings sind die einfachste Integration. Eine Anfrage, die auf einen 200-seitigen Bericht wartet, blockiert aber Ihren Worker, und eine Schleife über 5.000 Zertifikate stößt an Rate-Limits. Übergeben Sie in beiden Fällen die Arbeit an Formfeed und lassen Sie sich melden, wenn sie erledigt ist.
Asynchrones Einzelrendering
Fügen Sie "mode": "async" hinzu:
curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: report-2026-09" \
-H "Content-Type: application/json" \
-d '{ "template": "monthly-report", "data": { … }, "mode": "async", "meta": { "report_id": "2026-09" } }'
Alles, was die Anfrage ablehnen kann (der Body, der Plan, die Vorlage, validate_data, das Kontingent), wird vor der Antwort geprüft. Dann ist die Antwort 202 Accepted mit dem Rendering im Status queued, einem Location-Header, der auf das Ergebnis des Renderings zeigt, und Retry-After: 5:
{ "id": "rnd_0f3c9a2e7b1d4e5f8a6c3b2d1e0f9a8b", "status": "queued", "download_url": null, "meta": { "report_id": "2026-09" }, … }
Eingereihte Renderings eines Workspace laufen so viele gleichzeitig, wie die gleichzeitigen Renderings des Plans erlauben; die übrigen warten, bis sie an der Reihe sind. Danach haben Sie zwei Möglichkeiten:
- einen Webhook empfangen (empfohlen): Ein Ereignis
render.completedoderrender.failedkommt an Ihren Webhook-Endpunkten an und enthält das vollständige Rendering mit seinerdownload_url. Ein"webhook_url": "https://…"in der Anfrage sendet dierender.*-Ereignisse dieses Renderings zusätzlich an diese URL; sie sind signiert, wenn die Anfrage auch einwebhook_secret(16–200 Zeichen) sendet, und ohne eines unsigniert (siehe Webhook-URLs pro Rendering); - oder abfragen: Rufen Sie
GET /renders/{id}alle ein bis zwei Sekunden auf, bisstatusden Wertsucceededoderfailedhat, oder fragen Sie die URL ausLocationab, die mit202antwortet, bis das Rendering fertig ist. Abfragen zählen nicht gegen das Rate-Limit.
Die SDKs verpacken das Abfragen in einen Aufruf (standardmäßig jede Sekunde, höchstens zwei Minuten lang):
const queued = await client.renders.create({
template: 'monthly-report',
data,
mode: 'async',
});
const done = await client.renders.waitFor(queued.id, { timeoutMs: 120_000 });
queued = client.renders.create(template="monthly-report", data=data, mode="async")
done = client.renders.wait_for(queued.id, timeout=120)
Der automatische Fallback
Eine synchrone Anfrage, die innerhalb von 30 Sekunden keinen freien Render-Slot gefunden hat oder nach 60 Sekunden keine Antwort hat, antwortet ebenfalls mit 202, dazu mit X-Render-Async-Fallback: true. Das Rendering wird nicht abgebrochen: Es wird im Hintergrund fertig, meldet sich wie jedes asynchrone Rendering über Webhooks oder per Abfrage und wird einmal berechnet. Code, der synchrone Anfragen stellt, sollte 202 als Aufforderung behandeln, Location abzufragen, nicht als Fehler.
Location ist das Ergebnis des Renderings, /v1/renders/{id}/result als absolute URL, und jedes 202 trägt Retry-After: 5. Das Ergebnis antwortet erneut mit 202, solange das Rendering wartet oder läuft. Ist es fertig, antwortet es wie die synchrone Anfrage: mit dem Rendering, mit der Datei, wenn die Anfrage ?output=binary verlangt hat (das Location dann wiederholt), oder mit dem Fehler des Renderings als Problem samt render_id. Clients, die einem 202 von selbst folgen, etwa Power Automate und Logic Apps, warten dort auf das fertige Dokument.
Batch
POST /v1/renders/batch reiht bis zu 1.000 Renderings als einen Job ein. Der Endpunkt braucht den Scope render:create.
curl https://api-eu.formfeed.dev/v1/renders/batch \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: course-cs101-certificates" \
-H "Content-Type: application/json" \
-d '{
"template": "certificate",
"zip": true,
"items": [
{ "data": { "name": "Jane Doe" }, "filename": "jane-doe.pdf", "meta": { "student_id": "s-1" } },
{ "data": { "name": "John Doe" }, "filename": "john-doe.pdf", "meta": { "student_id": "s-2" } }
],
"meta": { "course": "cs101" }
}'
| Feld | Typ | Standard | Bedeutung |
|---|---|---|---|
items | Array, 1 bis 1.000 | – | Eine Render-Anfrage pro Dokument. |
template | String | – | Die Vorlage jedes Eintrags, der keine eigene Quelle (template, html oder url) nennt. |
output | String | – | Das output jedes Eintrags, der keines nennt; ohne es erhält jeder Eintrag das Format seiner Vorlage. |
canary_key | String, 1–200 Zeichen | – | Der canary_key jedes Eintrags, der keinen nennt (Canaries). |
zip | Boolean | false | Zusätzlich alle erfolgreichen Ausgaben in ein Archiv packen. |
webhook_url | String | – | Eine öffentliche https://-Adresse, die die Ereignisse dieses Batches erhält (Webhooks). |
webhook_secret | String, 16–200 Zeichen | – | Signiert die Zustellungen an webhook_url; braucht webhook_url. |
meta | Objekt | {} | Ihre eigenen Felder für den Job, zurückgegeben in ihm und seinen job.*-Ereignissen. Jeder Eintrag hat sein eigenes meta. |
Ein Eintrag nimmt die Felder eines einzelnen Renderings, mit diesen Unterschieden: mode ist immer async, das eigene webhook_url und dedupe eines Eintrags wirken nicht, und Einträge werden nie dedupliziert. Der Batch rendert in der Region seines ersten Eintrags; lassen Sie region weg.
Alles wird geprüft, bevor etwas eingereiht wird: Ein ungültiger Eintrag ergibt 400 invalid_request mit seiner Position in index, ein Eintrag, der validate_data nicht besteht, 422 data_validation_error mit seinem index, und in einem Plan ohne Mehrverbrauch (Free) braucht ein Live-Batch mindestens so viele verbleibende Einheiten, wie er Einträge hat, sonst 402 quota_exceeded. In jedem dieser Fälle wird nichts vom Batch eingereiht.
Ein Batch ist immer asynchron. Die Antwort ist 202 mit dem Job, Retry-After: 5 und einem Location-Header mit dem Job als absoluter URL, /v1/jobs/{id}:
{ "id": "job_5b2e8c1d9f3a4b6e8c0d2f4a6b8c0e1d", "type": "batch", "status": "queued", "total": 2, "succeeded": 0, "failed": 0, "zip_url": null, "zip_expires_at": null, "items": [ … ], "meta": { "course": "cs101" }, … }
Verfolgen Sie ihn mit GET /v1/jobs/{id} (Scope render:read) oder warten Sie auf seinen Webhook:
| Job-Status | Bedeutung |
|---|---|
queued, processing | Einträge warten oder werden gerendert |
completed | Jeder Eintrag ist gelungen |
partially_failed | Einige Einträge sind fehlgeschlagen; die anderen haben Ausgaben |
failed | Jeder Eintrag ist fehlgeschlagen |
Der Job listet seine Renderings unter items in der Reihenfolge, in der Sie sie gesendet haben, jedes ein vollständiges Rendering mit eigenem status, download_url, error und meta. total, succeeded und failed zählen sie; completed_at wird gesetzt, wenn der letzte Eintrag fertig ist.
Mit "zip": true zeigt zip_url nach Abschluss des Jobs auf ein Archiv aller erfolgreichen Ausgaben, und zip_expires_at sagt, wann dieser Link abläuft. Jede Datei im Archiv ist nach dem filename ihres Eintrags benannt, mit der Position des Eintrags in der Anfrage davor, damit die Namen eindeutig bleiben und sagen, welcher Eintrag eine Datei ist: 0001-jane-doe.pdf für den ersten Eintrag, 0002-john-doe.pdf für den zweiten. Ein fehlgeschlagener Eintrag lässt seine Nummer aus. Der Link ist signiert und bis zu sieben Tage gültig; GET /jobs/{id} stellt einen frischen aus, bis das Archiv selbst gelöscht wird, und das geschieht, wenn die erste Datei darin abläuft. Danach ist zip_url null.
Ein fehlschlagender Eintrag hält die anderen nie auf. Prüfen Sie failed und das error der Einträge, um genau die fehlgeschlagenen erneut zu versuchen.
Webhooks eines Batches
Jeder Eintrag sendet sein eigenes Ereignis render.completed oder render.failed, wenn er fertig ist. Ist der letzte Eintrag fertig (und mit zip, sobald das Archiv bereitliegt), sendet der Job job.completed (Status completed oder partially_failed) oder job.failed (jeder Eintrag ist fehlgeschlagen), mit dem ganzen Job und jedem Eintrag als vollständigem Rendering. Ihre Webhook-Endpunkte erhalten die Ereignistypen, die sie abonniert haben. Die eigene webhook_url des Batches erhält alle: ein render.*-Ereignis pro Eintrag und das job.*-Ereignis.
Canaries in einem Batch
Hat der Channel der Vorlage einen Canary, trifft ein Batch eine Wahl für alle seine Einträge, sodass die Dokumente eines Batches dieselbe Version teilen: anhand seines Idempotency-Key, der eine Wiederholung des Batches auf derselben Seite hält, oder ohne einen zufällig. Über einen Eintrag mit canary_key (seinem eigenen oder dem des Batches) entscheidet stattdessen dieser Schlüssel.
Einheiten
Jeder Eintrag wird wie ein einzelnes Rendering abgerechnet, ein fehlgeschlagener Eintrag kostet nichts. Das Zip kostet nichts. Einträge mit E-Rechnung nehmen in einem Test-Batch je eine der Test-E-Rechnungen des Tages.
Mit den SDKs (jobs.waitFor fragt standardmäßig alle zwei Sekunden ab, höchstens zehn Minuten lang):
const job = await client.renders.batch({
template: 'certificate',
items: students.map((s) => ({
data: { name: s.name },
filename: `${s.slug}.pdf`,
meta: { student_id: s.id },
})),
zip: true,
});
const finished = await client.jobs.waitFor(job.id);
console.log(finished.succeeded, finished.failed, finished.zip_url);
job = client.renders.batch([{"data": {"name": s.name}, "meta": {"student_id": s.id}} for s in students], template="certificate", zip=True)
finished = client.jobs.wait_for(job.id)
Mehr als 1.000 Dokumente
Teilen Sie die Liste in Abschnitte zu je 1.000 Einträgen und senden Sie pro Abschnitt einen Batch, jeden mit eigenem Idempotenz-Schlüssel (course-cs101-certificates-part-3). Jeder Batch zählt einmal gegen das Rate-Limit, gleich wie viele Einträge er hat.
Aus einer Tabelle, ohne Code
Der Dialog Renderings → Batch aus Tabelle der App macht aus jeder Zeile einer CSV- oder Excel-Datei einen Eintrag eines Batches. Siehe Batch aus einer Tabelle.