# Callbacks og signering

Gjenoppta arbeidsflyten din når en forespørsel avsluttes, og bevis at kallet virkelig kom fra Formstep.

## Callbacks og signering

Når en forespørsel når slutten — fullført, utløpt eller kansellert — sender Formstep en signert POST-varsling til URL-en automatiseringen din oppga. Det kallet er det som gjenopptar kjøringen.

<h2 id="what-fires">Hva som utløses, og når</h2>

<p>
  Alle tre ankommer samme URL, så grener på <code>type</code> før du antar at det finnes svar. Det er hele poenget med å utløse ved enhver
  avslutning: en arbeidsflyt parkert på en kunde gjenopptas enten de svarte, ignorerte deg, eller ble kalt av.
</p>

<h2 id="payload">Hva som ankommer</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> er alltid der — inkludert <code>externalId</code>, <code>metadata</code>, og <code>context</code>, uendret.
    Den bærer tidsstempelet for uansett hvilken avslutning som skjedde (<code>completedAt</code>, <code>expiredAt</code>, eller{' '}
    <code>canceledAt</code> med en valgfri <code>cancelReason</code>).
  </li>
  <li>
    <code>outcome</code> er kun til stede når mottakeren svarte på et <a href="/no/requests/decisions-and-approvals">beslutningsspørsmål</a>{' '}
    — <code>approve</code>, <code>decline</code>, eller <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> og <code>display</code> vises kun ved fullføring, i akkurat den formen
    en <a href="/no/developers/webhooks-reference#payload">innsendingswebhook</a> bærer. <code>form.snapshotId</code> er den nøyaktige
    publiserte versjonen mottakeren svarte på; <code>submission.pdfUrl</code> er en URL bare når skjemaet beholder en innsendings-PDF, og
    ellers null.
  </li>
  <li>
    <code>test</code> er <code>true</code> når forespørselen ble opprettet i{' '}
    <a href="/no/requests/creating-requests#test-mode">testmodus</a> — forgren deg på det, eller forkast hendelsen.
  </li>
  <li>
    <code>answers</code> er nøkkelsatt etter <a href="/no/requests/field-keys">feltnøkkel</a>, med gjentakende grupper nøstet som ett objekt
    per instans. Et valgsvar er valgets <strong>key</strong> fra <code>fields.list</code>, ikke etiketten dets; etiketten er i{' '}
    <code>display</code>, under samme nøkkel.
  </li>
  <li>
    POST-en ankommer som <code>Content-Type: application/json</code> med <code>User-Agent: Formstep</code>, og bærer{' '}
    <code>X-Formstep-Event-Id</code>, <code>X-Formstep-Event-Type</code> og <code>X-Formstep-Signature</code> — slik at du kan avduplisere
    og rute før parsing.
  </li>
</ul>

> ⚠️ **Avdupliser på id**
> <p>
>     <code>id</code> er stabil på tvers av hvert forsøk og hver avspilling av samme hendelse. Hvis mottakeren din kan komme til å handle to
>     ganger på samme id — en dobbel faktura, en dobbel sak — husk hvilke id-er du allerede har håndtert.
>   </p>

<h2 id="verify">Verifiser signaturen</h2>

<p>
  Hver callback bærer en signaturheader, <code>X-Formstep-Signature: t=&#123;unix seconds&#125;,sha256=&#123;hex&#125;</code>. Hex-en er en
  HMAC-SHA256 av tidsstempelet, et punktum, og selve forespørselens rå body, beregnet med arbeidsområdens{' '}
  <strong>signeringshemmelighet for forespørsler</strong>.
</p>

<p>To regler, uansett hvilket språk du bruker:</p>

<ol>
  <li>
    Hash den <strong>rå</strong> bodyen, før noen parsing eller re-serialisering. Re-enkodet JSON er ikke de samme bytene.
  </li>
  <li>
    Sammenlign i konstant tid — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — aldri med <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">Signeringshemmeligheten for forespørsler</h2>

