# Callbacks & Signierung

Setze deinen Workflow fort, wenn ein Request endet, und beweise, dass der Aufruf wirklich von Formstep kommt.

## Callbacks & Signierung

Wenn ein Request sein Ende erreicht — abgeschlossen, abgelaufen oder storniert — sendet Formstep eine signierte Benachrichtigung per POST an die URL, die deine Automation angegeben hat. Dieser Aufruf setzt den Lauf fort.

<h2 id="what-fires">Was feuert, und wann</h2>

<p>
  Alle drei kommen an derselben URL an, also verzweige nach <code>type</code>, bevor du Antworten voraussetzt. Das ist der ganze Sinn davon,
  bei jedem Ende zu feuern: Ein bei einer Kundin geparkter Workflow wird fortgesetzt, egal ob sie geantwortet, dich ignoriert oder du den
  Request abgebrochen hast.
</p>

<h2 id="payload">Was ankommt</h2>

```
{
  "id": "evt_kj7...",
  "type": "request.completed",
  "createdAt": "2026-03-04T09:31:40.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "request": {
      "id": "kd7...",
      "externalId": "run-42",
      "status": "completed",
      "outcome": "approve",
      "language": "en",
      "recipient": { "email": "ada@acme.com", "name": "Ada" },
      "metadata": { "runId": "run-42" },
      "context": { "case_id": "CASE-9" },
      "createdAt": "2026-03-04T09:20:00.000Z",
      "completedAt": "2026-03-04T09:31:40.000Z"
    },
    "form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
    "submission": {
      "id": "jd7...",
      "respondentEmail": "ada@acme.com",
      "submittedAt": "2026-03-04T09:31:40.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
    "display": { "company_name": "Acme", "contacts": "Ada" }
  }
}
```

<ul>
  <li>
    <code>data.request</code> ist immer da — einschließlich deiner <code>externalId</code>, <code>metadata</code> und <code>context</code>,
    unverändert. Es trägt den Zeitstempel des jeweiligen Endes (<code>completedAt</code>, <code>expiredAt</code> oder{' '}
    <code>canceledAt</code> mit optionalem <code>cancelReason</code>).
  </li>
  <li>
    <code>outcome</code> ist nur vorhanden, wenn die empfangende Person eine{' '}
    <a href="/de/requests/decisions-and-approvals">Entscheidungsfrage</a> beantwortet hat — <code>approve</code>, <code>decline</code> oder{' '}
    <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> und <code>display</code> erscheinen nur beim Abschluss, genau in der
    Form, die auch ein <a href="/de/developers/webhooks-reference#payload">Submission-Webhook</a> trägt. <code>form.snapshotId</code> ist
    die genaue veröffentlichte Version, die die empfangende Person beantwortet hat; <code>submission.pdfUrl</code> ist nur dann eine URL,
    wenn das Formular ein Einreichungs-PDF aufbewahrt, sonst null.
  </li>
  <li>
    <code>test</code> ist <code>true</code>, wenn der Request im <a href="/de/requests/creating-requests#test-mode">Testmodus</a> erstellt
    wurde — verzweige danach, oder verwirf das Ereignis.
  </li>
  <li>
    <code>answers</code> ist nach <a href="/de/requests/field-keys">Feldschlüssel</a> keyed, wobei Wiederholungsgruppen als ein Objekt pro
    Instanz verschachtelt sind. Eine Auswahlantwort ist der Options-<strong>key</strong> aus <code>fields.list</code>, nicht sein Label; das
    Label steht in <code>display</code>, unter demselben Schlüssel.
  </li>
  <li>
    Der POST kommt mit <code>Content-Type: application/json</code> und <code>User-Agent: Formstep</code> an und trägt{' '}
    <code>X-Formstep-Event-Id</code>, <code>X-Formstep-Event-Type</code> und <code>X-Formstep-Signature</code> — sodass du deduplizieren und
    routen kannst, bevor du parst.
  </li>
</ul>

> ⚠️ **Nach id deduplizieren**
> <p>
>     <code>id</code> ist über jeden Retry und jede Wiederholung desselben Ereignisses hinweg stabil. Falls dein Empfänger bei derselben ID
>     zweimal handeln könnte — eine doppelte Rechnung, ein doppeltes Ticket — merke dir die IDs, die du bereits verarbeitet hast.
>   </p>

<h2 id="verify">Die Signatur prüfen</h2>

<p>
  Jeder Callback trägt einen Signatur-Header, <code>X-Formstep-Signature: t=&#123;unix seconds&#125;,sha256=&#123;hex&#125;</code>. Der
  Hex-Wert ist ein HMAC-SHA256 aus dem Zeitstempel, einem Punkt und dem rohen Request-Body, berechnet mit dem{' '}
  <strong>Request-Signaturgeheimnis</strong> deines Workspace.
</p>

<p>Zwei Regeln, egal welche Sprache du verwendest:</p>

<ol>
  <li>
    Hashe den <strong>rohen</strong> Body, vor jedem Parsen oder erneuten Serialisieren. Neu kodiertes JSON sind nicht dieselben Bytes.
  </li>
  <li>
    Vergleiche in konstanter Zeit — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — niemals mit <code>==</code>.
  </li>
