Entwickler
Webhooks-Referenz
Payload-Schema, Ereignistypen, Signierung, Wiederholungsverhalten und REST-Abonnement-Endpunkte für Zapier und Make.
Suchst du die Einrichtungsanleitung?
Diese Seite dokumentiert den Payload-Vertrag und die REST-Abonnement-API. Um einen benutzerdefinierten Webhook für dein Formular in der Oberfläche einzurichten, siehe Individuelle Webhooks.
Anfrage
POST <your-url> mit Content-Type: application/json.
Header
Content-Type: application/jsonX-formbase-Signature: t={timestamp},sha256={hex}— vorhanden, wenn ein Signing-Secret eingerichtet ist (siehe unten)X-formbase-Event-IdundX-formbase-Event-Type— dieselben Werte wieidundtypeim Body, sodass du deduplizieren und routen kannst, bevor du parst. Ein Request-Callback sendet dieselben zwei.Alle benutzerdefinierten Header, die du bei der Einrichtung hinzufügst. Sie werden unverändert übernommen, außer
Content-Type, das sich nicht überschreiben lässt. Native Abonnements, die überwebhooks.createerstellt wurden, haben keine benutzerdefinierten Header.
Ereignistypen
Abonnement-Ereignisse
Wenn du eine Webhook-Integration einrichtest, wählst du, welches Ereignis Zustellungen auslöst:
| Ereignis | Wann es ausgelöst wird |
|---|---|
| submission_created | Ein Ausfüller schließt das Formular ab und reicht es ein. Dies ist die Standardeinstellung. |
| submission_updated | Ein Ausfüller bearbeitet eine Einreichung, die er bereits gesendet hat, wenn das Formular die Bearbeitung nach dem Absenden erlaubt. |
| submission_abandoned | Ein Entwurf einer Einreichung war länger als das konfigurierte Fenster inaktiv. Erfordert die Nachverfolgung teilweiser Einreichungen (Pro). |
| request_completed | Eine empfangende Person schließt einen Request auf dem Formular ab. Trägt den Request-Block und die Antworten. |
| request_expired | Ein Request auf dem Formular läuft ab, bevor die empfangende Person ihn abschließt. Nur der Request-Block. |
| request_canceled | Ein Request auf dem Formular wird storniert. Nur der Request-Block. |
Die drei request_*-Ereignisse werden über webhooks.create abonniert und sind das, worauf die formbase-Apps für
Zapier, Make und n8n hören. Jedes liefert denselben Umschlag, den ein Request-Callback sendet,
signiert mit dem eigenen Geheimnis des Abonnements. Testanfragen erreichen nie ein Abonnement, und requests.replayCallback
sendet nur den Callback erneut. Ein Abonnement, ein Ereignis: submission_created ist Traffic über den öffentlichen Link, und
request_completed ist Request-Traffic, ein abgeschlossener Request löst submission_created also nie aus, und ein
Formular mit beiden Abonnements erhält eine Zustellung pro Abschluss. Eine Bearbeitung erreicht ausschließlich ein
submission_updated-Abonnement, nie ein submission_created-Abonnement. Der benutzerdefinierte Webhook, der in den
Formulareinstellungen konfiguriert ist, hat keine Ereigniswahl: Er empfängt erste Einreichungen und Bearbeitungen gleichermaßen,
unterschieden durch type.
Payload-Ereignistypen
Das Feld type im JSON-Body zeigt dir an, was passiert ist:
submission.completed— eine neue abgeschlossene Einreichungsubmission.updated— eine bestehende Einreichung wurde bearbeitetsubmission.abandoned— eine Entwurfseinreichung wurde nach dem konfigurierten Leerlauffenster abgebrochen
Ein Request-Callback und ein request_*-Abonnement verwenden denselben Umschlag mit
request.completed, request.expired und request.canceled, sodass ein Parser alle sechs liest.
Payload-Struktur
{
"id": "evt_abc123",
"type": "submission.completed",
"createdAt": "2026-04-25T12:34:56.000Z",
"apiVersion": "2026-09-24",
"test": false,
"data": {
"form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
"submission": {
"id": "sub_...",
"respondentEmail": "alice@example.com",
"submittedAt": "2026-04-25T12:34:56.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": {
"email": "alice@example.com",
"plan": "pro",
"attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
},
"display": {
"email": "alice@example.com",
"plan": "Pro",
"attendees": "Grace Hopper, Alan Turing"
}
}
}| Schlüssel | Was es ist |
|---|---|
| id | Die Event-ID. Wiederholungen verwenden sie erneut — dedupliziere danach. |
| type | Einer der sechs oben genannten Ereignistypen. |
| createdAt | Wann das Ereignis eingereiht wurde, nicht wann dieser Zustellversuch stattfand. Bleibt über Wiederholungen hinweg stabil. |
| apiVersion | Der Payload-Vertrag, als Datum. Er ändert sich, wenn ein Schlüssel entfernt oder umbenannt wird oder seine Bedeutung ändert. Neue Schlüssel kommen ohne Änderung hinzu. |
| test | true bei einer Beispiel- oder Testzustellung sowie bei einem im Testmodus erstellten Request. Immer vorhanden. |
| data.form.snapshotId | Die veröffentlichte Version, die die ausfüllende Person beantwortet hat. Feld-Schlüssel, Titel und Typen sind pro Snapshot fest. |
| data.submission.updatedAt | Wann die ausfüllende Person die Einreichung zuletzt bearbeitet hat, oder null bis zur ersten Bearbeitung. |
| data.submission.editCount | Wie oft die ausfüllende Person die Einreichung nach dem Absenden bearbeitet hat: 0 bei submission.completed, 1 bei der ersten Bearbeitung. |
| data.answers | Jede Antwort, nach Feld-Schlüssel. Jede Antwort erscheint einmal. |
| data.display | Für Menschen lesbarer Text für jede Antwort, unter denselben Schlüsseln. |
| data.schema | Optional: die Feldliste (key, title, type, group sowie die Options- oder Zeilen- und Spaltenschlüssel mit ihren Labels), wenn der Webhook dafür eingerichtet wurde, sie zu senden. |
answers und display
answers ist das flache { field key: value }-Objekt, dessen Schlüssel die bei der Veröffentlichung fixierten
Feld-Schlüssel sind. Lies es, wenn ein Workflow verzweigt oder einen Wert speichert: answers.email, kein Array zu
durchlaufen. Eine Auswahlantwort ist der Schlüssel der gewählten Option — der key, den
fields.list für diese Option listet —, ist also dieselbe, unabhängig davon, in welcher Sprache die ausfüllende Person
geantwortet hat. Ein Datum ist ein ISO-String, eine Zahl eine Zahl, eine Mehrfachauswahl ein Array von Options-Schlüsseln. Unbeantwortete
Felder werden ausgelassen, nie als null gesendet.
display trägt dieselben Schlüssel mit für Menschen lesbarem Text: das Optionslabel statt seines Schlüssels, ein formatiertes
Datum, eine verbundene Liste. Lies es, wenn eine Person den Wert sehen wird — eine Slack-Nachricht, eine Tabellenzelle, eine E-Mail.
Eine Wiederholungsgruppe erscheint in answers genau einmal, unter dem eigenen Feld-Schlüssel der Gruppe, als Array von
Zeilenobjekten, deren Schlüssel die Feld-Schlüssel der einzelnen Mitglieder sind — oben answers.attendees[0].attendee_name —
und in display als eine Zeile mit verbundenen Zeilen. Ein Mitglied wird nie auf die oberste Ebene gehoben.
Berechnete Felder erscheinen in beiden Maps, unter dem Namen des berechneten Felds als
Schlüssel (answers.total).
Buchungen und Zahlungen
Eine „Termin planen”-Frage und ein Zahlungsfeld tragen jeweils ein Objekt in answers, unter dem Feld-Schlüssel der Frage, und
eine Textzeile in display. Die Zeiten sind ISO-Zeitpunkte, sodass eine Tabellenkalkulation oder ein Workflow sie auswerten
kann, egal in welcher Sprache die ausfüllende Person geantwortet hat:
{
"book_a_call": {
"status": "confirmed",
"start": "2026-09-29T07:00:00.000Z",
"end": "2026-09-29T07:30:00.000Z",
"timeZone": "Europe/Oslo",
"attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
"meetingUrl": "https://app.cal.com/video/...",
"provider": "cal.com",
"providerBookingId": "...",
"eventTitle": "Intro call"
},
"pay_the_fee": {
"status": "paid",
"amount": 40,
"currency": "USD",
"amountRefunded": 0,
"receiptUrl": "https://pay.stripe.com/receipts/...",
"paidAt": "2026-09-24T10:12:00.000Z",
"refundedAt": null,
"disputedAt": null,
"provider": "stripe",
"providerPaymentIntentId": "pi_..."
}
}Der status einer Buchung ist confirmed, rescheduled, cancelled, rejected
oder no_show. Der einer Zahlung ist paid, partially_refunded, refunded oder
disputed, und amount steht in der Haupteinheit der Währung: 40 sind $40.00. Verschiebt Cal.com nach
dem Absenden eine Buchung oder erstattet Stripe eine Zahlung, aktualisiert formbase das Objekt, sodass submissions.list und
spätere Ereignisse den aktuellen Stand zeigen; für die Änderung wird kein neues Ereignis gesendet.
Vor apiVersion 2026-09-24 wurde eine Buchung als ein einziger Satz in answers gesendet und eine
Zahlung gar nicht.
Die Feldzuordnung des Webhooks gilt für beide Maps zugleich: Wähle “ausgewählte” Felder aus, und die anderen werden ausgelassen; benenne
die Spalte eines Felds um, und der neue Name ist sein Schlüssel in answers und display gleichermaßen. Ein Feld,
das das Formular veröffentlichte, bevor es Feld-Schlüssel gab, geht unter seiner Element-ID hinaus; veröffentliche das Formular erneut, um
ihm einen lesbaren Schlüssel zu geben.
Feldtitel und -typen
Das Event wiederholt Titel und Typ jedes Felds nicht. Lies sie aus fields.list, das pro data.form.snapshotId
stabil ist, sodass du die Feldliste cachen und nur neu abrufen kannst, wenn sich die Snapshot-ID ändert. Ein Empfänger, der keinen zweiten
Aufruf machen kann, kann Die Feldliste mit jedem Event senden in den Webhook-Einstellungen aktivieren; das Event trägt
dann data.schema, einen Eintrag pro Feld. Eine Auswahlfrage listet ihre options auf und eine Matrix ihre
rows und columns, jeweils als { key, label }, sodass sich die Schlüssel in answers
ohne einen zweiten Aufruf zu Labels auflösen lassen:
[
{ "key": "email", "title": "Email", "type": "email", "group": null },
{ "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
{ "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]Der Request-Block
Beim benutzerdefinierten Webhook, der in den Formulareinstellungen konfiguriert ist, trägt eine
Einreichung, die einen Request beantwortet hat, ein zusätzliches Objekt innerhalb von
data, request. Es fehlt bei jeder Einreichung über den öffentlichen Link, daher erkennt der Empfänger daran,
welcher der beiden Kanäle es war. Ein Zapier-, Make- oder n8n-Abonnement bekommt es nie zu sehen: Request-Traffic erreicht ein Abonnement
als request.completed, das den vollständigen Request-Block trägt.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— der Request, den diese Einreichung beantwortet hat. Übergib ihn anrequests.getfür das vollständige Bild.externalIdundmetadata— deine eigene Buchführung, genau so, wie du sie beirequests.createangegeben hast. Jedes erscheint nur, wenn es gesetzt wurde.
Webhooks sind keine Callbacks
Ein Einreichungs-Webhook feuert bei einer Einreichung; der Request-Block nennt nur den Request, den sie beantwortet hat. Ein
Callback feuert, wenn ein Request endet — abgeschlossen, abgelaufen oder storniert —, und trägt
context und outcome. Ablauf und Stornierung haben keine Einreichung, also feuert dafür nie ein
Einreichungs-Webhook. Um das Ende eines Requests ohne Callback-URL zu hören, abonniere request_completed,
request_expired oder request_canceled über webhooks.create.
Einreichungs-PDF
data.submission.pdfUrl ist ein Link zum PDF der Einreichung. Ein Formular mit einem benutzerdefinierten Webhook oder einem
Zapier-, Make- oder n8n-Abonnement behält ein PDF von jeder Einreichung, sodass dessen Events den Link enthalten. Er ist nur dann
null, wenn die Einreichung kein PDF hat.
Sprache der Einreichung
data.submission.language ist der BCP-47-Code der Sprache, in der die ausfüllende Person das Formular eingereicht hat (bei
übersetzten Formularen). Für einsprachige Formulare ist der Wert null. Verwende dieses Feld, um nach der Sprache der
ausfüllenden Person zu verzweigen oder weiterzuleiten, ohne eine separate Abfrage durchzuführen.
Payloads für abgebrochene Einreichungen
Wenn du submission_abandoned abonnierst, prüft formbase stündlich auf inaktive Entwürfe. Wenn ein Entwurf länger als das
konfigurierte Inaktivitätsfenster nicht bearbeitet wurde, löst formbase eine Zustellung aus.
Native API-Integrationen müssen idleWindow an webhooks.create senden. Zulässige Werte sind 12h,
1d, 3d und 1w. Es gibt keinen impliziten Standard; ein Abonnement für abgebrochene Einreichungen
ohne Wert wird abgelehnt.
| Inaktivitätsfenster | Beschreibung |
|---|---|
| 12 Stunden | Für Nachverfolgungen am selben Tag |
| 1 Tag | Standard — ein angemessener Abstand vor einer Erinnerung |
| 3 Tage | Für weniger dringende Formulare |
| 1 Woche | Für Formulare mit geringer Nutzungsfrequenz |
Die Payload-Struktur ist identisch mit einer abgeschlossenen Einreichung. Zwei Unterschiede:
Antworten können spärlich sein — nur Fragen, die die ausfüllende Person beantwortet hat, erscheinen in
answersunddisplay.submittedAt— fällt auf den Zustellungszeitstempel zurück, da der Ausfüller nie offiziell eingereicht hat.
Jede Integration wird höchstens einmal pro abgebrochenem Entwurf ausgelöst. Nach der Zustellung wird der Entwurf von zukünftigen Prüfungen ausgeschlossen.
Signierung
Jeder Webhook hat sein eigenes Signing-Secret — in der Oberfläche eingerichtet, wenn du einen benutzerdefinierten Webhook konfigurierst,
oder über den optionalen Parameter signingSecret (32–255 Zeichen) bei webhooks.create. Es ist nicht dasselbe wie
das Request-Signaturgeheimnis des Arbeitsbereichs, das für Request-Callbacks verwendet wird, aber
Header und Algorithmus sind identisch, sodass ein einziger Verifizierer für beide funktioniert.
Jede signierte Zustellung trägt X-formbase-Signature: t={seconds},sha256={hex}. Der Digest ist ein
HMAC-SHA256 von {t}.{raw body}, mit deinem Secret als Schlüssel. Zwei Regeln: hashe den
rohen Body vor jeglichem Parsen oder erneuten Serialisieren, und vergleiche in konstanter Zeit.
import crypto from 'node:crypto'
export function verifyFormbaseWebhook(rawBody: string, header: string | undefined, secret: string, toleranceSeconds = 300): boolean {
if (!header) return false
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
if (!parts.t || !parts.sha256) return false
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.sha256, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}import hashlib, hmac, time
def verify_formbase_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
t, received = parts.get('t'), parts.get('sha256')
if not t or not received:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
return False
return abs(time.time() - int(t)) <= tolerance_secondsformbase erzwingt kein Replay-Fenster, die Toleranz oben liegt also bei dir. Eine Änderung des Secrets wirkt sich auf den nächsten Versuch aus, auch auf bereits laufende Wiederholungen — aktualisiere zuerst deinen Empfänger.
In der Produktion immer verifizieren
Ohne Verifizierung kann jeder, der deine URL entdeckt, gefälschte Einreichungen senden.
Wiederholungsversuche
formbase unternimmt bis zu 5 Versuche pro Zustellung — 1 erster Versuch und 4 Wiederholungen — mit mindestens 1, 2, 4 und 8 Minuten
Abstand. formbase sucht alle 30 Minuten nach fälligen Wiederholungen, sodass eine Wiederholung bis zu einer halben Stunde nach Ablauf
ihres Backoffs kommen kann und der letzte Versuch etwa zwei Stunden nach dem ersten erfolgt. Ein Retry-After-Header in deiner
Antwort wird respektiert, wenn er länger verlangt als der nächste Backoff-Schritt. Eine Zustellung gilt als fehlgeschlagen, wenn dein
Endpunkt:
- einen Nicht-2xx-Status zurückgibt
- eine Zeitüberschreitung hat
- die Verbindung zurücksetzt
Ein Fall wird nie wiederholt: ein Ziel, das blockiert, nicht auflösbar ist oder auf eine private Adresse verweist. Die URL wird unmittelbar vor jedem Versuch neu geprüft — DNS eingeschlossen —, sodass ein Host, der nicht mehr erlaubt ist, die Zustellung sofort scheitern lässt, statt das Budget aufzubrauchen.
Nach 5 aufeinanderfolgenden fehlgeschlagenen Zustellungen wird die Integration pausiert. Behebe den Endpunkt und aktiviere sie über Formulareinstellungen → Integrationen erneut; eine erfolgreiche Zustellung setzt den Zähler zurück.
Wiederholte Zustellungen desselben Ereignisses verwenden dieselbe id — dedupliziere also, indem du verarbeitete IDs
speicherst. Ein wirklich neues Ereignis — etwa wenn eine ausfüllende Person ihre Einreichung bearbeitet — kommt mit einer neuen
id und type: “submission.updated” an: beim benutzerdefinierten Webhook oder bei einem
submission_updated-Abonnement. Der createdAt ist der Zeitpunkt, zu dem das Ereignis eingereiht wurde, nicht der
des Zustellversuchs, und bleibt daher auch über Wiederholungen hinweg gleich. Um Bearbeitungen zu unterscheiden, lies
data.submission.editCount: Er zählt mit jeder Bearbeitung hoch, und data.submission.updatedAt gibt an, wann die
letzte stattgefunden hat.
Testen
Sowohl das Integrations-Einrichtungsfenster als auch das Detailpanel haben eine Schaltfläche Test senden. Sie sendet ein
Beispiel des abonnierten Ereignisses an deine URL, damit du die Verbindung überprüfen kannst, ohne auf eine echte Einreichung oder einen
echten Request warten zu müssen. Dieselben Beispiele stehen auch über die API zur Verfügung, als submissions.sample und
requests.sample.
Für die lokale Entwicklung stelle deinen Dev-Server mit einem Tunnel bereit:
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Lokale Entwicklung
Verwende die Tunnel-URL als deinen Webhook-Endpunkt und klicke dann auf „Test senden”, um die Verbindung von Anfang bis Ende zu überprüfen.