# Webhooks-Referenz

Payload-Schema, Ereignistypen, Signierung, Wiederholungsversuche und REST-Abonnement-Endpunkte.

## Webhooks-Referenz

Payload-Schema, Ereignistypen, Signierung, Wiederholungsverhalten und REST-Abonnement-Endpunkte für Zapier und Make.

> ℹ️ **Suchst du die Einrichtungsanleitung?**
> <p>
>     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 <a href="/de/integrations/webhooks">Individuelle Webhooks</a>.
>   </p>

<h2 id="request">Anfrage</h2>
<p>
  <code>POST &lt;your-url&gt;</code> mit <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">Header</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — vorhanden, wenn ein Signing-Secret eingerichtet ist
    (siehe unten)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> und <code>X-Formstep-Event-Type</code> — dieselben Werte wie <code>id</code> und <code>type</code> im
    Body, sodass du deduplizieren und routen kannst, bevor du parst. Ein <a href="/de/requests/callbacks">Request-Callback</a> sendet
    dieselben zwei.
  </li>
  <li>
    Alle benutzerdefinierten Header, die du bei der Einrichtung hinzufügst. Sie werden unverändert übernommen, außer{' '}
    <code>Content-Type</code>, das sich nicht überschreiben lässt. Native Abonnements, die über <code>webhooks.create</code> erstellt
    wurden, haben keine benutzerdefinierten Header.
  </li>
</ul>

<h2 id="event-types">Ereignistypen</h2>

<h3 id="subscription-events">Abonnement-Ereignisse</h3>
<p>Wenn du eine Webhook-Integration einrichtest, wählst du, welches Ereignis Zustellungen auslöst:</p>

<p>
  Die drei <code>request_*</code>-Ereignisse werden über <code>webhooks.create</code> abonniert und sind das, worauf die Formstep-Apps für
  Zapier, Make und n8n hören. Jedes liefert denselben Umschlag, den ein <a href="/de/requests/callbacks">Request-Callback</a> sendet,
  signiert mit dem eigenen Geheimnis des Abonnements. Testanfragen erreichen nie ein Abonnement, und <code>requests.replayCallback</code>{' '}
  sendet nur den Callback erneut. Ein Abonnement, ein Ereignis: <code>submission_created</code> ist Traffic über den öffentlichen Link, und{' '}
  <code>request_completed</code> ist Request-Traffic, ein abgeschlossener Request löst <code>submission_created</code> also nie aus, und ein
  Formular mit beiden Abonnements erhält eine Zustellung pro Abschluss. Eine Bearbeitung erreicht ausschließlich ein{' '}
  <code>submission_updated</code>-Abonnement, nie ein <code>submission_created</code>-Abonnement. Der benutzerdefinierte Webhook, der in den
  Formulareinstellungen konfiguriert ist, hat keine Ereigniswahl: Er empfängt erste Einreichungen und Bearbeitungen gleichermaßen,
  unterschieden durch <code>type</code>.
</p>

<h3 id="payload-event-types">Payload-Ereignistypen</h3>
<p>
  Das Feld <code>type</code> im JSON-Body zeigt dir an, was passiert ist:
</p>
<ul>
  <li>
    <code>submission.completed</code> — eine neue abgeschlossene Einreichung
  </li>
  <li>
    <code>submission.updated</code> — eine bestehende Einreichung wurde bearbeitet
  </li>
  <li>
    <code>submission.abandoned</code> — eine Entwurfseinreichung wurde nach dem konfigurierten Leerlauffenster abgebrochen
  </li>
</ul>
<p>
  Ein <a href="/de/requests/callbacks">Request-Callback</a> und ein <code>request_*</code>-Abonnement verwenden denselben Umschlag mit{' '}
  <code>request.completed</code>, <code>request.expired</code> und <code>request.canceled</code>, sodass ein Parser alle sechs liest.
</p>

<h2 id="payload">Payload-Struktur</h2>

```
{
  "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"
    }
  }
}
```

<h3 id="fields-vs-answers">answers und display</h3>
<p>
  <code>answers</code> ist das flache <code>{'{ field key: value }'}</code>-Objekt, dessen Schlüssel die bei der Veröffentlichung fixierten
  Feld-Schlüssel sind. Lies es, wenn ein Workflow verzweigt oder einen Wert speichert: <code>answers.email</code>, kein Array zu
  durchlaufen. Eine Auswahlantwort ist der <strong>Schlüssel</strong> der gewählten Option — der <code>key</code>, den{' '}
  <code>fields.list</code> 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 <code>null</code> gesendet.
</p>
<p>
  <code>display</code> 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.
