# Callbacks & ondertekening

Hervat je workflow zodra een aanvraag eindigt, en bewijs dat de aanroep echt van Formstep komt.

## Callbacks & ondertekening

Wanneer een aanvraag zijn einde bereikt — voltooid, verlopen, of geannuleerd — post Formstep een ondertekende melding naar de URL die je automatisering heeft opgegeven. Die aanroep is wat de run hervat.

<h2 id="what-fires">Wat vuurt af, en wanneer</h2>

<p>
  Alle drie komen aan op dezelfde URL, dus vertak op <code>type</code> voordat je aanneemt dat er antwoorden zijn. Dat is het hele punt van
  afvuren bij elk einde: een workflow die geparkeerd stond op een klant hervat, of ze nu geantwoord hebben, je genegeerd hebben, of
  afgeblazen zijn.
</p>

<h2 id="payload">Wat er binnenkomt</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> is er altijd — inclusief jouw <code>externalId</code>, <code>metadata</code>, en <code>context</code>,
    ongewijzigd. Hij draagt de tijdstempel van welk einde er ook gebeurde (<code>completedAt</code>, <code>expiredAt</code>, of{' '}
    <code>canceledAt</code> met een optionele <code>cancelReason</code>).
  </li>
  <li>
    <code>outcome</code> is alleen aanwezig wanneer de ontvanger een <a href="/nl/requests/decisions-and-approvals">beslissingsvraag</a>{' '}
    heeft beantwoord — <code>approve</code>, <code>decline</code>, of <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> en <code>display</code> verschijnen alleen bij voltooiing, in precies
    de vorm die een <a href="/nl/developers/webhooks-reference#payload">inzendingswebhook</a> draagt. <code>form.snapshotId</code> is de
    exacte gepubliceerde versie waarop de ontvanger antwoordde; <code>submission.pdfUrl</code> is alleen een URL wanneer het formulier een
    inzendings-PDF bewaart, en anders null.
  </li>
  <li>
    <code>test</code> is <code>true</code> wanneer de aanvraag is aangemaakt in{' '}
    <a href="/nl/requests/creating-requests#test-mode">testmodus</a> — vertak erop, of laat het event vallen.
  </li>
  <li>
    <code>answers</code> is gesorteerd op <a href="/nl/requests/field-keys">veldsleutel</a>, met herhaalgroepen genest als één object per
    instantie. Een keuzeantwoord is de <strong>sleutel</strong> van de optie uit <code>fields.list</code>, niet het label; het label staat
    in <code>display</code>, onder dezelfde sleutel.
  </li>
  <li>
    De POST komt binnen als <code>Content-Type: application/json</code> met <code>User-Agent: Formstep</code>, en draagt{' '}
    <code>X-Formstep-Event-Id</code>, <code>X-Formstep-Event-Type</code> en <code>X-Formstep-Signature</code> — zodat je kunt deduplicaren
    en routeren voordat je gaat parsen.
  </li>
</ul>

> ⚠️ **Dedupliceer op id**
> <p>
>     <code>id</code> is stabiel bij elke retry en elke herhaling van dezelfde gebeurtenis. Als je ontvanger mogelijk twee keer op dezelfde id
>     reageert — een dubbele factuur, een dubbel ticket — onthoud dan de id's die je al hebt verwerkt.
>   </p>

<h2 id="verify">De handtekening verifiëren</h2>

<p>
  Elke callback bevat een handtekeningheader, <code>X-Formstep-Signature: t=&#123;unix seconden&#125;,sha256=&#123;hex&#125;</code>. De hex
  is een HMAC-SHA256 van het tijdstempel, een punt, en de ruwe request-body, berekend met het <strong>request signing secret</strong> van je
  werkruimte.
</p>

<p>Twee regels, welke taal je ook gebruikt:</p>

<ol>
  <li>
    Hash de <strong>ruwe</strong> body, voordat er iets geparseerd of opnieuw geserialiseerd wordt. Opnieuw gecodeerde JSON is niet dezelfde
    bytes.
  </li>
  <li>
    Vergelijk in constante tijd — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — nooit met <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">Het request signing secret</h2>

