Forespørsler
Callbacks og signering
Når en forespørsel når slutten — fullført, utløpt eller kansellert — sender formbase en signert POST-varsling til URL-en automatiseringen din oppga. Det kallet er det som gjenopptar kjøringen.
Hva som utløses, og når
| Hendelse | Når |
|---|---|
| request.completed | Mottakeren sendte inn. Bærer svarene. |
| request.expired | Utløpstiden passerte mens forespørselen fortsatt ventet. |
| request.canceled | Du eller automatiseringen din trakk den tilbake. |
Alle tre ankommer samme URL, så grener på type 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.
Hva som ankommer
{
"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.requester alltid der — inkludertexternalId,metadata, ogcontext, uendret. Den bærer tidsstempelet for uansett hvilken avslutning som skjedde (completedAt,expiredAt, ellercanceledAtmed en valgfricancelReason).outcomeer kun til stede når mottakeren svarte på et beslutningsspørsmål —approve,decline, ellerchanges.form,submission,answersogdisplayvises kun ved fullføring, i akkurat den formen en innsendingswebhook bærer.form.snapshotIder den nøyaktige publiserte versjonen mottakeren svarte på;submission.pdfUrler en URL bare når skjemaet beholder en innsendings-PDF, og ellers null.testertruenår forespørselen ble opprettet i testmodus — forgren deg på det, eller forkast hendelsen.answerser nøkkelsatt etter feltnøkkel, med gjentakende grupper nøstet som ett objekt per instans. Et valgsvar er valgets key frafields.list, ikke etiketten dets; etiketten er idisplay, under samme nøkkel.POST-en ankommer som
Content-Type: application/jsonmedUser-Agent: formbase, og bærerX-formbase-Event-Id,X-formbase-Event-TypeogX-formbase-Signature— slik at du kan avduplisere og rute før parsing.
Avdupliser på id
id 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.
Verifiser signaturen
Hver callback bærer en signaturheader, X-formbase-Signature: t={unix seconds},sha256={hex}. Hex-en er en
HMAC-SHA256 av tidsstempelet, et punktum, og selve forespørselens rå body, beregnet med arbeidsområdens
signeringshemmelighet for forespørsler.
To regler, uansett hvilket språk du bruker:
Hash den rå bodyen, før noen parsing eller re-serialisering. Re-enkodet JSON er ikke de samme bytene.
Sammenlign i konstant tid —
crypto.timingSafeEqual,hmac.compare_digest— aldri med==.
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_secondsSigneringshemmeligheten for forespørsler
Én hemmelighet per arbeidsområde signerer hver callback fra den. Finn den under OAuth og API-nøkler i arbeidsområde-sidefeltet, i kortet Signeringshemmelighet for forespørsler. 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.
Regenerering har ingen overgangsperiode
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. Oppdater mottakeren din først, regenerer deretter. Det finnes ikke noe vindu der begge hemmelighetene godtas.
Nye forsøk
En callback får åtte forsøk: det første, deretter sju nye forsøk med minst 1, 2, 4, 8, 16, 32 og 60 minutters mellomrom.
formbase 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 id:
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.
| Din respons | Hva formbase gjør |
|---|---|
| 2xx | Ferdig. Callbacken merkes som levert. |
| 408, 429, 5xx | Nytt forsøk, med respekt for Retry-After når du sender en. |
| Annen 4xx | Stopper. Endepunktet ditt avviste kallet; å prøve samme body på nytt kan ikke hjelpe. |
| Tidsavbrudd eller tilkoblingsfeil | Nytt forsøk etter samme plan. |
| Blokkert URL | Stopper umiddelbart. En vert som ikke lar seg slå opp, en privat adresse, eller en URL uten HTTPS kan aldri bli tillatt. Omdirigeringer følges aldri, så en 3xx stopper også. |
Hvis budsjettet går tomt — mottakeren din var nede en ettermiddag — er callbacken ikke tapt. Forespørselen får merket
Callback feilet, arbeidsområdeeieren får én e-post med verten, årsaken og antall forsøk, og svarene forblir lesbare
gjennom requests.get. For å sende den på nytt, åpne forespørselen på
Forespørsler-siden og trykk Kjør på nytt, eller kall
requests.replayCallback. Den sender den samme frosne nyttelasten på nytt med samme id, som er akkurat hva en
mottaker som avdupliserer ønsker.
Abonnementer hører de samme hendelsene
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:
formbase-appene for Zapier og n8n gjør dette for deg, og webhooks.create gjør det fra kode, med hendelsestypen
request_completed, request_expired eller request_canceled. 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. Kjør på nytt sender bare callbacken på nytt; et abonnement prøver på nytt på egen hånd og pauser etter fem
mislykkede forsøk.
En gjenopptakelses-URL er ikke autentisering
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å.