# Webhook API-referanse

Nyttelastskjema, hendelsestyper, signering, nye forsøk og REST-abonnementsendepunkter.

## Webhook API-referanse

Nyttelastskjema, hendelsestyper, signering, oppførsel ved nye forsøk, og REST-abonnementsendepunkter for Zapier og Make.

> ℹ️ **Ser du etter oppsettsguiden?**
> <p>
>     Denne siden dokumenterer nyttelastkontrakten og REST-abonnements-API-et. For å sette opp en egendefinert webhook for skjemaet ditt i
>     grensesnittet, se <a href="/no/integrations/webhooks">Webhooks</a>.
>   </p>

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

<h3 id="headers">Hoder</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — vises når en signeringsnøkkel er satt opp (se
    nedenfor)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> og <code>X-Formstep-Event-Type</code> — samme verdier som <code>id</code> og <code>type</code> i
    kroppen, slik at du kan deduplisere og rute før parsing. En <a href="/no/requests/callbacks">forespørsel-callback</a> sender de samme
    to.
  </li>
  <li>
    Eventuelle egendefinerte hoder du legger til under oppsett. De slås sammen som oppgitt, unntatt <code>Content-Type</code>, som ikke kan
    overstyres. Native abonnementer opprettet via <code>webhooks.create</code> har ingen egendefinerte hoder.
  </li>
</ul>

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

<h3 id="subscription-events">Abonnementshendelser</h3>
<p>Når du setter opp en webhook-integrasjon, velger du hvilken hendelse som utløser leveranser:</p>

<p>
  De tre <code>request_*</code>-hendelsene abonneres gjennom <code>webhooks.create</code> og er det Formstep-appene for Zapier, Make og n8n
  lytter på. Hver leverer samme konvolutt som en <a href="/no/requests/callbacks">forespørsel-callback</a> sender, signert med abonnementets
  egen hemmelighet. Testforespørsler når aldri et abonnement, og <code>requests.replayCallback</code> sender bare callbacken på nytt. Ett
  abonnement, én hendelse: <code>submission_created</code> er trafikk fra delelenker og <code>request_completed</code> er trafikk fra
  forespørsler, så en fullført forespørsel utløser aldri <code>submission_created</code>, og et skjema med begge abonnementene mottar én
  leveranse per fullføring. En redigering når bare et <code>submission_updated</code>-abonnement, aldri et <code>submission_created</code>
  -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 <code>type</code>.
</p>

<h3 id="payload-event-types">Hendelsestyper i nyttelasten</h3>
<p>
  Feltet <code>type</code> i JSON-kroppen forteller deg hva som skjedde:
</p>
<ul>
  <li>
    <code>submission.completed</code> — en ny fullført innsending
  </li>
  <li>
    <code>submission.updated</code> — en eksisterende innsending ble redigert
  </li>
  <li>
    <code>submission.abandoned</code> — et innsendingutkast ble forlatt etter det konfigurerte inaktivitetsvinduet
  </li>
</ul>
<p>
  En <a href="/no/requests/callbacks">forespørsel-callback</a> og et <code>request_*</code>-abonnement bruker samme konvolutt med{' '}
  <code>request.completed</code>, <code>request.expired</code> og <code>request.canceled</code>, så én parser leser alle seks.
</p>

<h2 id="payload">Nyttelastens form</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 og display</h3>
<p>
  <code>answers</code> er det flate <code>{'{ field key: value }'}</code>-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: <code>answers.email</code>, ingen array å gå gjennom. Et
  valgsvar er det valgte alternativets <strong>nøkkel</strong> — <code>key</code>-en som <code>fields.list</code> 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 <code>null</code>.
</p>
<p>
  <code>display</code> 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.
