Anfragen
Callbacks & Signierung
Wenn ein Request sein Ende erreicht — abgeschlossen, abgelaufen oder storniert — sendet formbase eine signierte Benachrichtigung per POST an die URL, die deine Automation angegeben hat. Dieser Aufruf setzt den Lauf fort.
Was feuert, und wann
| Ereignis | Wann |
|---|---|
| request.completed | Die empfangende Person hat abgesendet. Trägt die Antworten. |
| request.expired | Der Ablauf ist eingetreten, während der Request noch ausstehend war. |
| request.canceled | Du oder deine Automation habt ihn zurückgezogen. |
Alle drei kommen an derselben URL an, also verzweige nach type, 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.
Was ankommt
{
"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" }
}
}data.requestist immer da — einschließlich deinerexternalId,metadataundcontext, unverändert. Es trägt den Zeitstempel des jeweiligen Endes (completedAt,expiredAtodercanceledAtmit optionalemcancelReason).outcomeist nur vorhanden, wenn die empfangende Person eine Entscheidungsfrage beantwortet hat —approve,declineoderchanges.form,submission,answersunddisplayerscheinen nur beim Abschluss, genau in der Form, die auch ein Submission-Webhook trägt.form.snapshotIdist die genaue veröffentlichte Version, die die empfangende Person beantwortet hat;submission.pdfUrlist nur dann eine URL, wenn das Formular ein Einreichungs-PDF aufbewahrt, sonst null.testisttrue, wenn der Request im Testmodus erstellt wurde — verzweige danach, oder verwirf das Ereignis.answersist nach Feldschlüssel keyed, wobei Wiederholungsgruppen als ein Objekt pro Instanz verschachtelt sind. Eine Auswahlantwort ist der Options-key ausfields.list, nicht sein Label; das Label steht indisplay, unter demselben Schlüssel.Der POST kommt mit
Content-Type: application/jsonundUser-Agent: formbasean und trägtX-formbase-Event-Id,X-formbase-Event-TypeundX-formbase-Signature— sodass du deduplizieren und routen kannst, bevor du parst.
Nach id deduplizieren
id 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.
Die Signatur prüfen
Jeder Callback trägt einen Signatur-Header, X-formbase-Signature: t={unix seconds},sha256={hex}. Der
Hex-Wert ist ein HMAC-SHA256 aus dem Zeitstempel, einem Punkt und dem rohen Request-Body, berechnet mit dem
Request-Signaturgeheimnis deines Workspace.
Zwei Regeln, egal welche Sprache du verwendest:
Hashe den rohen Body, vor jedem Parsen oder erneuten Serialisieren. Neu kodiertes JSON sind nicht dieselben Bytes.
Vergleiche in konstanter Zeit —
crypto.timingSafeEqual,hmac.compare_digest— niemals mit==.
import crypto from 'node:crypto'
export function verifyFormbaseCallback(rawBody, header, secret, toleranceSeconds = 300) {
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_formbase_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_secondsDas Request-Signaturgeheimnis
Ein Geheimnis pro Workspace signiert jeden Callback aus ihm. Du findest es unter OAuth und API-Schlüssel in der
Workspace-Seitenleiste, in der Karte Request-Signaturgeheimnis. 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
callbackUrl erstellt hat, hat also noch keines.
Neugenerieren kennt keine Übergangsfrist
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. Aktualisiere zuerst deinen Empfänger, dann generiere neu. Es gibt kein Zeitfenster, in dem beide Geheimnisse akzeptiert werden.
Wiederholungen
Ein Callback bekommt acht Versuche: den ersten, dann sieben Wiederholungen mit mindestens 1, 2, 4, 8, 16, 32 und 60
Minuten Abstand. formbase 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 id: 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.
| Deine Antwort | Was formbase tut |
|---|---|
| 2xx | Fertig. Der Callback wird als zugestellt markiert. |
| 408, 429, 5xx | Wiederholt, unter Beachtung von Retry-After, falls du eines sendest. |
| Andere 4xx | Stoppt. Dein Endpunkt hat den Aufruf abgelehnt; denselben Body erneut zu senden kann nicht helfen. |
| Timeout oder Verbindungsfehler | Wiederholt nach demselben Zeitplan. |
| Blockierte URL | Stoppt sofort. Ein Host, der sich nicht auflösen lässt, eine private Adresse oder eine Nicht-HTTPS-URL können nicht erlaubt werden. Weiterleitungen werden nie verfolgt, daher stoppt auch ein 3xx. |
Ist das Budget aufgebraucht — dein Empfänger war den Nachmittag über offline — ist der Callback nicht verloren. Der Request bekommt ein
Callback fehlgeschlagen-Badge, dem Workspace-Owner wird einmalig eine E-Mail mit Host, Grund und Versuchszahl geschickt,
und die Antworten bleiben über requests.get lesbar. Um ihn erneut anzustoßen, öffne den Request auf der
Requests-Seite und drücke Replay, oder rufe
requests.replayCallback auf. Es sendet dasselbe eingefrorene Payload mit derselben id erneut — genau das, was
ein deduplizierender Empfänger braucht.
Abonnements hören dieselben Ereignisse
Eine Callback-URL gehört zu einem Request. Wenn jeder Request auf einem Formular denselben Empfänger erreichen soll, abonniere stattdessen
einmal: Die formbase-Apps für Zapier und n8n erledigen das für dich, und webhooks.create macht es per Code mit
request_completed, request_expired oder request_canceled 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. Replay sendet nur den Callback erneut; ein Abonnement wiederholt eigenständig und pausiert nach fünf
fehlgeschlagenen Versuchen.
Eine Resume-URL ist keine Authentifizierung
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.