<p>
  Én hemmelighet per arbeidsområde signerer hver callback fra den. Finn den under <strong>OAuth og API-nøkler</strong> i
  arbeidsområde-sidefeltet, i kortet <strong>Signeringshemmelighet for forespørsler</strong>. Den er maskert som standard; øye-knappen
  avslører den og kopier-knappen kopierer den. Det er ikke en engangsverdi — du kan komme tilbake og lese den igjen.
</p>

> ❗ **Regenerering har ingen overgangsperiode**
> <p>
>     Kun arbeidsområdeeieren kan regenerere hemmeligheten, og i det øyeblikket de gjør det, slutter den gamle å fungere — også for callbacks
>     som allerede er under forsøk på nytt. <strong>Oppdater mottakeren din først, regenerer deretter.</strong> Det finnes ikke noe vindu der
>     begge hemmelighetene godtas.
>   </p>

<h2 id="retries">Nye forsøk</h2>

<p>
  En callback får <strong>åtte forsøk</strong>: det første, deretter sju nye forsøk med minst 1, 2, 4, 8, 16, 32 og 60 minutters mellomrom.
  Formstep ser etter forfalte nye forsøk hvert 30. minutt, så et nytt forsøk kan komme opptil en halvtime etter at mellomrommet er ute, og
  det siste forsøket kommer om lag fire timer etter at forespørselen avsluttet. Hvert forsøk bærer de samme bytene og samme <code>id</code>:
  nyttelasten er frosset i øyeblikket forespørselen avsluttet, så et nytt forsøk beskriver hva som skjedde da, ikke hvordan forespørselen
  ser ut nå. Mål-URL-en og signeringshemmeligheten leses ved hvert forsøk, og fryses ikke sammen med den.
</p>

<p>
  Hvis budsjettet går tomt — mottakeren din var nede en ettermiddag — er callbacken ikke tapt. Forespørselen får merket{' '}
  <strong>Callback feilet</strong>, arbeidsområdeeieren får én e-post med verten, årsaken og antall forsøk, og svarene forblir lesbare
  gjennom <code>requests.get</code>. For å sende den på nytt, åpne forespørselen på{' '}
  <a href="/no/requests/managing-requests">Forespørsler-siden</a> og trykk <strong>Kjør på nytt</strong>, eller kall{' '}
  <code>requests.replayCallback</code>. Den sender den samme frosne nyttelasten på nytt med samme <code>id</code>, som er akkurat hva en
  mottaker som avdupliserer ønsker.
</p>

<h2 id="subscriptions">Abonnementer hører de samme hendelsene</h2>

<p>
  En callback-URL hører til én forespørsel. Når hver forespørsel på et skjema skal nå den samme mottakeren, abonner én gang i stedet:{' '}
  Formstep-appene for Zapier og n8n gjør dette for deg, og <code>webhooks.create</code> gjør det fra kode, med hendelsestypen{' '}
  <code>request_completed</code>, <code>request_expired</code> eller <code>request_canceled</code>. Et abonnement mottar denne samme
  konvolutten, signert med sin egen hemmelighet i stedet for arbeidsområdens signeringshemmelighet for forespørsler, med sin egen
  hendelses-ID og sitt eget forsøksbudsjett. En forespørsel som har både en callback-URL og et matchende abonnement, utløses to ganger, én
  gang til hver. <strong>Kjør på nytt</strong> sender bare callbacken på nytt; et abonnement prøver på nytt på egen hånd og pauser etter fem
  mislykkede forsøk.
</p>

> 💡 **En gjenopptakelses-URL er ikke autentisering**
> <p>
>     Arbeidsflytverktøy gir deg en vanskelig-å-gjette gjenopptakelses-URL, og det er fristende å behandle det som bevis. Det er en
>     bærerhemmelighet — den kan lekke inn i logger, og den forteller deg ikke at bodyen ikke er tuklet med. Verifiser signaturen i den
>     gjenopptatte grenen også.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Feilsøking](/no/requests/troubleshooting) — Når en callback fortsetter å feile.
  - [Webhook-referanse](/no/developers/webhooks-reference) — answers- og display-listene en fullføring bærer, i sin helhet.
</div>
