# Webhook API-referentie

Payload-schema, gebeurtenistypen, ondertekening, nieuwe pogingen en REST-abonnement-endpoints.

## Webhook API-referentie

Payload-schema, gebeurtenistypen, ondertekening, retrygedrag en REST-abonnement-endpoints voor Zapier en Make.

> ℹ️ **Op zoek naar de installatiegids?**
> <p>
>     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 <a href="/nl/integrations/webhooks">Webhooks</a>.
>   </p>

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

<h3 id="headers">Headers</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — aanwezig wanneer een ondertekeningsgeheim is
    ingesteld (zie hieronder)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> en <code>X-Formstep-Event-Type</code> — dezelfde waarden als <code>id</code> en <code>type</code> in de
    body, zodat je kunt dedupliceren en routeren vóór het parsen. Een <a href="/nl/requests/callbacks">aanvraagcallback</a> stuurt dezelfde
    twee.
  </li>
  <li>
    Eventuele aangepaste headers die je tijdens het instellen toevoegt. Ze worden meegestuurd zoals opgegeven, behalve{' '}
    <code>Content-Type</code>, die niet kan worden overschreven. Native abonnementen die via <code>webhooks.create</code> zijn aangemaakt,
    hebben geen aangepaste headers.
  </li>
</ul>

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

<h3 id="subscription-events">Abonnementsgebeurtenissen</h3>
<p>Wanneer je een webhook-integratie instelt, kies je welke gebeurtenis leveringen triggert:</p>

<p>
  De drie <code>request_*</code>-events worden geabonneerd via <code>webhooks.create</code> en zijn waar de Formstep-apps voor Zapier, Make
  en n8n naar luisteren. Elk levert dezelfde envelop die een <a href="/nl/requests/callbacks">aanvraagcallback</a> stuurt, ondertekend met
  het eigen geheim van het abonnement. Testaanvragen bereiken nooit een abonnement, en <code>requests.replayCallback</code> stuurt alleen de
  callback opnieuw. Eén abonnement, één gebeurtenis: <code>submission_created</code> is verkeer via de openbare link en{' '}
  <code>request_completed</code> is aanvraagverkeer, dus een voltooide aanvraag vuurt nooit <code>submission_created</code> af en een
  formulier met beide abonnementen ontvangt één levering per voltooiing. Een bewerking bereikt alleen een <code>submission_updated</code>
  -abonnement, nooit een <code>submission_created</code>-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 <code>type</code>.
</p>

<h3 id="payload-event-types">Payload-gebeurtenistypen</h3>
<p>
  Het veld <code>type</code> in de JSON-body vertelt je wat er is gebeurd:
</p>
<ul>
  <li>
    <code>submission.completed</code> — een nieuwe voltooide inzending
  </li>
  <li>
    <code>submission.updated</code> — een bestaande inzending is bewerkt
  </li>
  <li>
    <code>submission.abandoned</code> — een conceptinzending is verlaten na het ingestelde inactiviteitsvenster
  </li>
</ul>
<p>
  Een <a href="/nl/requests/callbacks">aanvraagcallback</a> en een <code>request_*</code>-abonnement gebruiken dezelfde envelop met{' '}
  <code>request.completed</code>, <code>request.expired</code> en <code>request.canceled</code>, zodat één parser alle zes leest.
</p>

<h2 id="payload">Payloadstructuur</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 en display</h3>
<p>
  <code>answers</code> is het platte <code>{'{ field key: value }'}</code>-object, met als sleutels de veldsleutels die zijn vastgelegd bij
  publicatie. Lees dit wanneer een workflow vertakt of een waarde opslaat: <code>answers.email</code>, zonder een array te doorlopen. Een
  keuze-antwoord is de <strong>sleutel</strong> van de gekozen optie — de <code>key</code> die <code>fields.list</code> 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 <code>null</code>.
</p>
<p>
  <code>display</code> 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.