</p>
<p>
  Eine Wiederholungsgruppe erscheint in <code>answers</code> 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 <code>answers.attendees[0].attendee_name</code> —
  und in <code>display</code> als eine Zeile mit verbundenen Zeilen. Ein Mitglied wird nie auf die oberste Ebene gehoben.{' '}
  <a href="/de/building-forms/calculated-fields">Berechnete Felder</a> erscheinen in beiden Maps, unter dem Namen des berechneten Felds als
  Schlüssel (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Buchungen und Zahlungen</h3>
<p>
  Eine „Termin planen"-Frage und ein Zahlungsfeld tragen jeweils ein Objekt in <code>answers</code>, unter dem Feld-Schlüssel der Frage, und
  eine Textzeile in <code>display</code>. Die Zeiten sind ISO-Zeitpunkte, sodass eine Tabellenkalkulation oder ein Workflow sie auswerten
  kann, egal in welcher Sprache die ausfüllende Person geantwortet hat:
</p>

```
{
  "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_..."
  }
}
```

<p>
  Der <code>status</code> einer Buchung ist <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code>{' '}
  oder <code>no_show</code>. Der einer Zahlung ist <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> oder{' '}
  <code>disputed</code>, und <code>amount</code> steht in der Haupteinheit der Währung: <code>40</code> sind $40.00. Verschiebt Cal.com nach
  dem Absenden eine Buchung oder erstattet Stripe eine Zahlung, aktualisiert Formstep das Objekt, sodass <code>submissions.list</code> und
  spätere Ereignisse den aktuellen Stand zeigen; für die Änderung wird kein neues Ereignis gesendet.
</p>
<p>
  Vor <code>apiVersion</code> <code>2026-09-24</code> wurde eine Buchung als ein einziger Satz in <code>answers</code> gesendet und eine
  Zahlung gar nicht.
</p>

<p>
  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 <code>answers</code> und <code>display</code> 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.
</p>

<h3 id="schema">Feldtitel und -typen</h3>
<p>
  Das Event wiederholt Titel und Typ jedes Felds nicht. Lies sie aus <code>fields.list</code>, das pro <code>data.form.snapshotId</code>{' '}
  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 <strong>Die Feldliste mit jedem Event senden</strong> in den Webhook-Einstellungen aktivieren; das Event trägt
  dann <code>data.schema</code>, einen Eintrag pro Feld. Eine Auswahlfrage listet ihre <code>options</code> auf und eine Matrix ihre{' '}
  <code>rows</code> und <code>columns</code>, jeweils als <code>{'{ key, label }'}</code>, sodass sich die Schlüssel in <code>answers</code>{' '}
  ohne einen zweiten Aufruf zu Labels auflösen lassen:
</p>

```
[
  { "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" }
]
```

<h3 id="request-block">Der Request-Block</h3>
<p>
  Beim <a href="/de/integrations/webhooks">benutzerdefinierten Webhook</a>, der in den Formulareinstellungen konfiguriert ist, trägt eine
  Einreichung, die einen <a href="/de/requests/overview">Request</a> beantwortet hat, ein zusätzliches Objekt innerhalb von{' '}
  <code>data</code>, <code>request</code>. 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 <code>request.completed</code>, das den vollständigen Request-Block trägt.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — der Request, den diese Einreichung beantwortet hat. Übergib ihn an <code>requests.get</code> für das vollständige
    Bild.
  </li>
  <li>
    <code>externalId</code> und <code>metadata</code> — deine eigene Buchführung, genau so, wie du sie bei <code>requests.create</code>{' '}
    angegeben hast. Jedes erscheint nur, wenn es gesetzt wurde.
  </li>
</ul>

> ℹ️ **Webhooks sind keine Callbacks**
> <p>
>     Ein Einreichungs-Webhook feuert bei einer Einreichung; der Request-Block nennt nur den Request, den sie beantwortet hat. Ein{' '}
>     <a href="/de/requests/callbacks">Callback</a> feuert, wenn ein Request endet — abgeschlossen, abgelaufen oder storniert —, und trägt{' '}
>     <code>context</code> und <code>outcome</code>. 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 <code>request_completed</code>,{' '}
>     <code>request_expired</code> oder <code>request_canceled</code> über <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">Einreichungs-PDF</h3>
<p>
  <code>data.submission.pdfUrl</code> 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{' '}
  <code>null</code>, wenn die Einreichung kein PDF hat.
</p>

<h3 id="submission-language">Sprache der Einreichung</h3>
<p>
  <code>data.submission.language</code> 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 <code>null</code>. Verwende dieses Feld, um nach der Sprache der
  ausfüllenden Person zu verzweigen oder weiterzuleiten, ohne eine separate Abfrage durchzuführen.
</p>

<h2 id="abandoned-submissions">Payloads für abgebrochene Einreichungen</h2>
<p>
  Wenn du <code>submission_abandoned</code> abonnierst, prüft Formstep stündlich auf inaktive Entwürfe. Wenn ein Entwurf länger als das
  konfigurierte Inaktivitätsfenster nicht bearbeitet wurde, löst Formstep eine Zustellung aus.
</p>
<p>
  Native API-Integrationen müssen <code>idleWindow</code> an <code>webhooks.create</code> senden. Zulässige Werte sind <code>12h</code>,{' '}
  <code>1d</code>, <code>3d</code> und <code>1w</code>. Es gibt keinen impliziten Standard; ein Abonnement für abgebrochene Einreichungen
  ohne Wert wird abgelehnt.
</p>

<p>Die Payload-Struktur ist identisch mit einer abgeschlossenen Einreichung. Zwei Unterschiede:</p>
<ul>
  <li>
    <strong>Antworten können spärlich sein</strong> — nur Fragen, die die ausfüllende Person beantwortet hat, erscheinen in{' '}
    <code>answers</code> und <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — fällt auf den Zustellungszeitstempel zurück, da der Ausfüller nie offiziell eingereicht hat.
  </li>
</ul>
<p>
  Jede Integration wird höchstens einmal pro abgebrochenem Entwurf ausgelöst. Nach der Zustellung wird der Entwurf von zukünftigen Prüfungen
  ausgeschlossen.
</p>

<h2 id="signing">Signierung</h2>
<p>
  Jeder Webhook hat sein eigenes Signing-Secret — in der Oberfläche eingerichtet, wenn du einen benutzerdefinierten Webhook konfigurierst,
  oder über den optionalen Parameter <code>signingSecret</code> (32–255 Zeichen) bei <code>webhooks.create</code>. Es ist nicht dasselbe wie
  das Request-Signaturgeheimnis des Arbeitsbereichs, das für <a href="/de/requests/callbacks">Request-Callbacks</a> verwendet wird, aber
  Header und Algorithmus sind identisch, sodass ein einziger Verifizierer für beide funktioniert.
</p>
<p>
  Jede signierte Zustellung trägt <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. Der Digest ist ein
  HMAC-SHA256 von <code>&#123;t&#125;.&#123;raw body&#125;</code>, mit deinem Secret als Schlüssel. Zwei Regeln: hashe den{' '}
  <strong>rohen</strong> Body vor jeglichem Parsen oder erneuten Serialisieren, und vergleiche in konstanter Zeit.
</p>

```
import crypto from 'node:crypto'

  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_formstep_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
```

<p>
  Formstep 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.
</p>

> ⚠️ **In der Produktion immer verifizieren**
> <p>Ohne Verifizierung kann jeder, der deine URL entdeckt, gefälschte Einreichungen senden.</p>

<h2 id="retries">Wiederholungsversuche</h2>
<p>
  Formstep unternimmt bis zu 5 Versuche pro Zustellung — 1 erster Versuch und 4 Wiederholungen — mit mindestens 1, 2, 4 und 8 Minuten
  Abstand. Formstep 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 <code>Retry-After</code>-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:
</p>
<ul>
  <li>einen Nicht-2xx-Status zurückgibt</li>
  <li>eine Zeitüberschreitung hat</li>
  <li>die Verbindung zurücksetzt</li>
</ul>
<p>
  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.
</p>
<p>
  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.
</p>
<p>
  Wiederholte Zustellungen desselben Ereignisses verwenden dieselbe <code>id</code> — dedupliziere also, indem du verarbeitete IDs
  speicherst. Ein wirklich neues Ereignis — etwa wenn eine ausfüllende Person ihre Einreichung bearbeitet — kommt mit einer neuen{' '}
  <code>id</code> und <code>type: "submission.updated"</code> an: beim benutzerdefinierten Webhook oder bei einem{' '}
  <code>submission_updated</code>-Abonnement. Der <code>createdAt</code> 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{' '}
  <code>data.submission.editCount</code>: Er zählt mit jeder Bearbeitung hoch, und <code>data.submission.updatedAt</code> gibt an, wann die
  letzte stattgefunden hat.
</p>

<h2 id="testing">Testen</h2>
<p>
  Sowohl das Integrations-Einrichtungsfenster als auch das Detailpanel haben eine Schaltfläche <strong>Test senden</strong>. 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 <code>submissions.sample</code> und{' '}
  <code>requests.sample</code>.
</p>
<p>Für die lokale Entwicklung stelle deinen Dev-Server mit einem Tunnel bereit:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

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

> 💡 **Lokale Entwicklung**
> <p>
>     Verwende die Tunnel-URL als deinen Webhook-Endpunkt und klicke dann auf „Test senden", um die Verbindung von Anfang bis Ende zu
>     überprüfen.
>   </p>

<h2 id="next-steps">Nächste Schritte</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhooks einrichten](/de/integrations/webhooks) — Webhooks für dein Formular konfigurieren
  - [Pläne & Preise](/de/subscription-billing/plans-pricing) — Plan-API-Funktionen und Limits vergleichen
</div>
