Ontwikkelaars
Webhook API-referentie
Payload-schema, gebeurtenistypen, ondertekening, retrygedrag en REST-abonnement-endpoints voor Zapier en Make.
Op zoek naar de installatiegids?
Deze pagina documenteert het payload-contract en de REST-abonnement-API. Om een aangepaste webhook voor je formulier in te stellen via de UI, zie Webhooks.
Verzoek
POST <your-url> met Content-Type: application/json.
Headers
Content-Type: application/jsonX-formbase-Signature: t={timestamp},sha256={hex}— aanwezig wanneer een ondertekeningsgeheim is ingesteld (zie hieronder)X-formbase-Event-IdenX-formbase-Event-Type— dezelfde waarden alsidentypein de body, zodat je kunt dedupliceren en routeren vóór het parsen. Een aanvraagcallback stuurt dezelfde twee.Eventuele aangepaste headers die je tijdens het instellen toevoegt. Ze worden meegestuurd zoals opgegeven, behalve
Content-Type, die niet kan worden overschreven. Native abonnementen die viawebhooks.createzijn aangemaakt, hebben geen aangepaste headers.
Gebeurtenistypen
Abonnementsgebeurtenissen
Wanneer je een webhook-integratie instelt, kies je welke gebeurtenis leveringen triggert:
| Gebeurtenis | Wanneer het wordt geactiveerd |
|---|---|
| submission_created | Een respondent voltooit en verstuurt het formulier. Dit is de standaardinstelling. |
| submission_updated | Een respondent bewerkt een inzending die hij al heeft verstuurd, wanneer het formulier bewerken na verzenden toestaat. |
| submission_abandoned | Een conceptinzending is langer inactief dan het geconfigureerde venster. Vereist gedeeltelijke inzendingstracking (Pro). |
| request_completed | Een ontvanger voltooit een aanvraag op het formulier. Bevat het aanvraagblok en de antwoorden. |
| request_expired | Een aanvraag op het formulier verloopt voordat de ontvanger hem voltooit. Alleen het aanvraagblok. |
| request_canceled | Een aanvraag op het formulier wordt geannuleerd. Alleen het aanvraagblok. |
De drie request_*-events worden geabonneerd via webhooks.create en zijn waar de formbase-apps voor Zapier, Make
en n8n naar luisteren. Elk levert dezelfde envelop die een aanvraagcallback stuurt, ondertekend met
het eigen geheim van het abonnement. Testaanvragen bereiken nooit een abonnement, en requests.replayCallback stuurt alleen de
callback opnieuw. Eén abonnement, één gebeurtenis: submission_created is verkeer via de openbare link en
request_completed is aanvraagverkeer, dus een voltooide aanvraag vuurt nooit submission_created af en een
formulier met beide abonnementen ontvangt één levering per voltooiing. Een bewerking bereikt alleen een submission_updated
-abonnement, nooit een submission_created-abonnement. De webhook die je instelt bij Formulierinstellingen heeft geen keuze
voor de gebeurtenis: hij ontvangt eerste inzendingen en bewerkingen gelijk, van elkaar onderscheiden via type.
Payload-gebeurtenistypen
Het veld type in de JSON-body vertelt je wat er is gebeurd:
submission.completed— een nieuwe voltooide inzendingsubmission.updated— een bestaande inzending is bewerktsubmission.abandoned— een conceptinzending is verlaten na het ingestelde inactiviteitsvenster
Een aanvraagcallback en een request_*-abonnement gebruiken dezelfde envelop met
request.completed, request.expired en request.canceled, zodat één parser alle zes leest.
Payloadstructuur
{
"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"
}
}
}| Sleutel | Wat het is |
|---|---|
| id | Het event-id. Nieuwe pogingen hergebruiken dit — dedupliceer erop. |
| type | Een van de zes gebeurtenistypen hierboven. |
| createdAt | Wanneer het event in de wachtrij kwam, niet wanneer deze leveringspoging plaatsvond. Blijft gelijk bij nieuwe pogingen. |
| apiVersion | Het payload-contract, als datum. Verandert wanneer een sleutel wordt verwijderd, hernoemd of van betekenis verandert. Nieuwe sleutels komen erbij zonder wijziging. |
| test | true voor een voorbeeld- of testlevering, en voor een aanvraag die in testmodus is aangemaakt. Altijd aanwezig. |
| data.form.snapshotId | De gepubliceerde versie die de respondent heeft beantwoord. Veldsleutels, titels en typen liggen vast per snapshot. |
| data.submission.updatedAt | Wanneer de respondent de inzending voor het laatst heeft bewerkt, of null tot de eerste bewerking. |
| data.submission.editCount | Hoeveel keer de respondent de inzending heeft bewerkt na het versturen: 0 bij submission.completed, 1 bij de eerste bewerking. |
| data.answers | Elk antwoord, met veldsleutel als sleutel. Elk antwoord verschijnt één keer. |
| data.display | Leesbare tekst voor elk antwoord, onder dezelfde sleutels. |
| data.schema | Optioneel: de veldenlijst (key, title, type, group, en de optie- of rij- en kolomsleutels met hun labels), wanneer de webhook is ingesteld om die mee te sturen. |
answers en display
answers is het platte { field key: value }-object, met als sleutels de veldsleutels die zijn vastgelegd bij
publicatie. Lees dit wanneer een workflow vertakt of een waarde opslaat: answers.email, zonder een array te doorlopen. Een
keuze-antwoord is de sleutel van de gekozen optie — de key die fields.list voor die optie
vermeldt — dus het is hetzelfde ongeacht in welke taal de respondent heeft geantwoord. Een datum is een ISO-string, een getal een getal,
een multi-select een array van optiesleutels. Onbeantwoorde velden worden weggelaten, nooit verstuurd als null.
display bevat dezelfde sleutels met leesbare tekst: het optielabel in plaats van de sleutel, een geformatteerde datum, een
samengevoegde lijst. Lees dit wanneer iemand de waarde te zien krijgt — een Slack-bericht, een spreadsheetcel, een e-mail.
Een herhalende groep verschijnt één keer in answers, onder de eigen veldsleutel van de groep, als een array van rij-objecten
met als sleutel de veldsleutel van elk lid — hierboven answers.attendees[0].attendee_name — en in display als
één regel met de rijen samengevoegd. Een lid wordt nooit naar het hoogste niveau getild.
Berekende velden verschijnen in beide kaarten onder de naam van het berekende veld als
sleutel (answers.total).
Boekingen en betalingen
Een vraag Afspraak inplannen en een Betalingsveld bevatten elk een object in answers, onder de veldsleutel van de vraag, en
één regel tekst in display. De tijden zijn ISO-tijdstippen, zodat een spreadsheet of een workflow ze kan verwerken, ongeacht
in welke taal de respondent heeft geantwoord:
{
"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_..."
}
}De status van een boeking is confirmed, rescheduled, cancelled, rejected
of no_show. Die van een betaling is paid, partially_refunded, refunded of
disputed, en amount staat in de hoofdeenheid van de valuta: 40 is $40,00. Wanneer Cal.com na de
inzending een boeking verzet of Stripe een betaling terugbetaalt, werkt formbase het object bij, zodat submissions.list en
latere gebeurtenissen de huidige status tonen; voor de wijziging wordt geen nieuwe gebeurtenis verstuurd.
Vóór apiVersion 2026-09-24 werd een boeking als één zin in answers verstuurd en een betaling
helemaal niet.
De veldtoewijzing van de webhook geldt voor beide kaarten tegelijk: kies “geselecteerde” velden en de rest wordt weggelaten; hernoem de
kolom van een veld en de nieuwe naam is de sleutel ervan in zowel answers als display. Een veld dat het
formulier publiceerde voordat veldsleutels bestonden, gaat uit onder zijn element-id; publiceer het formulier opnieuw om het een leesbare
sleutel te geven.
Veldtitels en -typen
Het event herhaalt de titel en het type van elk veld niet. Lees die uit fields.list, wat stabiel is per
data.form.snapshotId, zodat je de veldenlijst kunt cachen en alleen opnieuw ophaalt wanneer het snapshot-id verandert. Een
ontvanger die geen tweede aanroep kan doen, kan Stuur de veldenlijst mee met elk event aanzetten in de
webhookinstellingen; het event draagt dan data.schema, één item per veld. Een keuzevraag somt zijn options op en
een matrix zijn rows en columns, elk als { key, label }, zodat de sleutels in
answers zonder tweede aanroep naar labels vertalen:
[
{ "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" }
]Het aanvraagblok
Bij de aangepaste webhook die in de formulierinstellingen is ingesteld, draagt een inzending die
een aanvraag beantwoordde één extra object binnen data, request. Het is
afwezig bij elke inzending via een openbare link, dus zo herkent die ontvanger de twee kanalen uit elkaar. Een Zapier-, Make- of
n8n-abonnement krijgt het nooit te zien: aanvraagverkeer bereikt een abonnement als request.completed, dat het volledige
aanvraagblok draagt.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— de aanvraag die deze inzending beantwoordde. Geef die mee aanrequests.getvoor het volledige beeld.externalIdenmetadata— je eigen administratie, precies zoals je die hebt opgegeven bijrequests.create. Elk is alleen aanwezig als het is ingesteld.
Webhooks zijn geen callbacks
Een inzendingswebhook vuurt af bij een inzending; het aanvraagblok noemt alleen de aanvraag die hij beantwoordde. Een
callback vuurt af wanneer een aanvraag eindigt — voltooid, verlopen of geannuleerd — en draagt
context en outcome. Verlopen en annuleren hebben geen inzending, dus daarvoor vuurt nooit een
inzendingswebhook af. Om het einde van een aanvraag te horen zonder callback-URL, abonneer je je op request_completed,
request_expired of request_canceled via webhooks.create.
PDF van de inzending
data.submission.pdfUrl is een link naar de PDF van de inzending. Een formulier met een aangepaste webhook of een Zapier-,
Make- of n8n-abonnement bewaart een PDF van elke inzending, dus de events ervan bevatten de link. De waarde is alleen null
wanneer de inzending geen PDF heeft.
Taal van de inzending
data.submission.language is de BCP-47-code van de taal waarin de respondent heeft ingediend (voor vertaalde formulieren). De
waarde is null voor formulieren met één taal. Gebruik dit veld om te routeren of te vertakken op basis van de taal van de
respondent, zonder een aparte lookup.
Payloads voor verlaten inzendingen
Wanneer je je abonneert op submission_abandoned, controleert formbase elk uur op inactieve concepten. Als een concept langer
inactief is dan het geconfigureerde venster, verstuurt formbase een levering.
Native API-integraties moeten idleWindow meesturen aan webhooks.create. Toegestane waarden zijn 12h
en 1d, maar ook 3d en 1w. Er is geen impliciete standaardwaarde; een abandoned-abonnement zonder
waarde wordt geweigerd.
| Inactief venster | Beschrijving |
|---|---|
| 12 uur | Voor opvolgingen op dezelfde dag |
| 1 dag | Een redelijk interval voordat je een herinnering stuurt |
| 3 dagen | Voor minder urgente formulieren |
| 1 week | Voor formulieren met lage frequentie |
De payloadstructuur is identiek aan een voltooide inzending. Twee verschillen:
Answers kunnen schaars zijn — alleen vragen die de respondent heeft beantwoord, verschijnen in
answersendisplay.submittedAt— valt terug op de verzendingtijdstempel, omdat de respondent nooit formeel heeft ingediend.
Elke integratie wordt maximaal één keer per verlaten concept geactiveerd. Na levering wordt het concept uitgesloten van toekomstige controles.
Ondertekening
Elke webhook heeft zijn eigen ondertekeningsgeheim — ingesteld in de UI wanneer je een aangepaste webhook configureert, of met de
optionele parameter signingSecret (32–255 tekens) op webhooks.create. Dit is niet het
workspace-aanvraagondertekeningsgeheim dat wordt gebruikt voor aanvraagcallbacks, maar de header en
het algoritme zijn identiek, dus één verificatiefunctie verwerkt beide.
Elke ondertekende levering draagt X-formbase-Signature: t={seconds},sha256={hex}. De samenvatting is een
HMAC-SHA256 van {t}.{raw body}, met jouw geheim als sleutel. Twee regels: hash de ruwe
body vóór enige parsing of herserialisatie, en vergelijk in constante tijd.
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 legt geen replayvenster op, dus de tolerantie hierboven kies je zelf. Het wijzigen van het geheim wordt direct van kracht bij de volgende poging, ook voor nieuwe pogingen die al onderweg zijn — werk eerst je ontvanger bij.
Verifieer altijd in productie
Zonder verificatie kan iedereen die jouw URL ontdekt nep-inzendingen versturen.
Nieuwe pogingen
formbase doet maximaal 5 pogingen per levering — 1 initieel en 4 nieuwe pogingen — met minstens 1, 2, 4 en 8 minuten ertussen. formbase
zoekt elke 30 minuten naar nieuwe pogingen die klaarstaan, dus een nieuwe poging kan tot een half uur na het einde van zijn backoff komen,
en de laatste poging zo’n twee uur na de eerste. Een Retry-After-header in jouw antwoord wordt gerespecteerd wanneer die om
een langere wachttijd vraagt dan de volgende backoffstap. Een levering telt als mislukt als jouw endpoint:
- Een niet-2xx-status retourneert
- Een time-out heeft
- De verbinding reset
Eén geval wordt nooit opnieuw geprobeerd: een bestemming die geblokkeerd of onoplosbaar is, of die naar een privéadres verwijst. De URL wordt opnieuw gevalideerd — inclusief DNS — vlak vóór elke poging, zodat een host die niet langer is toegestaan de levering direct laat mislukken in plaats van het budget op te maken.
Na 5 opeenvolgende mislukte leveringen wordt de integratie gepauzeerd. Los het endpoint op en schakel het opnieuw in via Formulierinstellingen → Integraties; een geslaagde levering zet de teller terug.
Opnieuw geprobeerde leveringen van hetzelfde event hergebruiken dezelfde id, dus dedupliceer door verwerkte ids op te slaan.
Een echt nieuw event — bijvoorbeeld een respondent die zijn inzending bewerkt — komt binnen met een nieuwe id en
type: “submission.updated”: bij de webhook in Formulierinstellingen, of bij een submission_updated-abonnement.
De createdAt is het moment waarop het event in de wachtrij kwam, niet het moment van de poging, dus die blijft ook bij nieuwe
pogingen hetzelfde. Om bewerkingen te onderscheiden, lees data.submission.editCount: die loopt op bij elke bewerking, en
data.submission.updatedAt geeft aan wanneer de laatste heeft plaatsgevonden.
Testen
Zowel het installatievenster als het detailvenster van de integratie hebben een knop Stuur test. Dit stuurt een voorbeeld
van het geabonneerde event naar jouw URL, zodat je de verbinding kunt verifiëren zonder te wachten op een echte inzending of aanvraag.
Dezelfde voorbeelden zijn beschikbaar via de API als submissions.sample en requests.sample.
Voor lokale ontwikkeling stel je jouw dev-server beschikbaar via een tunnel:
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Lokale ontwikkeling
Gebruik de tunnel-URL als jouw webhook-endpoint en klik vervolgens op Stuur test om end-to-end te verifiëren.