Webhooks
Webhooks teilen Ihrer Anwendung mit, dass etwas passiert ist: Ein Rendering ist fertig, ein Batch ist abgeschlossen, das Kontingent geht zur Neige. Formfeed sendet ein HTTPS-POST mit einem JSON-Ereignis an jeden Endpunkt, der diesen Ereignistyp abonniert hat.
Es gibt zwei Wege, sie zu empfangen:
- Ein registrierter Endpunkt des Workspace erhält jedes Ereignis der Typen, die er abonniert hat. Das verwendet eine Integration in Produktion.
- Eine URL pro Rendering: Eine Render- oder Batch-Anfrage nennt ihre eigene
webhook_url, die nur die Ereignisse dieser Anfrage erhält.
formfeed listen --forward-to http://localhost:3000/webhooks leitet die Ereignisse des Workspace an einen lokalen Server weiter, signiert wie echte Zustellungen. Siehe Webhooks lokal entwickeln.
Endpunkt registrieren
In der App legen Inhaber und Admins Endpunkte unter Webhooks mit Endpunkt hinzufügen an. Über die API braucht es einen Schlüssel mit dem Scope webhook:manage, der nicht zu den Standard-Scopes eines neuen Schlüssels gehört:
curl https://api-eu.formfeed.dev/v1/webhooks \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://fennlor.example/hooks/formfeed", "events": ["render.completed", "render.failed", "job.completed", "job.failed"] }'
| Feld | Typ | Standard | Bedeutung |
|---|---|---|---|
url | String, Pflicht | – | Wohin Ereignisse gesendet werden: eine öffentliche https://-Adresse, höchstens 2.000 Zeichen. Private, interne und Metadaten-Adressen werden mit 400 abgelehnt. |
events | Array | render.completed, render.failed, job.completed, job.failed | Die Ereignistypen, die der Endpunkt erhält. quota.* und die Opt-in-Typen storage.* kommen nur, wenn sie aufgeführt sind. |
environments | Array | ["live", "test"] | Über wessen Renderings der Endpunkt informiert wird, siehe Test- und Live-Ereignisse. |
description | String | null | Ihre eigene Notiz, höchstens 200 Zeichen. |
Die Antwort ist 201 mit dem Endpunkt: seiner id (eine UUID, die die anderen Aufrufe erwarten), seinen Einstellungen, enabled: true und seinem Signaturgeheimnis secret, einem String, der mit whsec_ beginnt. Das Geheimnis wird nur einmal angezeigt: Speichern Sie es als FORMFEED_WEBHOOK_SECRET. Ein Header Location nennt die absolute URL des Endpunkts.
Die URL muss öffentlich und HTTPS sein. Weiterleitungen werden nicht verfolgt: Eine Antwort 3xx zählt als fehlgeschlagene Zustellung.
Wie viele Endpunkte eine Organisation haben kann, legt ihr Plan fest: einen im Free-Plan, fünf im Starter-Plan, beliebig viele ab Pro, gezählt über alle ihre Workspaces, deaktivierte eingeschlossen. Ein eingeschalteter Trigger eines No-Code-Tools wie Zapier, Make oder n8n ist ebenfalls ein Endpunkt, eine Sitzung von formfeed listen nicht. Ein weiterer ergibt 403 forbidden mit feature: "webhooks" und dem limit: Löschen Sie einen Endpunkt oder wechseln Sie den Plan.
Endpunkte verwalten Sie mit diesen Aufrufen, alle mit webhook:manage:
| Aufruf | Wirkung |
|---|---|
GET /webhooks | Listet die Endpunkte des Workspace als { "data": [ … ] }, mit consecutive_failures, aber ohne Geheimnisse. Laufende Sitzungen von formfeed listen stehen nicht darin. |
POST /webhooks | Legt einen Endpunkt an, wie oben. |
PUT /webhooks/{id} | Ändert beliebige von url, events, environments, description und enabled; weggelassene Felder bleiben, wie sie sind. "enabled": true setzt auch die Fehlerzählung zurück. |
DELETE /webhooks/{id} | Löscht den Endpunkt (204). |
POST /webhooks/{id}/test | Sendet ein Ereignis webhook.test an genau diesen Endpunkt (202, { "queued": true }), unabhängig von seinen events und environments. Ein deaktivierter Endpunkt empfängt nichts, sein Test antwortet daher mit 409 conflict: Schalten Sie ihn zuerst ein. Das Ergebnis steht im Zustellprotokoll. |
Die SDKs bieten dieselben Aufrufe als client.webhooks.list(), create(), update(), delete() und test(). Test senden auf der Seite Webhooks der App tut dasselbe wie POST /webhooks/{id}/test.
Test- und Live-Ereignisse
environments legt fest, über wessen Renderings ein Endpunkt informiert wird: live für Renderings mit Live-Schlüsseln, test für Renderings mit Test-Schlüsseln. Standard sind beide. Ein Staging-Empfänger, der nie Produktionsdokumente sehen soll, nimmt ["test"], ein Produktions-Empfänger, der Ihre Tests ignorieren soll, ["live"]:
{
"url": "https://staging.fennlor.example/hooks/formfeed",
"events": ["render.completed"],
"environments": ["test"]
}
Ändern Sie es später mit PUT /webhooks/{id}. quota.warning und quota.exceeded gehören zu keiner Umgebung und erreichen jeden Endpunkt, der sie abonniert hat. storage.delivered und storage.failed gehören zur Umgebung des Renderings, das kopiert wurde.
Ereignisse
| Ereignis | Gesendet, wenn | data | Standardmäßig abonniert |
|---|---|---|---|
render.completed | Ein Rendering ist gelungen: synchron, asynchron oder als Eintrag eines Batches | Das Rendering, mit download_url und Ihrem meta | ja |
render.failed | Ein Rendering ist endgültig fehlgeschlagen | Das Rendering, mit error | ja |
job.completed | Ein Batch ist fertig und mindestens ein Eintrag ist gelungen (sein status ist completed oder partially_failed); hat der Batch ein Zip angefordert, sobald das Zip bereit ist | Der Job, mit zip_url und den Renderings in items | ja |
job.failed | Ein Batch ist fertig und jeder Eintrag ist fehlgeschlagen | Der Job | ja |
quota.warning | 80 % der enthaltenen Einheiten der Periode sind verbraucht, einmal pro Abrechnungsperiode | org_id, period_start, usage, siehe Kontingent-Ereignisse | nein |
quota.exceeded | Alle enthaltenen Einheiten der Periode sind verbraucht, einmal pro Abrechnungsperiode | org_id, period_start, usage | nein |
storage.delivered | Opt-in. Die Kopie eines Dokuments ist im eigenen Bucket des Workspace angekommen | Die Kopie: bucket, key, url, render_id, sha256, etag, … | nein |
storage.failed | Opt-in. Eine Kopie in den eigenen Bucket des Workspace ist endgültig fehlgeschlagen | Die Kopie, mit reason und dem error des Anbieters | nein |
webhook.test | Sie haben Test senden gedrückt oder POST /webhooks/{id}/test aufgerufen | endpoint_id, message | immer, nur an den getesteten Endpunkt |
Ein Endpunkt, der ohne events angelegt wird, erhält die vier Render- und Job-Ereignisse. Nehmen Sie quota.warning und quota.exceeded in seine Liste auf, um diese zu erhalten. Die beiden Speicher-Ereignisse sind Opt-in: Ein Endpunkt erhält sie nur, wenn seine Liste events sie nennt, was standardmäßig kein Endpunkt tut, bestehende eingeschlossen. Die eigene webhook_url eines Renderings erhält weder die Kontingent- noch die Speicher-Ereignisse.
Ein einzelnes Rendering mit eigener webhook_url sendet nur render.*-Ereignisse, nie ein job.*-Ereignis; job.*-Ereignisse gehören zu Batches.
Neue Ereignistypen können jederzeit hinzukommen. Ignorieren Sie die, die Sie nicht verarbeiten, und antworten Sie trotzdem mit 2xx.
Kontingent-Ereignisse
Kontingent-Ereignisse betreffen die ganze Organisation, nicht einen Workspace: Sie erreichen die abonnierten Endpunkte aller Workspaces der Organisation, und ihre workspace_id ist null. usage ist der Stand der Abrechnungsperiode, als das Ereignis entstand:
{
"id": "evt_2c7e0f9a4b1d4e6f8a3c5b7d9e1f2a4c",
"type": "quota.warning",
"created_at": "2026-09-24T08:12:40Z",
"workspace_id": null,
"data": {
"org_id": "5f2b9c1e-…",
"period_start": "2026-09-07",
"usage": {
"period_start": "2026-09-07",
"period_end": "2026-10-07",
"included_units": 15000,
"used_units": 12004,
"overage_used": 0,
"overage_balance": 0,
"pack_units_used": 0,
"plan_id": "pro",
"price_eur_month": 59,
"overage_eur_per_1000": 5,
"auto_top_up": false,
"hard_cap_units": 50400
}
}
}
hard_cap_units ist die Grenze, an der ein bezahlter Plan aufhört zu rendern (ein Prepaid-Paket hebt sie an); null bei einem Plan ohne Mehrverbrauch. Siehe Wenn die Einheiten aufgebraucht sind. Die Inhaber und Admins der Organisation sowie ihre Rechnungs-E-Mail-Adresse erhalten dieselben beiden Hinweise per E-Mail.
Nutzlast
POST /hooks/formfeed HTTP/1.1
Content-Type: application/json
User-Agent: Formfeed-Webhooks/1.0
Webhook-Id: evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a
Webhook-Timestamp: 1757584800
Webhook-Attempt: 1
Webhook-Signature: t=1757584800,v1=5f1c2a…
{
"id": "evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a",
"type": "render.completed",
"created_at": "2026-09-11T10:00:00Z",
"workspace_id": "3a6f…",
"data": {
"id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"status": "succeeded",
"download_url": "https://cdn.formfeed.dev/o/eu/…?exp=…&sig=…",
"page_count": 2,
"meta": { "order_id": "8812" },
…
}
}
| Header | Wert |
|---|---|
Webhook-Id | Die ID des Ereignisses, evt_…. Sie bleibt über Wiederholungen und erneute Zustellungen des Ereignisses gleich, und jeder Endpunkt, der das Ereignis erhält, bekommt dieselbe ID. |
Webhook-Timestamp | Unix-Sekunden, zu denen dieser Versuch gesendet wurde. Derselbe Wert steht als t in der Signatur. |
Webhook-Attempt | Der Zustellversuch, beginnend bei 1. |
Webhook-Signature | t=<unix seconds>,v1=<hex>, siehe Signatur prüfen. Fehlt bei einer URL pro Rendering ohne webhook_secret. |
Der Body ist das Ereignis: id, type, created_at (wann das Ereignis entstand), workspace_id (null bei Kontingent-Ereignissen) und data.
Bei render.*-Ereignissen ist data das Rendering genau so, wie GET /renders/{id} es liefert, mit IDs der Form rnd_… und tpl_…; bei job.*-Ereignissen der Batch wie bei GET /jobs/{id}, mit zip_url und den Renderings in items; bei storage.*-Ereignissen die Kopie mit ihrem status, bucket und key. download_url und zip_url eines Ereignisses werden beim Erzeugen des Ereignisses signiert und gelten sieben Tage oder bis das Ergebnis abläuft, je nachdem, was zuerst eintritt (ein Ergebnis mit "access": "public" trägt stattdessen seinen öffentlichen Link, der so lange lebt wie die Datei). Eine später wiederholte oder erneut zugestellte Lieferung trägt dieselben URLs; für eine frische URL rufen Sie das Rendering ab.
Signatur prüfen
Der Header Webhook-Signature hat die Form t=<unix seconds>,v1=<hex>:
tist der Moment, in dem der Versuch gesendet wurde. Jeder Versuch, Wiederholungen eingeschlossen, wird mit einem neuentneu signiert.v1ist der HMAC-SHA256 des Stringst + "." + rawBody, geschrieben als 64 Hex-Zeichen in Kleinbuchstaben.- Der Schlüssel ist Ihr Geheimnis genau so, wie Sie es erhalten haben, als UTF-8-Bytes: der ganze String
whsec_…eines Endpunkts oder einer Sitzung vonformfeed listen, oder daswebhook_secret, das Sie mit einer URL pro Rendering gesendet haben. Entfernen Sie das Präfixwhsec_nicht und dekodieren Sie den Rest nicht.
Die Header-Namen sind die der Spezifikation Standard Webhooks, die Signatur ist es nicht: Eine Bibliothek für diese Spezifikation lehnt jede Zustellung von Formfeed ab. Verwenden Sie die SDKs oder den Code unten. Prüfen Sie die Signatur immer, bevor Sie einem Ereignis vertrauen:
- Berechnen Sie den HMAC über den rohen Anfrage-Body, genau so, wie er angekommen ist. Wer das JSON parst und wieder serialisiert, verändert die Bytes und macht die Signatur ungültig.
- Vergleichen Sie in konstanter Zeit.
- Lehnen Sie ein
tab, das mehr als fünf Minuten von Ihrer Uhr abweicht. Das verhindert, dass mitgeschnittene Anfragen erneut eingespielt werden. Die SDKs lehnen es in beide Richtungen ab; halten Sie die Uhr des Servers also synchron.
- Node (SDK)
- Python (SDK)
- Jede Sprache
import express from 'express';
import { parseWebhookEvent } from '@formfeed/sdk';
const app = express();
// express.text behält den Body als genau den String, der signiert wurde
app.post(
'/hooks/formfeed',
express.text({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
} catch {
return res.status(400).send('invalid signature');
}
if (event.type === 'render.completed') {
await queue.add('store-document', event.data); // Arbeit später erledigen, jetzt antworten
}
res.sendStatus(204);
},
);
parseWebhookEvent wirft eine Ausnahme, wenn die Signatur nicht passt, und gibt sonst das geparste Ereignis zurück. verifyWebhookSignature(secret, header, rawBody) liefert stattdessen true oder false. Beide sind asynchron, nehmen ein optionales viertes Argument { toleranceSeconds } (Standard 300) und verwenden Web Crypto; sie funktionieren daher in Node, Deno, Bun und Cloudflare Workers.
import os
from flask import Flask, request
from formfeed import parse_webhook_event
app = Flask(__name__)
@app.post("/hooks/formfeed")
def formfeed_hook():
try:
event = parse_webhook_event(
os.environ["FORMFEED_WEBHOOK_SECRET"],
request.headers.get("Webhook-Signature"),
request.get_data(), # rohe Bytes, nicht request.json
)
except ValueError:
return "invalid signature", 400
if event.type == "render.completed":
store_document.delay(event.data) # Arbeit später erledigen, jetzt antworten
return "", 204
parse_webhook_event löst ValueError aus, wenn die Signatur nicht passt, und gibt sonst ein WebhookEvent zurück (id, type, created_at, workspace_id, data). verify_webhook_signature(secret, header, raw_body) gibt stattdessen True oder False zurück. Beide nehmen den Body als bytes oder str und ein Schlüsselwortargument tolerance_seconds (Standard 300).
// Node ohne das SDK; dieselben Schritte funktionieren in jeder Sprache mit HMAC-SHA256
import { createHmac, timingSafeEqual } from 'node:crypto';
// header: der Header Webhook-Signature; rawBody: der Body, wie er ankam (String oder Buffer)
function verify(secret, header, rawBody, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.trim().split('=')),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds)
return false;
const expected = createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const given = Buffer.from(String(parts.v1 ?? ''), 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
Ein vollständiger Empfänger, der lauscht, in konstanter Zeit prüft und 401 antwortet, wenn die
Prüfung fehlschlägt, ist in den Guides für sieben Sprachen ausgeschrieben:
Node.js,
Python, PHP,
Go, C#,
Java und Ruby.
Einen zuverlässigen Handler bauen
- Antworten Sie schnell. Geben Sie innerhalb von 10 Sekunden
2xxzurück, sonst zählt die Zustellung als fehlgeschlagen. Legen Sie langsame Arbeit (die Datei herunterladen, eine E-Mail senden) in eine Warteschlange und bestätigen Sie zuerst. - Rechnen Sie mit Duplikaten. Eine Zustellung kann mehr als einmal ankommen, zum Beispiel wenn Ihr
2xxverloren ging oder jemand das Ereignis erneut sendet. Deduplizieren Sie anhand deriddes Ereignisses. - Rechnen Sie mit beliebiger Reihenfolge. Wiederholungen können neuere Ereignisse überholen. Wenn die Reihenfolge wichtig ist, lesen Sie den aktuellen Zustand mit
GET /renders/{id}, statt der Abfolge der Ereignisse zu vertrauen. - Nutzen Sie
meta. Legen Sie Ihre eigenen IDs immetades Renderings ab. Dann weiß der Handler ohne Nachschlagetabelle, zu welchem Datensatz das Dokument gehört. - Laden Sie zeitnah herunter. Die
download_urlim Ereignis ist signiert und läuft ab. Wenn Sie später einen frischen Link brauchen, rufen SieGET /renders/{id}auf.
Wiederholungen und Deaktivierung
Eine Zustellung gelingt, wenn Ihr Endpunkt innerhalb von 10 Sekunden mit 2xx antwortet. Alles andere (ein anderer Status, eine Weiterleitung, eine Zeitüberschreitung, ein Verbindungsfehler, eine Adresse, die inzwischen in ein privates Netz aufgelöst wird) wird nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 12 Stunden wiederholt: insgesamt sechs Versuche, verteilt über etwa 14 Stunden und 40 Minuten. Jeder Versuch trägt denselben Body und dieselbe Webhook-Id, einen neuen Webhook-Timestamp und eine neue Signatur sowie den nächsten Webhook-Attempt.
Wenn die Wiederholungen von fünf Ereignissen in Folge erschöpft sind, schaltet Formfeed den Endpunkt ab: enabled wird false, und die Seite Webhooks zeigt ihn als Nach Fehlern abgeschaltet. Eine E-Mail darüber wird nicht verschickt; behalten Sie also die Seite im Blick, oder enabled und consecutive_failures in GET /webhooks. Eine gelungene Zustellung dazwischen setzt die Zählung zurück. Solange der Endpunkt abgeschaltet ist, werden ihm keine neuen Ereignisse zugestellt. Schalten Sie ihn mit seinem Schalter Aktiviert wieder ein, sobald der Handler repariert ist, oder mit PUT /webhooks/{id} und { "enabled": true }. URLs pro Rendering und Sitzungen von formfeed listen werden nie abgeschaltet.
Jede Zustellung ist mit Statuscode, Antwortzeit und Fehler unter Zustellungen auf der Seite Webhooks aufgeführt. Dort können Inhaber und Admins eine noch nicht erfolgreiche Zustellung mit Erneut zustellen sofort noch einmal senden, ohne auf den nächsten Versuch zu warten.
Ein Ereignis erneut senden
POST /webhooks/events/{id}/resend sendet ein gespeichertes Ereignis als neue Zustellung erneut, ob es zugestellt wurde oder nicht, zum Beispiel nachdem Sie einen Handler korrigiert haben, der 2xx geantwortet, aber das Falsche getan hat:
curl https://api-eu.formfeed.dev/v1/webhooks/events/evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a/resend \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "endpoint_id": "8d7c2a4e-…" }'
endpoint_id ist ein aktivierter, registrierter Endpunkt des Workspace (Scope webhook:manage) oder eine laufende Sitzung von formfeed listen (Scope webhook:listen). Das Ereignis muss vorher an einen Endpunkt, eine Sitzung oder eine URL pro Rendering des Workspace gegangen sein, erfolgreich oder nicht: Mit dieser Zustellung ist es gespeichert. Die Antwort ist 202 mit der neuen delivery_id, der event_id, dem Typ event und der endpoint_id; die Zustellung wird neu signiert, beginnt bei Webhook-Attempt: 1 und folgt den normalen Wiederholungen. Der Body ist das Ereignis, wie es gespeichert wurde, seine download_url kann also abgelaufen sein.
Ein unbekanntes Ereignis, ein unbekannter oder ein abgeschalteter Endpunkt antwortet mit 404 not_found, eine beendete Sitzung mit 410 listen_session_ended. In den SDKs heißt das client.webhooks.resend(eventId, endpointId), in der CLI formfeed webhooks resend <evt_id> --to <endpoint id>.
Webhook-URLs pro Rendering
Eine Render- oder Batch-Anfrage kann eine eigene webhook_url mitbringen, eine öffentliche https://-Adresse wie die eines Endpunkts. Ihre Ereignisse gehen zusätzlich zu den Endpunkten, die für den Workspace registriert sind, an diese URL – ob das Rendering synchron läuft oder im Hintergrund:
- Ein einzelnes Rendering sendet sein
render.completedoderrender.faileddorthin. - Ein Batch sendet das
render.*-Ereignis jedes Eintrags dorthin und dasjob.*-Ereignis, wenn der Batch fertig ist.
Geben Sie ein webhook_secret mit, werden diese Zustellungen damit signiert, genau wie die eines registrierten Endpunkts: derselbe Header Webhook-Signature, derselbe Prüfcode, mit Ihrem webhook_secret als Schlüssel. Das Geheimnis gehört Ihnen, die API gibt es nie zurück, und die App zeigt es nicht an.
{
"template": "invoice-de",
"data": { "invoice": { "number": "2026-0042" } },
"mode": "async",
"webhook_url": "https://fennlor.example/hooks/one-render",
"webhook_secret": "eine-lange-zufaellige-zeichenkette"
}
webhook_secret nimmt zwischen 16 und 200 Zeichen, und nur zusammen mit einer webhook_url; alles andere antwortet mit 400 invalid_request. Wiederholungen derselben Zustellung werden ebenfalls damit signiert. Die URL erhält keine quota.*- oder storage.*-Ereignisse, ihre Zustellungen folgen denselben Wiederholungen, und ihre Fehlschläge schalten nie etwas ab.
Ohne webhook_secret trägt die Zustellung keine Webhook-Signature; Sie können sie dann nicht von einer gefälschten Anfrage an dieselbe Adresse unterscheiden. Behandeln Sie ihren Body in dem Fall als Hinweis: Entnehmen Sie ihm die ID des Renderings und rufen Sie es mit GET /renders/{id} ab, bevor Sie handeln.