</p>
<p>
  Een herhalende groep verschijnt één keer in <code>answers</code>, onder de eigen veldsleutel van de groep, als een array van rij-objecten
  met als sleutel de veldsleutel van elk lid — hierboven <code>answers.attendees[0].attendee_name</code> — en in <code>display</code> als
  één regel met de rijen samengevoegd. Een lid wordt nooit naar het hoogste niveau getild.{' '}
  <a href="/nl/building-forms/calculated-fields">Berekende velden</a> verschijnen in beide kaarten onder de naam van het berekende veld als
  sleutel (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Boekingen en betalingen</h3>
<p>
  Een vraag Afspraak inplannen en een Betalingsveld bevatten elk een object in <code>answers</code>, onder de veldsleutel van de vraag, en
  één regel tekst in <code>display</code>. De tijden zijn ISO-tijdstippen, zodat een spreadsheet of een workflow ze kan verwerken, ongeacht
  in welke taal de respondent heeft geantwoord:
</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>
  De <code>status</code> van een boeking is <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code>{' '}
  of <code>no_show</code>. Die van een betaling is <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> of{' '}
  <code>disputed</code>, en <code>amount</code> staat in de hoofdeenheid van de valuta: <code>40</code> is $40,00. Wanneer Cal.com na de
  inzending een boeking verzet of Stripe een betaling terugbetaalt, werkt Formstep het object bij, zodat <code>submissions.list</code> en
  latere gebeurtenissen de huidige status tonen; voor de wijziging wordt geen nieuwe gebeurtenis verstuurd.
</p>
<p>
  Vóór <code>apiVersion</code> <code>2026-09-24</code> werd een boeking als één zin in <code>answers</code> verstuurd en een betaling
  helemaal niet.
</p>

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

<h3 id="schema">Veldtitels en -typen</h3>
<p>
  Het event herhaalt de titel en het type van elk veld niet. Lees die uit <code>fields.list</code>, wat stabiel is per{' '}
  <code>data.form.snapshotId</code>, zodat je de veldenlijst kunt cachen en alleen opnieuw ophaalt wanneer het snapshot-id verandert. Een
  ontvanger die geen tweede aanroep kan doen, kan <strong>Stuur de veldenlijst mee met elk event</strong> aanzetten in de
  webhookinstellingen; het event draagt dan <code>data.schema</code>, één item per veld. Een keuzevraag somt zijn <code>options</code> op en
  een matrix zijn <code>rows</code> en <code>columns</code>, elk als <code>{'{ key, label }'}</code>, zodat de sleutels in{' '}
  <code>answers</code> zonder tweede aanroep naar labels vertalen:
</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">Het aanvraagblok</h3>
<p>
  Bij de <a href="/nl/integrations/webhooks">aangepaste webhook</a> die in de formulierinstellingen is ingesteld, draagt een inzending die
  een <a href="/nl/requests/overview">aanvraag</a> beantwoordde één extra object binnen <code>data</code>, <code>request</code>. 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 <code>request.completed</code>, dat het volledige
  aanvraagblok draagt.
</p>

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

<ul>
  <li>
    <code>id</code> — de aanvraag die deze inzending beantwoordde. Geef die mee aan <code>requests.get</code> voor het volledige beeld.
  </li>
  <li>
    <code>externalId</code> en <code>metadata</code> — je eigen administratie, precies zoals je die hebt opgegeven bij{' '}
    <code>requests.create</code>. Elk is alleen aanwezig als het is ingesteld.
  </li>
</ul>

> ℹ️ **Webhooks zijn geen callbacks**
> <p>
>     Een inzendingswebhook vuurt af bij een inzending; het aanvraagblok noemt alleen de aanvraag die hij beantwoordde. Een{' '}
>     <a href="/nl/requests/callbacks">callback</a> vuurt af wanneer een aanvraag eindigt — voltooid, verlopen of geannuleerd — en draagt{' '}
>     <code>context</code> en <code>outcome</code>. 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 <code>request_completed</code>,{' '}
>     <code>request_expired</code> of <code>request_canceled</code> via <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">PDF van de inzending</h3>
<p>
  <code>data.submission.pdfUrl</code> 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 <code>null</code>{' '}
  wanneer de inzending geen PDF heeft.
</p>

<h3 id="submission-language">Taal van de inzending</h3>
<p>
  <code>data.submission.language</code> is de BCP-47-code van de taal waarin de respondent heeft ingediend (voor vertaalde formulieren). De
  waarde is <code>null</code> 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.
</p>

<h2 id="abandoned-submissions">Payloads voor verlaten inzendingen</h2>
<p>
  Wanneer je je abonneert op <code>submission_abandoned</code>, controleert Formstep elk uur op inactieve concepten. Als een concept langer
  inactief is dan het geconfigureerde venster, verstuurt Formstep een levering.
</p>
<p>
  Native API-integraties moeten <code>idleWindow</code> meesturen aan <code>webhooks.create</code>. Toegestane waarden zijn <code>12h</code>{' '}
  en <code>1d</code>, maar ook <code>3d</code> en <code>1w</code>. Er is geen impliciete standaardwaarde; een abandoned-abonnement zonder
  waarde wordt geweigerd.
</p>

<p>De payloadstructuur is identiek aan een voltooide inzending. Twee verschillen:</p>
<ul>
  <li>
    <strong>Answers kunnen schaars zijn</strong> — alleen vragen die de respondent heeft beantwoord, verschijnen in <code>answers</code> en{' '}
    <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — valt terug op de verzendingtijdstempel, omdat de respondent nooit formeel heeft ingediend.
  </li>
</ul>
<p>
  Elke integratie wordt maximaal één keer per verlaten concept geactiveerd. Na levering wordt het concept uitgesloten van toekomstige
  controles.
</p>

<h2 id="signing">Ondertekening</h2>
<p>
  Elke webhook heeft zijn eigen ondertekeningsgeheim — ingesteld in de UI wanneer je een aangepaste webhook configureert, of met de
  optionele parameter <code>signingSecret</code> (32–255 tekens) op <code>webhooks.create</code>. Dit is niet het
  workspace-aanvraagondertekeningsgeheim dat wordt gebruikt voor <a href="/nl/requests/callbacks">aanvraagcallbacks</a>, maar de header en
  het algoritme zijn identiek, dus één verificatiefunctie verwerkt beide.
</p>
<p>
  Elke ondertekende levering draagt <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. De samenvatting is een
  HMAC-SHA256 van <code>&#123;t&#125;.&#123;raw body&#125;</code>, met jouw geheim als sleutel. Twee regels: hash de <strong>ruwe</strong>{' '}
  body vóór enige parsing of herserialisatie, en vergelijk in constante tijd.
</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 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.
</p>

> ⚠️ **Verifieer altijd in productie**
> <p>Zonder verificatie kan iedereen die jouw URL ontdekt nep-inzendingen versturen.</p>

<h2 id="retries">Nieuwe pogingen</h2>
<p>
  Formstep doet maximaal 5 pogingen per levering — 1 initieel en 4 nieuwe pogingen — met minstens 1, 2, 4 en 8 minuten ertussen. Formstep
  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 <code>Retry-After</code>-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:
</p>
<ul>
  <li>Een niet-2xx-status retourneert</li>
  <li>Een time-out heeft</li>
  <li>De verbinding reset</li>
</ul>
<p>
  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.
</p>
<p>
  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.
</p>
<p>
  Opnieuw geprobeerde leveringen van hetzelfde event hergebruiken dezelfde <code>id</code>, dus dedupliceer door verwerkte ids op te slaan.
  Een echt nieuw event — bijvoorbeeld een respondent die zijn inzending bewerkt — komt binnen met een nieuwe <code>id</code> en{' '}
  <code>type: "submission.updated"</code>: bij de webhook in Formulierinstellingen, of bij een <code>submission_updated</code>-abonnement.
  De <code>createdAt</code> 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 <code>data.submission.editCount</code>: die loopt op bij elke bewerking, en{' '}
  <code>data.submission.updatedAt</code> geeft aan wanneer de laatste heeft plaatsgevonden.
</p>

<h2 id="testing">Testen</h2>
<p>
  Zowel het installatievenster als het detailvenster van de integratie hebben een knop <strong>Stuur test</strong>. 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 <code>submissions.sample</code> en <code>requests.sample</code>.
</p>
<p>Voor lokale ontwikkeling stel je jouw dev-server beschikbaar via een tunnel:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

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

> 💡 **Lokale ontwikkeling**
> <p>Gebruik de tunnel-URL als jouw webhook-endpoint en klik vervolgens op Stuur test om end-to-end te verifiëren.</p>

<h2 id="next-steps">Volgende stappen</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhooks instellen](/nl/integrations/webhooks) — Configureer webhooks voor jouw formulier
  - [Abonnementen & prijzen](/nl/subscription-billing/plans-pricing) — Vergelijk API-functies en limieten per abonnement
</div>