</p>
<p>
  En gjentakende gruppe vises i <code>answers</code> én gang, under gruppens egen feltnøkkel, som en array av radobjekter nøklet med hvert
  medlems feltnøkkel — <code>answers.attendees[0].attendee_name</code> ovenfor — og i <code>display</code> som én linje med radene slått
  sammen. Et medlem løftes aldri opp til toppnivået. <a href="/no/building-forms/calculated-fields">Beregnede felt</a> vises i begge listene
  under det beregnede feltets navn som nøkkel (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Bestillinger og betalinger</h3>
<p>
  Et Planlegg avtale-spørsmål og et Betalingsfelt har hver et objekt i <code>answers</code>, under spørsmålets feltnøkkel, og én tekstlinje
  i <code>display</code>. Tidene er ISO-tidspunkter, så et regneark eller en arbeidsflyt kan tolke dem uansett hvilket språk respondenten
  svarte på:
</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>
  En bestillings <code>status</code> er <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code>{' '}
  eller <code>no_show</code>. En betalings er <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> eller{' '}
  <code>disputed</code>, og <code>amount</code> er i valutaens hovedenhet: <code>40</code> er $40.00. Når Cal.com flytter en bestilling
  eller Stripe refunderer en betaling etter innsendingen, oppdaterer Formstep objektet, slik at <code>submissions.list</code> og senere
  hendelser viser gjeldende tilstand; ingen ny hendelse sendes for endringen.
</p>
<p>
  Før <code>apiVersion</code> <code>2026-09-24</code> ble en bestilling sendt som én setning i <code>answers</code>, og en betaling ble ikke
  sendt i det hele tatt.
</p>

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

<h3 id="schema">Felttitler og -typer</h3>
<p>
  Hendelsen gjentar ikke hvert felts tittel og type. Les dem fra <code>fields.list</code>, som er stabil per{' '}
  <code>data.form.snapshotId</code>, 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å <strong>Send feltlisten med hver hendelse</strong> i webhookens innstillinger; hendelsen
  bærer da <code>data.schema</code>, ett element per felt. Et valgspørsmål lister <code>options</code> sine og en matrise <code>rows</code>{' '}
  og <code>columns</code> sine, hver som <code>{'{ key, label }'}</code>, slik at nøklene i <code>answers</code> løses til etiketter uten et
  andre kall:
</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">Request-blokken</h3>
<p>
  På den <a href="/no/integrations/webhooks">egendefinerte webhooken</a> som er konfigurert i skjemainnstillingene, bærer en innsending som
  besvarte en <a href="/no/requests/overview">forespørsel</a> ett ekstra objekt inne i <code>data</code>, <code>request</code>. 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 <code>request.completed</code>, som bærer hele
  request-blokken.
</p>

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

<ul>
  <li>
    <code>id</code> — forespørselen denne innsendingen besvarte. Send den til <code>requests.get</code> for hele bildet.
  </li>
  <li>
    <code>externalId</code> og <code>metadata</code> — din egen bokføring, nøyaktig slik du oppga den i <code>requests.create</code>. Hver
    er kun til stede når den ble satt.
  </li>
</ul>

> ℹ️ **Webhooks er ikke callbacks**
> <p>
>     En innsendings-webhook utløses ved en innsending; request-blokken navngir bare forespørselen den besvarte. En{' '}
>     <a href="/no/requests/callbacks">callback</a> utløses når en forespørsel avsluttes — fullført, utløpt eller kansellert — og bærer{' '}
>     <code>context</code> og <code>outcome</code>. 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å <code>request_completed</code>,{' '}
>     <code>request_expired</code> eller <code>request_canceled</code> gjennom <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">Innsendings-PDF</h3>
<p>
  <code>data.submission.pdfUrl</code> 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 <code>null</code> bare når
  innsendingen ikke har noen PDF.
</p>

<h3 id="submission-language">Innsendingsspråk</h3>
<p>
  <code>data.submission.language</code> er BCP-47-koden for språket respondenten sendte inn skjemaet på (for oversatte skjemaer). Den er{' '}
  <code>null</code> for skjemaer med ett enkelt språk. Bruk den til å rute eller forgrene basert på respondentens språk uten et eget
  oppslag.
</p>

<h2 id="abandoned-submissions">Nyttelaster for forlatte innsendinger</h2>
<p>
  Når du abonnerer på <code>submission_abandoned</code>, sjekker Formstep for inaktive utkast hver time. Hvis et utkast har vært inaktivt
  forbi det konfigurerte inaktivitetsvinduet, sender Formstep en leveranse.
</p>
<p>
  Native API-integrasjoner må sende <code>idleWindow</code> til <code>webhooks.create</code>. Godtatte verdier er <code>12h</code>,{' '}
  <code>1d</code>, <code>3d</code>, og <code>1w</code>. Det finnes ingen implisitt standard; et forlatt-abonnement uten en verdi avvises.
</p>

<p>Nyttelastens form er identisk med en fullført innsending. To forskjeller:</p>
<ul>
  <li>
    <strong>Svar kan være sparsomme</strong> — bare spørsmål respondenten svarte på vises i <code>answers</code> og <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — faller tilbake til avsendingstidspunktet siden respondenten aldri formelt sendte inn.
  </li>
</ul>
<p>Hver integrasjon sender ut maks én gang per forlatt utkast. Etter leveranse utelukkes utkastet fra fremtidige søk.</p>

<h2 id="signing">Signering</h2>
<p>
  Hver webhook har sin egen signeringsnøkkel — satt opp i grensesnittet når du konfigurerer en egendefinert webhook, eller med den valgfrie
  parameteren <code>signingSecret</code> (32–255 tegn) på <code>webhooks.create</code>. Det er ikke arbeidsområdens signeringshemmelighet
  for forespørsler, som brukes for <a href="/no/requests/callbacks">forespørsel-callbacks</a>, men hodet og algoritmen er identiske, så én
  verifiserer håndterer begge.
</p>
<p>
  Hver signert leveranse bærer <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. Sammendraget er en
  HMAC-SHA256 av <code>&#123;t&#125;.&#123;raw body&#125;</code>, nøklet med hemmeligheten din. To regler: hash den <strong>rå</strong>{' '}
  kroppen før noen parsing eller re-serialisering, og sammenlign i konstant tid.
</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 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.
</p>

> ⚠️ **Verifiser alltid i produksjon**
> <p>Uten verifisering kan hvem som helst som oppdager URL-en din poste falske innsendinger.</p>

<h2 id="retries">Nye forsøk</h2>
<p>
  Formstep 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. Formstep 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 <code>Retry-After</code>-header på svaret ditt respekteres når den ber om lenger tid enn
  neste tilbakegangssteg. En leveranse regnes som mislykket hvis endepunktet ditt:
</p>
<ul>
  <li>Returnerer en ikke-2xx-status</li>
  <li>Tidsavbrytes</li>
  <li>Tilbakestiller tilkoblingen</li>
</ul>
<p>
  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.
</p>
<p>
  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.
</p>
<p>
  Leveranser som prøves på nytt for samme hendelse gjenbruker samme <code>id</code>, 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 <code>id</code> og{' '}
  <code>type: "submission.updated"</code>: på Skjemainnstillinger-webhooken, eller på et <code>submission_updated</code>-abonnement.{' '}
  <code>createdAt</code> 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 <code>data.submission.editCount</code>: den teller oppover for hver
  redigering, og <code>data.submission.updatedAt</code> forteller når den siste skjedde.
</p>

<h2 id="testing">Testing</h2>
<p>
  Både integrasjonsoppsettet og detaljpanelet har en <strong>Send test</strong>-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 <code>submissions.sample</code> og <code>requests.sample</code>.
</p>
<p>For lokal utvikling, eksponer dev-serveren din med en tunnel:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

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

> 💡 **Lokal utvikling**
> <p>Bruk tunnel-URL-en som webhook-endepunkt, og trykk deretter Send test for å verifisere ende-til-ende.</p>

<h2 id="next-steps">Neste steg</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Oppsett av webhooks](/no/integrations/webhooks) — Konfigurer webhooks for skjemaet ditt
  - [Planer og priser](/no/subscription-billing/plans-pricing) — Sammenlign plan-API-funksjoner og grenser
</div>