</ol>

  
    
```
import crypto from 'node:crypto'

  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  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_callback(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
```

  

<h2 id="secret">Das Request-Signaturgeheimnis</h2>

<p>
  Ein Geheimnis pro Workspace signiert jeden Callback aus ihm. Du findest es unter <strong>OAuth und API-Schlüssel</strong> in der
  Workspace-Seitenleiste, in der Karte <strong>Request-Signaturgeheimnis</strong>. Es ist standardmäßig maskiert; der Augen-Button zeigt es
  an, der Kopieren-Button kopiert es. Es ist kein Einmalwert — du kannst jederzeit zurückkehren und es erneut auslesen. Das Geheimnis wird
  beim ersten Mal erzeugt, an dem es gebraucht wird — ein Workspace, der diese Karte nie geöffnet und nie einen Request mit{' '}
  <code>callbackUrl</code> erstellt hat, hat also noch keines.
</p>

> ❗ **Neugenerieren kennt keine Übergangsfrist**
> <p>
>     Nur der Workspace-Owner kann das Geheimnis neu generieren, und in dem Moment, in dem er das tut, funktioniert das alte nicht mehr — auch
>     nicht für Callbacks, die bereits wiederholt werden. <strong>Aktualisiere zuerst deinen Empfänger, dann generiere neu.</strong> Es gibt
>     kein Zeitfenster, in dem beide Geheimnisse akzeptiert werden.
>   </p>

<h2 id="retries">Wiederholungen</h2>

<p>
  Ein Callback bekommt <strong>acht Versuche</strong>: den ersten, dann sieben Wiederholungen mit mindestens 1, 2, 4, 8, 16, 32 und 60
  Minuten Abstand. Formstep sucht alle 30 Minuten nach fälligen Wiederholungen, sodass eine Wiederholung bis zu einer halben Stunde nach
  Ablauf ihres Abstands ankommen kann und der letzte Versuch etwa vier Stunden nach dem Ende des Requests erfolgt. Jeder Versuch trägt
  dieselben Bytes und dieselbe <code>id</code>: Das Payload ist in dem Moment eingefroren, in dem der Request endete, sodass ein Retry
  beschreibt, was damals passiert ist, nicht wie der Request jetzt aussieht. Die Ziel-URL und das Signaturgeheimnis werden bei jedem Versuch
  neu gelesen, nicht mit eingefroren.
</p>

<p>
  Ist das Budget aufgebraucht — dein Empfänger war den Nachmittag über offline — ist der Callback nicht verloren. Der Request bekommt ein{' '}
  <strong>Callback fehlgeschlagen</strong>-Badge, dem Workspace-Owner wird einmalig eine E-Mail mit Host, Grund und Versuchszahl geschickt,
  und die Antworten bleiben über <code>requests.get</code> lesbar. Um ihn erneut anzustoßen, öffne den Request auf der{' '}
  <a href="/de/requests/managing-requests">Requests-Seite</a> und drücke <strong>Replay</strong>, oder rufe{' '}
  <code>requests.replayCallback</code> auf. Es sendet dasselbe eingefrorene Payload mit derselben <code>id</code> erneut — genau das, was
  ein deduplizierender Empfänger braucht.
</p>

<h2 id="subscriptions">Abonnements hören dieselben Ereignisse</h2>

<p>
  Eine Callback-URL gehört zu einem Request. Wenn jeder Request auf einem Formular denselben Empfänger erreichen soll, abonniere stattdessen
  einmal: Die Formstep-Apps für Zapier und n8n erledigen das für dich, und <code>webhooks.create</code> macht es per Code mit{' '}
  <code>request_completed</code>, <code>request_expired</code> oder <code>request_canceled</code> als Ereignistyp. Ein Abonnement empfängt
  denselben Umschlag, signiert mit seinem eigenen Geheimnis statt mit dem Request-Signaturgeheimnis des Workspace, mit eigener Event-ID und
  eigenem Wiederholungsbudget. Ein Request mit sowohl einer Callback-URL als auch einem passenden Abonnement feuert zweimal, einmal an
  jeden. <strong>Replay</strong> sendet nur den Callback erneut; ein Abonnement wiederholt eigenständig und pausiert nach fünf
  fehlgeschlagenen Versuchen.
</p>

> 💡 **Eine Resume-URL ist keine Authentifizierung**
> <p>
>     Workflow-Tools geben dir eine schwer zu erratende Resume-URL, und es ist verlockend, das als Beweis zu behandeln. Sie ist ein
>     Bearer-Geheimnis — sie kann in Logs landen, und sie sagt dir nicht, dass der Body nicht manipuliert wurde. Verifiziere die Signatur auch
>     im fortgesetzten Zweig.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Fehlerbehebung](/de/requests/troubleshooting) — Wenn ein Callback immer wieder fehlschlägt.
  - [Webhook-Referenz](/de/developers/webhooks-reference) — Die answers- und display-Maps, die ein Abschluss trägt, vollständig.
</div>