<p>
  Eén geheim per werkruimte ondertekent elke callback ervan. Vind het bij <strong>OAuth en API-sleutels</strong> in de zijbalk van de
  werkruimte, op de kaart <strong>Request signing secret</strong>. Het is standaard verborgen; <strong>Geheim tonen</strong> toont het en de
  kopieerknop kopieert het. Het is geen eenmalige waarde — je kunt terugkomen en het opnieuw lezen. Het geheim wordt aangemaakt de eerste
  keer dat het nodig is, dus een werkruimte die die kaart nog nooit heeft geopend en nog nooit een aanvraag met een <code>callbackUrl</code>{' '}
  heeft aangemaakt, heeft er nog geen.
</p>

> ❗ **Opnieuw genereren heeft geen overgangsperiode**
> <p>
>     Alleen de eigenaar van de werkruimte kan het geheim opnieuw genereren, en zodra ze dat doen, stopt het oude geheim met werken —
>     inclusief voor callbacks die al opnieuw worden geprobeerd. <strong>Werk eerst je ontvanger bij, genereer daarna pas opnieuw.</strong> Er
>     is geen periode waarin beide geheimen worden geaccepteerd.
>   </p>

<h2 id="retries">Nieuwe pogingen</h2>

<p>
  Een callback krijgt <strong>acht pogingen</strong>: de eerste, dan zeven nieuwe pogingen met minstens 1, 2, 4, 8, 16, 32 en 60 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 tussenpoos komen, en de laatste poging komt zo'n vier uur na het einde van de aanvraag. Elke poging bevat dezelfde bytes en dezelfde{' '}
  <code>id</code>: de payload wordt bevroren op het moment dat de aanvraag eindigde, dus een nieuwe poging beschrijft wat er toen gebeurde,
  niet hoe de aanvraag er nu uitziet. De bestemmings-URL en het signing secret worden bij elke poging opnieuw gelezen, niet bevroren samen
  met de payload.
</p>

<p>
  Als het budget opraakt — je ontvanger lag de hele middag plat — is de callback niet verloren. De aanvraag krijgt een badge{' '}
  <strong>Callback mislukt</strong>, de eigenaar van de werkruimte krijgt eenmalig een e-mail met de host, de reden en het aantal pogingen,
  en de antwoorden blijven leesbaar via <code>requests.get</code>. Om hem opnieuw te versturen, open je de aanvraag op de{' '}
  <a href="/nl/requests/managing-requests">pagina Aanvragen</a> en druk je op <strong>Opnieuw afspelen</strong>, of roep je{' '}
  <code>requests.replayCallback</code> aan. Dat stuurt dezelfde bevroren payload opnieuw met dezelfde <code>id</code>, precies wat een
  ontvanger die dedupliceert nodig heeft.
</p>

<h2 id="subscriptions">Abonnementen ontvangen dezelfde gebeurtenissen</h2>

<p>
  Een callback-URL hoort bij één aanvraag. Wanneer elke aanvraag op een formulier dezelfde ontvanger moet bereiken, abonneer je je in plaats
  daarvan één keer: de Formstep-apps voor Zapier en n8n doen dit voor je, en <code>webhooks.create</code> doet het vanuit code met{' '}
  <code>request_completed</code>, <code>request_expired</code> of <code>request_canceled</code> als gebeurtenistype. Een abonnement ontvangt
  dezelfde envelop, ondertekend met zijn eigen geheim in plaats van het request signing secret van de werkruimte, met een eigen event-id en
  een eigen budget aan nieuwe pogingen. Een aanvraag met zowel een callback-URL als een bijpassend abonnement vuurt twee keer af, één keer
  naar elk. <strong>Opnieuw afspelen</strong> stuurt alleen de callback opnieuw; een abonnement probeert het zelf opnieuw en pauzeert na
  vijf mislukte pogingen.
</p>

> 💡 **Een resume-URL is geen authenticatie**
> <p>
>     Workflowtools geven je een moeilijk te raden resume-URL en het is verleidelijk dat als bewijs te behandelen. Het is een bearer-geheim —
>     het kan lekken in logs, en het vertelt je niet dat de body niet is aangepast. Verifieer de handtekening ook in de hervatte tak.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Problemen oplossen](/nl/requests/troubleshooting) — Wanneer een callback maar blijft mislukken.
  - [Webhook-referentie](/nl/developers/webhooks-reference) — De kaarten answers en display die een voltooiing bevat, volledig.
</div>
