Webhooks lokal entwickeln
Webhook-Endpunkte müssen öffentliche HTTPS-URLs sein, deshalb erreicht Formfeed den Server auf Ihrem Laptop nicht. formfeed listen schließt diese Lücke: Es leitet die Webhook-Ereignisse des Workspace an eine lokale URL weiter, signiert genau wie eine echte Zustellung. Der Handler, den Sie lokal schreiben, ist also der Handler, den Sie deployen. Kein Tunnel, kein temporärer Endpunkt, den Sie registrieren und später wieder löschen müssen.
$ formfeed listen --forward-to http://localhost:3000/webhooks
Ready. Forwarding test-environment events of workspace "Fennlor" to http://localhost:3000/webhooks
Webhook signing secret: whsec_3f9c… (kept for this API key across listen sessions)
12:04:11 render.completed rnd_8Hk2… → 200 (41 ms) evt_5b0c…
12:04:15 render.failed rnd_Q1a9… → 500 (12 ms) evt_91d2…
12:05:15 render.failed rnd_Q1a9… → 204 (9 ms) attempt 2 evt_91d2…
Sie brauchen die CLI (Node.js 22 oder neuer) und einen Schlüssel des Workspace mit dem Scope webhook:listen, den formfeed login Editoren, Admins und Inhabern gibt.
So funktioniert es
Eine Listen-Sitzung ist ein Webhook-Endpunkt des Workspace, dessen Adresse ein Relay im Formfeed-Gateway ist. Ereignisse werden genau wie bei einem registrierten Endpunkt zugeordnet, signiert, wiederholt und protokolliert; nur der letzte Schritt ist anders.
- Wann: im selben Moment wie für jeden anderen Endpunkt des Workspace. Ein lokaler Server sieht
render.completedetwa ein bis zwei Sekunden nach dem Ende des Renderings. - Von wo: Die Anfrage an
localhostkommt vom CLI-Prozess auf demselben Rechner. Nichts aus dem Internet verbindet sich mit Ihrem Laptop; die einzige Verbindung ist der WebSocket, den die CLI zum Gateway geöffnet hat. Das funktioniert hinter NAT, VPNs und Firmen-Proxys, die HTTPS zulassen. - Was ankommt: der bytegleiche Body und die Header
Content-Type,User-Agent,Webhook-Id,Webhook-Timestamp,Webhook-AttemptundWebhook-Signature, wie unter Nutzlast beschrieben. Die Signatur verwendet das Geheimnis der Sitzung statt das eines Endpunkts. - Was zurückgeht: nur der Statuscode Ihres Servers. Der Render-Worker behandelt ihn wie die Antwort eines echten Endpunkts:
2xxgilt als zugestellt, alles andere wird nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 12 Stunden wiederholt, und die Zustellung steht mit ihrem Status auf der Seite Webhooks der App. Ein lokaler Server, der nicht erreichbar ist oder nicht innerhalb von 8 Sekunden antwortet, wird als502protokolliert. - Wie lange eine Sitzung lebt: so lange, wie die CLI verbunden bleibt. Das Relay verlängert die Sitzung jede Minute, solange der WebSocket offen ist; stoppt die CLI oder bricht ihre Verbindung ab, endet die Sitzung.
Einen Empfänger aufsetzen
Beginnen Sie mit einem Handler, der die Signatur prüft. Er liest das Geheimnis aus einer Umgebungsvariablen, sodass derselbe Code in Produktion mit dem Geheimnis des Endpunkts läuft.
- Express
- Next.js
- Python (Flask)
import express from 'express';
import { verifyWebhookSignature } from '@formfeed/sdk';
const app = express();
// express.text behält den Body als genau den String, der signiert wurde
app.post(
'/webhooks',
express.text({ type: 'application/json' }),
async (req, res) => {
const ok = await verifyWebhookSignature(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body);
console.log(event.type, event.data.id);
res.sendStatus(204);
},
);
app.listen(3000);
import { verifyWebhookSignature } from '@formfeed/sdk';
export async function POST(request: Request) {
// der rohe Body, nicht request.json(): Die Signatur deckt die Bytes ab
const body = await request.text();
const ok = await verifyWebhookSignature(
process.env.FORMFEED_WEBHOOK_SECRET!,
request.headers.get('webhook-signature'),
body,
);
if (!ok) return new Response('invalid signature', { status: 400 });
const event = JSON.parse(body);
console.log(event.type, event.data.id);
return new Response(null, { status: 204 });
}
import json
import os
from flask import Flask, request
from formfeed import verify_webhook_signature
app = Flask(__name__)
@app.post("/webhooks")
def webhooks():
body = request.get_data() # rohe Bytes, nicht request.json
if not verify_webhook_signature(os.environ["FORMFEED_WEBHOOK_SECRET"], request.headers.get("Webhook-Signature"), body):
return "invalid signature", 400
event = json.loads(body)
print(event["type"], event["data"]["id"])
return "", 204
Dann in einem zweiten Terminal:
- Melden Sie sich einmal mit
formfeed loginan (oder setzen SieFORMFEED_API_KEYauf einen Schlüssel mit dem Scopewebhook:listen). - Starten Sie die Sitzung:
formfeed listen --forward-to http://localhost:3000/webhooks. - Kopieren Sie das ausgegebene Geheimnis nach
FORMFEED_WEBHOOK_SECRETund starten Sie Ihren Server neu. Das Geheimnis gehört zu Ihrem API-Schlüssel; das nächsteformfeed listenmit demselben Schlüssel signiert also mit demselben Geheimnis, und Ihre.envbleibt gültig. - Rendern Sie mit einem Test-Schlüssel oder rufen Sie
formfeed triggerauf, und sehen Sie zu, wie die Zustellung ankommt.
Jede Zeile zeigt die Uhrzeit, das Ereignis, das Rendering oder den Job (- bei einem Ereignis ohne, etwa einem Kontingent-Ereignis), die Antwort Ihres Servers (→ failed: mit dem Grund, wenn er nicht erreichbar war oder nicht rechtzeitig antwortete), ab dem zweiten Versuch dessen Nummer und die Ereignis-ID. Strg+C beendet die Sitzung; ab dann hören die Zustellungen an sie sofort auf.
Bricht die Verbindung zum Gateway ab, verbindet sich die CLI nach einer Pause von 1 bis 30 Sekunden selbst wieder, mit einer neuen Sitzung, und meldet „Reconnected with a new session; the signing secret is unchanged.“ Bekommt sie nach mehreren Versuchen keine Verbindung zurück, bricht sie mit einem Fehler ab. Ereignisse, die der alten Sitzung zugeordnet wurden, während die Verbindung unterbrochen war, werden nicht weitergeleitet: Ihre IDs stehen im Zustellprotokoll der App, und formfeed webhooks resend sendet sie an die neue Sitzung. Eine in der App beendete Sitzung beendet den Befehl.
Das Geheimnis wird von der vorherigen Sitzung des Schlüssels übernommen, egal wie lange diese zurückliegt: Die jeweils letzte Sitzung jedes Schlüssels bleibt dafür gespeichert. Nur ein Schlüssel, der noch nie gelauscht hat, erhält ein neues Geheimnis, und die CLI gibt es aus.
Optionen
| Option | Standard | Wirkung |
|---|---|---|
--forward-to <url> | http://localhost:3000/webhooks | Die lokale URL, die die Ereignisse empfängt, http:// oder https:// |
--events <types> | jeder Typ außer den Speicher-Ereignissen | Kommagetrennte Ereignistypen, zum Beispiel render.completed,job.failed. Der Standard umfasst die Render-, Job- und Kontingent-Ereignisse; die Opt-in-Ereignisse storage.delivered und storage.failed werden nur weitergeleitet, wenn sie genannt sind |
--live | aus | Auch Ereignisse von Live-Renderings weiterleiten. Ohne die Option werden nur Renderings mit Test-Schlüsseln (ff_test_…) weitergeleitet. Braucht zusätzlich den Scope webhook:manage, sonst antwortet der Start der Sitzung mit 403 forbidden |
--print-secret | aus | Startet eine Sitzung, gibt nur ihr Signaturgeheimnis aus und beendet die Sitzung wieder, für .env-Dateien: echo "FORMFEED_WEBHOOK_SECRET=$(formfeed listen --print-secret)" >> .env |
--skip-verify | aus | Akzeptiert ein selbstsigniertes Zertifikat, wenn die lokale URL https:// ist |
--json | aus | Globales Flag: eine JSON-Zeile pro Zustellung (und eine, sobald die Sitzung bereit ist, mit ihrem secret) statt der Tabelle |
--print-secret beendet eine laufende SitzungEine neue Sitzung beendet die vorherige Sitzung desselben Schlüssels, und --print-secret startet eine. Führen Sie es vor formfeed listen aus oder in einem Terminal mit einem anderen Schlüssel, sonst stoppt das laufende formfeed listen innerhalb einer Minute.
quota.warning und quota.exceeded gehören zu keiner Umgebung und werden weitergeleitet, wann immer ihr Typ ausgewählt ist, was er standardmäßig ist. Ein Rendering mit eigener webhook_url sendet weiterhin an diese URL; die Sitzung erhält dasselbe Ereignis zusätzlich, signiert mit dem Sitzungsgeheimnis.
Alle Befehle und Flags stehen in der CLI-Referenz.
Ein Ereignis erneut senden
Solange eine Sitzung läuft, können Sie jedes gespeicherte Ereignis erneut an sie senden, ob es zugestellt wurde oder nicht:
formfeed webhooks resend evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a
Die Ereignis-ID steht am Ende jeder Zeile, die formfeed listen ausgibt, im Header Webhook-Id einer Zustellung und im Zustellprotokoll der App. Ohne --to geht das Ereignis an die Sitzung des formfeed listen, das auf diesem Rechner mit demselben API-Schlüssel läuft. --to <id> nennt stattdessen eine Listen-Sitzung oder einen registrierten Endpunkt; ein Endpunkt braucht webhook:manage. Die erneute Zustellung ist eine neue, mit frischer Signatur und Webhook-Attempt: 1, und der Body ist das gespeicherte Ereignis unverändert, samt seiner download_url, die inzwischen abgelaufen sein kann.
Über die API ist das POST /webhooks/events/{id}/resend mit der endpoint_id einer Sitzung oder eines registrierten Endpunkts; die SDKs bieten client.webhooks.resend(eventId, endpointId) und client.webhooks.resend(event_id, endpoint_id). Die möglichen Antworten stehen unter Ein Ereignis erneut senden.
Ein echtes Ereignis auslösen
formfeed trigger render.completed --template invoice --data templates/invoice/data/default.json
rendert die Vorlage wirklich und asynchron. Das Ereignis, das Ihr Handler erhält, ist also echt und kein Fixture: Das Rendering existiert, download_url funktioniert, und GET /renders/{id} liefert es. Das Rendering trägt "meta": { "source": "formfeed trigger" }. Ohne --data verwendet die CLI den Datensatz default der Vorlage im Projekt, sonst die Beispieldaten ihrer veröffentlichten Version (ihrer neuesten Version, wenn keine veröffentlicht ist). Passen die Daten nicht, lautet das Ereignis render.failed.
Verwenden Sie einen Test-Schlüssel: Test-Renderings kosten keine Einheiten, und ihre Ereignisse erreichen eine Sitzung ohne --live. Mit einem Live-Schlüssel warnt die CLI, dass das Rendering abgerechnet wird. render.completed ist das einzige Ereignis, das trigger erzeugt; jedes andere gespeicherte Ereignis senden Sie mit formfeed webhooks resend erneut.
Grenzen
| Grenze | Wert |
|---|---|
| Sitzungen pro Workspace | 3 gleichzeitig; eine vierte antwortet mit 409 listen_session_limit |
| Sitzungen pro Schlüssel oder Mitglied | 1: Eine neue Sitzung beendet Ihre vorherige sofort, und das formfeed listen, das sie hielt, stoppt innerhalb einer Minute mit der Meldung, dass die Sitzung beendet wurde |
| Zustellungen pro Sitzung | 20 pro Sekunde; weitere werden mit 429 beantwortet und nach dem üblichen Zeitplan wiederholt |
| Zeit für Ihren lokalen Server | 8 Sekunden; danach zählt die Zustellung als fehlgeschlagen und wird wiederholt |
| Kosten | Keine; Sitzungen werden nicht abgerechnet |
Ist die Sitzung beendet, hören Zustellungen an sie sofort und ohne Wiederholung auf, und ein erneutes Senden an sie antwortet mit 410 listen_session_ended.
Ein fehlerhafter lokaler Server deaktiviert nie etwas: Die Regel, die einen Endpunkt nach fünf erschöpften Ereignissen in Folge deaktiviert, gilt für Sitzungen nicht. Beendete Sitzungen und ihre Zustellungen werden nach einem Tag gelöscht, außer der letzten Sitzung jedes Schlüssels, die sein Signaturgeheimnis hält.
Sicherheit
- Wer lauschen darf: Schlüssel mit dem Scope
webhook:listen. Neue API-Schlüssel haben ihn standardmäßig nicht;formfeed logingibt ihn Editoren, Admins und Inhabern. Live-Ereignisse brauchen zusätzlichwebhook:manage, denformfeed loginnur Admins und Inhabern gibt, damit ein Entwickler nicht versehentlich Dokumente von Kunden sieht. - Was eine Sitzung preisgibt: dieselben Ereignisse und signierten Download-Links, die Mitglieder schon im Zustellprotokoll der App sehen.
- Durchgehend signiert: Das Relay prüft jede Zustellung mit dem Sitzungsgeheimnis, bevor es etwas weiterleitet, und Ihr Handler prüft sie noch einmal. Eine erratene Sitzungs-ID nützt nichts.
- Nichts gespeichert: Das Relay hält nur den offenen WebSocket. Nutzlasten laufen durch Cloudflares EU-Jurisdiktion und werden dort nie gespeichert; das Zustellprotokoll in der Datenbank ist dasselbe wie bei jedem Endpunkt. Siehe Datenresidenz.
- Kein Tunnel:
listenleitet Formfeed-Ereignisse an eine lokale URL weiter. Es macht Ihren Server nicht erreichbar, nimmt keinen anderen Verkehr an und gibt nichts außer dem Statuscode zurück. - Sitzungen beenden: Die Seite Webhooks der App listet die laufenden Sitzungen unter CLI-Sessions; Inhaber und Admins beenden eine dort mit Session beenden. Über die API beendet
DELETE /webhooks/listen/{id}eine Sitzung des Workspace, mit jedem Schlüssel, derwebhook:listenhat.
Ohne die CLI
formfeed listen ist ein Client von POST /webhooks/listen (Scope webhook:listen). Der Body nimmt events (eine Liste von Ereignistypen, mit demselben Standard wie die CLI) und live (true, um Live-Renderings einzuschließen, was webhook:manage braucht). Die Antwort ist 201 mit der id der Sitzung, ihrem secret, ihren events und environments, expires_at und einer websocket_url, die 60 Sekunden gilt und keinen Schlüssel braucht.
Über diesen WebSocket sendet das Relay JSON-Nachrichten: { "type": "ready", "session": "…" }, sobald die Verbindung steht, { "type": "delivery", "id": "…", "headers": { … }, "body": "…" } für jede Zustellung und { "type": "ended", "reason": "…" }, wenn die Sitzung anderswo beendet wurde. Beantworten Sie jede Zustellung innerhalb von 9 Sekunden mit { "type": "result", "id": "…", "status": 204 }, dem Status, den Ihr Server gegeben hat, oder 0, wenn er nicht erreichbar war. Das Relay schließt den Socket mit dem Code 4000, wenn die Sitzung beendet wurde, und 4001, wenn eine andere Verbindung sie übernommen hat. DELETE /webhooks/listen/{id} beendet die Sitzung. Die SDKs starten und beenden Sitzungen mit client.webhooks.listen.start() und .end(id); den WebSocket übernimmt Ihr Code.
In Produktion
Registrieren Sie einen echten Endpunkt wie unter Webhooks beschrieben, legen Sie sein Geheimnis in FORMFEED_WEBHOOK_SECRET ab und deployen Sie denselben Handler. Mit environments des Endpunkts halten Sie Test-Ereignisse von Produktion fern.