Aanvragen
Callbacks & ondertekening
Wanneer een aanvraag zijn einde bereikt — voltooid, verlopen, of geannuleerd — post formbase een ondertekende melding naar de URL die je automatisering heeft opgegeven. Die aanroep is wat de run hervat.
Wat vuurt af, en wanneer
| Gebeurtenis | Wanneer |
|---|---|
| request.completed | De ontvanger heeft ingediend. Bevat de antwoorden. |
| request.expired | De vervaltermijn is verstreken terwijl de aanvraag nog in behandeling was. |
| request.canceled | Jij of je automatisering heeft hem ingetrokken. |
Alle drie komen aan op dezelfde URL, dus vertak op type 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.
Wat er binnenkomt
{
"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.requestis er altijd — inclusief jouwexternalId,metadata, encontext, ongewijzigd. Hij draagt de tijdstempel van welk einde er ook gebeurde (completedAt,expiredAt, ofcanceledAtmet een optionelecancelReason).outcomeis alleen aanwezig wanneer de ontvanger een beslissingsvraag heeft beantwoord —approve,decline, ofchanges.form,submission,answersendisplayverschijnen alleen bij voltooiing, in precies de vorm die een inzendingswebhook draagt.form.snapshotIdis de exacte gepubliceerde versie waarop de ontvanger antwoordde;submission.pdfUrlis alleen een URL wanneer het formulier een inzendings-PDF bewaart, en anders null.testistruewanneer de aanvraag is aangemaakt in testmodus — vertak erop, of laat het event vallen.answersis gesorteerd op veldsleutel, met herhaalgroepen genest als één object per instantie. Een keuzeantwoord is de sleutel van de optie uitfields.list, niet het label; het label staat indisplay, onder dezelfde sleutel.De POST komt binnen als
Content-Type: application/jsonmetUser-Agent: formbase, en draagtX-formbase-Event-Id,X-formbase-Event-TypeenX-formbase-Signature— zodat je kunt deduplicaren en routeren voordat je gaat parsen.
Dedupliceer op id
id 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.
De handtekening verifiëren
Elke callback bevat een handtekeningheader, X-formbase-Signature: t={unix seconden},sha256={hex}. De hex
is een HMAC-SHA256 van het tijdstempel, een punt, en de ruwe request-body, berekend met het request signing secret van je
werkruimte.
Twee regels, welke taal je ook gebruikt:
Hash de ruwe body, voordat er iets geparseerd of opnieuw geserialiseerd wordt. Opnieuw gecodeerde JSON is niet dezelfde bytes.
Vergelijk in constante tijd —
crypto.timingSafeEqual,hmac.compare_digest— nooit met==.
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_secondsHet request signing secret
Eén geheim per werkruimte ondertekent elke callback ervan. Vind het bij OAuth en API-sleutels in de zijbalk van de
werkruimte, op de kaart Request signing secret. Het is standaard verborgen; Geheim tonen 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 callbackUrl
heeft aangemaakt, heeft er nog geen.
Opnieuw genereren heeft geen overgangsperiode
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. Werk eerst je ontvanger bij, genereer daarna pas opnieuw. Er is geen periode waarin beide geheimen worden geaccepteerd.
Nieuwe pogingen
Een callback krijgt acht pogingen: de eerste, dan zeven nieuwe pogingen met minstens 1, 2, 4, 8, 16, 32 en 60 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 tussenpoos komen, en de laatste poging komt zo’n vier uur na het einde van de aanvraag. Elke poging bevat dezelfde bytes en dezelfde
id: 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.
| Jouw respons | Wat formbase doet |
|---|---|
| 2xx | Klaar. De callback wordt gemarkeerd als afgeleverd. |
| 408, 429, 5xx | Probeert opnieuw, met inachtneming van Retry-After als je die meestuurt. |
| Andere 4xx | Stopt. Je endpoint heeft de aanroep afgewezen; dezelfde body opnieuw sturen kan niet helpen. |
| Timeout of verbindingsfout | Probeert opnieuw volgens hetzelfde schema. |
| Geblokkeerde URL | Stopt onmiddellijk. Een host die niet oplost, een privéadres, of een niet-HTTPS-URL kan nooit worden toegestaan. Omleidingen worden nooit gevolgd, dus een 3xx stopt ook. |
Als het budget opraakt — je ontvanger lag de hele middag plat — is de callback niet verloren. De aanvraag krijgt een badge
Callback mislukt, 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 requests.get. Om hem opnieuw te versturen, open je de aanvraag op de
pagina Aanvragen en druk je op Opnieuw afspelen, of roep je
requests.replayCallback aan. Dat stuurt dezelfde bevroren payload opnieuw met dezelfde id, precies wat een
ontvanger die dedupliceert nodig heeft.
Abonnementen ontvangen dezelfde gebeurtenissen
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 formbase-apps voor Zapier en n8n doen dit voor je, en webhooks.create doet het vanuit code met
request_completed, request_expired of request_canceled 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. Opnieuw afspelen stuurt alleen de callback opnieuw; een abonnement probeert het zelf opnieuw en pauzeert na
vijf mislukte pogingen.
Een resume-URL is geen authenticatie
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.