formbasedocs
Gå til appenAppen

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/json
  • X-formbase-Signature: t={timestamp},sha256={hex} — vises når en signeringsnøkkel er satt opp (se nedenfor)

  • X-formbase-Event-Id og X-formbase-Event-Type — samme verdier som id og type i 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 via webhooks.create har ingen egendefinerte hoder.

Hendelsestyper

Abonnementshendelser

Når du setter opp en webhook-integrasjon, velger du hvilken hendelse som utløser leveranser:

HendelseNår den utløses
submission_createdEn respondent fullfører og sender inn skjemaet. Dette er standard.
submission_updatedEn respondent redigerer en innsending de allerede har sendt, når skjemaet tillater redigering etter innsending.
submission_abandonedEt utkast til innsending har vært inaktivt forbi det konfigurerte vinduet. Krever sporing av delvise innsendinger (Pro).
request_completedEn mottaker fullfører en forespørsel på skjemaet. Bærer request-blokken og svarene.
request_expiredEn forespørsel på skjemaet utløper før mottakeren fullfører den. Bare request-blokken.
request_canceledEn 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 innsending

  • submission.updated — en eksisterende innsending ble redigert

  • submission.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

POST body
json
{
  "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økkelHva det er
idHendelses-IDen. Nye forsøk gjenbruker den — deduplisér på den.
typeEn av de seks hendelsestypene ovenfor.
createdAtDa hendelsen ble satt i kø, ikke da dette leveringsforsøket ble gjort. Stabil på tvers av nye forsøk.
apiVersionNyttelastkontrakten, som en dato. Den endres når en nøkkel fjernes, gis nytt navn eller endrer betydning. Nye nøkler kommer uten en endring.
testtrue for en eksempel- eller testleveranse, og for en forespørsel opprettet i testmodus. Alltid til stede.
data.form.snapshotIdDen publiserte versjonen respondenten svarte på. Feltnøkler, titler og typer er faste per øyeblikksbilde.
data.submission.updatedAtDa respondenten sist redigerte innsendingen, eller null frem til første redigering.
data.submission.editCountHvor mange ganger respondenten redigerte innsendingen etter å ha sendt den inn: 0 ved submission.completed, 1 ved første redigering.
data.answersHvert svar, nøklet med feltnøkkel. Hvert svar vises én gang.
data.displayMenneskelig lesbar tekst for hvert svar, under de samme nøklene.
data.schemaValgfritt: 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å:

data.answers
json
{
  "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:

data.schema
json
[
  { "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.

Lagt til i data
json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
  • id — forespørselen denne innsendingen besvarte. Send den til requests.get for hele bildet.

  • externalId og metadata — din egen bokføring, nøyaktig slik du oppga den i requests.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.

InaktivitetsvinduBeskrivelse
12 timerFor oppfølging samme dag
1 dagEt rimelig intervall før en påminnelse
3 dagerFor mindre tidssensitive skjemaer
1 ukeFor 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 answers og display.

  • 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.

verify.ts
ts
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
}
verify.py
python
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_seconds

formbase 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.

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:

bash
# ngrok
ngrok http 3000

# cloudflare tunnel

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

Lokal utvikling

Bruk tunnel-URL-en som webhook-endepunkt, og trykk deretter Send test for å verifisere ende-til-ende.

Neste steg