formbasedocs
Zur AppApp

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/json
  • X-formbase-Signature: t={timestamp},sha256={hex} — vorhanden, wenn ein Signing-Secret eingerichtet ist (siehe unten)

  • X-formbase-Event-Id und X-formbase-Event-Type — dieselben Werte wie id und type im 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 über webhooks.create erstellt wurden, haben keine benutzerdefinierten Header.

Ereignistypen

Abonnement-Ereignisse

Wenn du eine Webhook-Integration einrichtest, wählst du, welches Ereignis Zustellungen auslöst:

EreignisWann es ausgelöst wird
submission_createdEin Ausfüller schließt das Formular ab und reicht es ein. Dies ist die Standardeinstellung.
submission_updatedEin Ausfüller bearbeitet eine Einreichung, die er bereits gesendet hat, wenn das Formular die Bearbeitung nach dem Absenden erlaubt.
submission_abandonedEin Entwurf einer Einreichung war länger als das konfigurierte Fenster inaktiv. Erfordert die Nachverfolgung teilweiser Einreichungen (Pro).
request_completedEine empfangende Person schließt einen Request auf dem Formular ab. Trägt den Request-Block und die Antworten.
request_expiredEin Request auf dem Formular läuft ab, bevor die empfangende Person ihn abschließt. Nur der Request-Block.
request_canceledEin 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 Einreichung

  • submission.updated — eine bestehende Einreichung wurde bearbeitet

  • submission.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

POST body
json
{
  "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üsselWas es ist
idDie Event-ID. Wiederholungen verwenden sie erneut — dedupliziere danach.
typeEiner der sechs oben genannten Ereignistypen.
createdAtWann das Ereignis eingereiht wurde, nicht wann dieser Zustellversuch stattfand. Bleibt über Wiederholungen hinweg stabil.
apiVersionDer 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.
testtrue bei einer Beispiel- oder Testzustellung sowie bei einem im Testmodus erstellten Request. Immer vorhanden.
data.form.snapshotIdDie veröffentlichte Version, die die ausfüllende Person beantwortet hat. Feld-Schlüssel, Titel und Typen sind pro Snapshot fest.
data.submission.updatedAtWann die ausfüllende Person die Einreichung zuletzt bearbeitet hat, oder null bis zur ersten Bearbeitung.
data.submission.editCountWie oft die ausfüllende Person die Einreichung nach dem Absenden bearbeitet hat: 0 bei submission.completed, 1 bei der ersten Bearbeitung.
data.answersJede Antwort, nach Feld-Schlüssel. Jede Antwort erscheint einmal.
data.displayFür Menschen lesbarer Text für jede Antwort, unter denselben Schlüsseln.
data.schemaOptional: 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:

data.answers
json
{
  "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:

data.schema
json
[
  { "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.

Zu data hinzugefügt
json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
  • id — der Request, den diese Einreichung beantwortet hat. Übergib ihn an requests.get für das vollständige Bild.

  • externalId und metadata — deine eigene Buchführung, genau so, wie du sie bei requests.create angegeben 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ätsfensterBeschreibung
12 StundenFür Nachverfolgungen am selben Tag
1 TagStandard — ein angemessener Abstand vor einer Erinnerung
3 TageFür weniger dringende Formulare
1 WocheFü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 answers und display.

  • 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.

verify.ts
ts
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
}
verify.py
python
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_seconds

formbase 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.

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:

bash
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000

Lokale 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.

Nächste Schritte