Utviklere
Webhook API-referanse
Nyttelastskjema, hendelsestyper, signering, oppførsel ved nye forsøk, og REST-abonnementsendepunkter for Zapier og Make.
Ser du etter oppsettsguiden?
Denne siden dokumenterer nyttelastkontrakten og REST-abonnements-API-et. For å sette opp en egendefinert webhook for skjemaet ditt i grensesnittet, se Webhooks.
Forespørsel
POST <your-url> med Content-Type: application/json.
Hoder
Content-Type: application/jsonX-formbase-Signature: t={timestamp},sha256={hex}— vises når en signeringsnøkkel er satt opp (se nedenfor)X-formbase-Event-IdogX-formbase-Event-Type— samme verdier somidogtypei kroppen, slik at du kan deduplisere og rute før parsing. En forespørsel-callback sender de samme to.Eventuelle egendefinerte hoder du legger til under oppsett. De slås sammen som oppgitt, unntatt
Content-Type, som ikke kan overstyres. Native abonnementer opprettet viawebhooks.createhar ingen egendefinerte hoder.
Hendelsestyper
Abonnementshendelser
Når du setter opp en webhook-integrasjon, velger du hvilken hendelse som utløser leveranser:
| Hendelse | Når den utløses |
|---|---|
| submission_created | En respondent fullfører og sender inn skjemaet. Dette er standard. |
| submission_updated | En respondent redigerer en innsending de allerede har sendt, når skjemaet tillater redigering etter innsending. |
| submission_abandoned | Et utkast til innsending har vært inaktivt forbi det konfigurerte vinduet. Krever sporing av delvise innsendinger (Pro). |
| request_completed | En mottaker fullfører en forespørsel på skjemaet. Bærer request-blokken og svarene. |
| request_expired | En forespørsel på skjemaet utløper før mottakeren fullfører den. Bare request-blokken. |
| request_canceled | En forespørsel på skjemaet blir kansellert. Bare request-blokken. |
De tre request_*-hendelsene abonneres gjennom webhooks.create og er det formbase-appene for Zapier, Make og n8n
lytter på. Hver leverer samme konvolutt som en forespørsel-callback sender, signert med abonnementets
egen hemmelighet. Testforespørsler når aldri et abonnement, og requests.replayCallback sender bare callbacken på nytt. Ett
abonnement, én hendelse: submission_created er trafikk fra delelenker og request_completed er trafikk fra
forespørsler, så en fullført forespørsel utløser aldri submission_created, og et skjema med begge abonnementene mottar én
leveranse per fullføring. En redigering når bare et submission_updated-abonnement, aldri et submission_created
-abonnement. Webhooken du setter opp i Skjemainnstillinger har ikke noe valg av hendelse: den mottar første innsendinger og redigeringer
likt, skilt fra hverandre med type.
Hendelsestyper i nyttelasten
Feltet type i JSON-kroppen forteller deg hva som skjedde:
submission.completed— en ny fullført innsendingsubmission.updated— en eksisterende innsending ble redigertsubmission.abandoned— et innsendingutkast ble forlatt etter det konfigurerte inaktivitetsvinduet
En forespørsel-callback og et request_*-abonnement bruker samme konvolutt med
request.completed, request.expired og request.canceled, så én parser leser alle seks.
Nyttelastens form
{
"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"
}
}
}| Nøkkel | Hva det er |
|---|---|
| id | Hendelses-IDen. Nye forsøk gjenbruker den — deduplisér på den. |
| type | En av de seks hendelsestypene ovenfor. |
| createdAt | Da hendelsen ble satt i kø, ikke da dette leveringsforsøket ble gjort. Stabil på tvers av nye forsøk. |
| apiVersion | Nyttelastkontrakten, som en dato. Den endres når en nøkkel fjernes, gis nytt navn eller endrer betydning. Nye nøkler kommer uten en endring. |
| test | true for en eksempel- eller testleveranse, og for en forespørsel opprettet i testmodus. Alltid til stede. |
| data.form.snapshotId | Den publiserte versjonen respondenten svarte på. Feltnøkler, titler og typer er faste per øyeblikksbilde. |
| data.submission.updatedAt | Da respondenten sist redigerte innsendingen, eller null frem til første redigering. |
| data.submission.editCount | Hvor mange ganger respondenten redigerte innsendingen etter å ha sendt den inn: 0 ved submission.completed, 1 ved første redigering. |
| data.answers | Hvert svar, nøklet med feltnøkkel. Hvert svar vises én gang. |
| data.display | Menneskelig lesbar tekst for hvert svar, under de samme nøklene. |
| data.schema | Valgfritt: feltlisten (key, title, type, group, og alternativ- eller rad- og kolonnenøklene med etikettene sine), når webhooken ble satt opp til å sende den. |
answers og display
answers er det flate { field key: value }-objektet, med nøkler satt til feltnøklene som ble frosset ved
publisering. Les den når en arbeidsflyt forgrener seg eller lagrer en verdi: answers.email, ingen array å gå gjennom. Et
valgsvar er det valgte alternativets nøkkel — key-en som fields.list lister for det
alternativet — så den er den samme uansett hvilket språk respondenten svarte på. En dato er en ISO-streng, et tall et tall, et flervalg en
array av alternativnøkler. Ubesvarte felt utelates, sendes aldri som null.
display bærer de samme nøklene med menneskelig lesbar tekst: alternativets etikett i stedet for nøkkelen, en formatert dato,
en sammenslått liste. Les den når en person skal se verdien — en Slack-melding, en regnearkcelle, en e-post.
En gjentakende gruppe vises i answers én gang, under gruppens egen feltnøkkel, som en array av radobjekter nøklet med hvert
medlems feltnøkkel — answers.attendees[0].attendee_name ovenfor — og i display som én linje med radene slått
sammen. Et medlem løftes aldri opp til toppnivået. Beregnede felt vises i begge listene
under det beregnede feltets navn som nøkkel (answers.total).
Bestillinger og betalinger
Et Planlegg avtale-spørsmål og et Betalingsfelt har hver et objekt i answers, under spørsmålets feltnøkkel, og én tekstlinje
i display. Tidene er ISO-tidspunkter, så et regneark eller en arbeidsflyt kan tolke dem uansett hvilket språk respondenten
svarte 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_..."
}
}En bestillings status er confirmed, rescheduled, cancelled, rejected
eller no_show. En betalings er paid, partially_refunded, refunded eller
disputed, og amount er i valutaens hovedenhet: 40 er $40.00. Når Cal.com flytter en bestilling
eller Stripe refunderer en betaling etter innsendingen, oppdaterer formbase objektet, slik at submissions.list og senere
hendelser viser gjeldende tilstand; ingen ny hendelse sendes for endringen.
Før apiVersion 2026-09-24 ble en bestilling sendt som én setning i answers, og en betaling ble ikke
sendt i det hele tatt.
Webhookens felttilordning gjelder for begge listene samtidig: velg «valgte» felt og resten utelates; gi et felts kolonne nytt navn, og det
nye navnet er nøkkelen dets i answers og display likt. Et felt skjemaet publiserte før feltnøkler fantes, sendes
ut under element-IDen sin; publiser skjemaet på nytt for å gi det en lesbar nøkkel.
Felttitler og -typer
Hendelsen gjentar ikke hvert felts tittel og type. Les dem fra fields.list, som er stabil per
data.form.snapshotId, slik at du kan mellomlagre feltlisten og bare hente på nytt når øyeblikksbilde-IDen endres. En mottaker
som ikke kan gjøre et andre kall, kan slå på Send feltlisten med hver hendelse i webhookens innstillinger; hendelsen
bærer da data.schema, ett element per felt. Et valgspørsmål lister options sine og en matrise rows
og columns sine, hver som { key, label }, slik at nøklene i answers løses til etiketter uten et
andre kall:
[
{ "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" }
]Request-blokken
På den egendefinerte webhooken som er konfigurert i skjemainnstillingene, bærer en innsending som
besvarte en forespørsel ett ekstra objekt inne i data, request. Det er
fraværende på hver offentlig-lenke-innsending, så tilstedeværelsen er hvordan den mottakeren skiller de to kanalene fra hverandre. Et
Zapier-, Make- eller n8n-abonnement ser det aldri: forespørseltrafikk når et abonnement som request.completed, som bærer hele
request-blokken.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— forespørselen denne innsendingen besvarte. Send den tilrequests.getfor hele bildet.externalIdogmetadata— din egen bokføring, nøyaktig slik du oppga den irequests.create. Hver er kun til stede når den ble satt.
Webhooks er ikke callbacks
En innsendings-webhook utløses ved en innsending; request-blokken navngir bare forespørselen den besvarte. En
callback utløses når en forespørsel avsluttes — fullført, utløpt eller kansellert — og bærer
context og outcome. Utløp og kansellering har ingen innsending, så ingen innsendings-webhook utløses noensinne
for dem. For å høre at en forespørsel avsluttes uten en callback-URL, abonner på request_completed,
request_expired eller request_canceled gjennom webhooks.create.
Innsendings-PDF
data.submission.pdfUrl er en lenke til innsendings-PDF-en. Et skjema med en egendefinert webhook eller et Zapier-, Make-
eller n8n-abonnement beholder en PDF av hver innsending, så hendelsene til skjemaet har med lenken. Den er null bare når
innsendingen ikke har noen PDF.
Innsendingsspråk
data.submission.language er BCP-47-koden for språket respondenten sendte inn skjemaet på (for oversatte skjemaer). Den er
null for skjemaer med ett enkelt språk. Bruk den til å rute eller forgrene basert på respondentens språk uten et eget
oppslag.
Nyttelaster for forlatte innsendinger
Når du abonnerer på submission_abandoned, sjekker formbase for inaktive utkast hver time. Hvis et utkast har vært inaktivt
forbi det konfigurerte inaktivitetsvinduet, sender formbase en leveranse.
Native API-integrasjoner må sende idleWindow til webhooks.create. Godtatte verdier er 12h,
1d, 3d, og 1w. Det finnes ingen implisitt standard; et forlatt-abonnement uten en verdi avvises.
| Inaktivitetsvindu | Beskrivelse |
|---|---|
| 12 timer | For oppfølging samme dag |
| 1 dag | Et rimelig intervall før en påminnelse |
| 3 dager | For mindre tidssensitive skjemaer |
| 1 uke | For skjemaer med lav frekvens |
Nyttelastens form er identisk med en fullført innsending. To forskjeller:
Svar kan være sparsomme — bare spørsmål respondenten svarte på vises i
answersogdisplay.submittedAt— faller tilbake til avsendingstidspunktet siden respondenten aldri formelt sendte inn.
Hver integrasjon sender ut maks én gang per forlatt utkast. Etter leveranse utelukkes utkastet fra fremtidige søk.
Signering
Hver webhook har sin egen signeringsnøkkel — satt opp i grensesnittet når du konfigurerer en egendefinert webhook, eller med den valgfrie
parameteren signingSecret (32–255 tegn) på webhooks.create. Det er ikke arbeidsområdens signeringshemmelighet
for forespørsler, som brukes for forespørsel-callbacks, men hodet og algoritmen er identiske, så én
verifiserer håndterer begge.
Hver signert leveranse bærer X-formbase-Signature: t={seconds},sha256={hex}. Sammendraget er en
HMAC-SHA256 av {t}.{raw body}, nøklet med hemmeligheten din. To regler: hash den rå
kroppen før noen parsing eller re-serialisering, og sammenlign i konstant tid.
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 håndhever ikke et repriseringsvindu, så toleransen ovenfor er din å velge. Å endre hemmeligheten trer i kraft ved neste forsøk, inkludert nye forsøk som allerede er underveis — oppdater mottakeren din først.
Verifiser alltid i produksjon
Uten verifisering kan hvem som helst som oppdager URL-en din poste falske innsendinger.
Nye forsøk
formbase gjør opptil 5 forsøk per leveranse — 1 første forsøk og 4 nye forsøk — med minst 1, 2, 4 og 8 minutter mellom hvert. formbase ser
etter forfalte nye forsøk hvert 30. minutt, så et nytt forsøk kan komme opptil en halvtime etter at tilbakegangen er over, og det siste
forsøket om lag to timer etter det første. En Retry-After-header på svaret ditt respekteres når den ber om lenger tid enn
neste tilbakegangssteg. En leveranse regnes som mislykket hvis endepunktet ditt:
- Returnerer en ikke-2xx-status
- Tidsavbrytes
- Tilbakestiller tilkoblingen
Ett tilfelle prøves aldri på nytt: et mål som er blokkert, ikke kan slås opp, eller peker til en privat adresse. URL-en revalideres — inkludert DNS — umiddelbart før hvert forsøk, så en vert som slutter å være tillatt feiler leveransen med én gang i stedet for å bruke opp budsjettet.
Etter 5 påfølgende mislykkede leveranser pauses integrasjonen. Løs endepunktet og aktiver den på nytt fra Skjemainnstillinger → Integrasjoner; en vellykket leveranse nullstiller telleren.
Leveranser som prøves på nytt for samme hendelse gjenbruker samme id, så du kan deduplisere ved å lagre behandlede id-er. En
genuint ny hendelse — for eksempel at en respondent redigerer innsendingen sin — kommer med en ny id og
type: “submission.updated”: på Skjemainnstillinger-webhooken, eller på et submission_updated-abonnement.
createdAt er tidspunktet hendelsen ble satt i kø, ikke tidspunktet forsøket ble gjort, så den forblir den samme på tvers av
nye forsøk også. For å skille redigeringer fra hverandre, les data.submission.editCount: den teller oppover for hver
redigering, og data.submission.updatedAt forteller når den siste skjedde.
Testing
Både integrasjonsoppsettet og detaljpanelet har en Send test-knapp. Den sender et eksempel på den abonnerte hendelsen til
URL-en din slik at du kan verifisere tilkoblingen uten å vente på en ekte innsending eller forespørsel. De samme eksemplene er
tilgjengelige over API-et som submissions.sample og requests.sample.
For lokal utvikling, eksponer dev-serveren din med en tunnel:
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Lokal utvikling
Bruk tunnel-URL-en som webhook-endepunkt, og trykk deretter Send test for å verifisere ende-til-ende.