# Formulieren

Met de Formulieren-module bouwt een merchant contact-, offerte- en intakeformulieren in het client-dashboard en ontvangt de antwoorden in een inbox in het dashboard. Je site zet de vragen niet hard in de code: hij laadt het gepubliceerde formulier via zijn **key**, rendert het en stuurt de antwoorden van de bezoeker terug. Borkol controleert de antwoorden opnieuw op de server, slaat de inzending op, nummert haar, mailt de merchant en de bezoeker en verstuurt een webhook.

Alle vier de endpoints staan onder `/v1/api/forms`, vereisen dat de module `forms` is ingeschakeld voor de winkel (anders `403 {"error":"Module not enabled: forms"}`) en vallen onder de winkelbrede rate limit van 120 requests per minuut (zie [Errors en rate limits](/nl/api/getting-started/errors-and-rate-limits)).

| Endpoint | Scope | Doel |
| --- | --- | --- |
| `GET /v1/api/forms/{key}` | `forms:read` | Laad de gepubliceerde versie van een actief formulier in één taal. Zie [Een formulier laden](./forms). |
| `GET /v1/api/forms/address-lookup` | `forms:read` | Zoek straat en plaats bij een postcode en huisnummer, voor een `address`-veld. Zie [Adres opzoeken](./forms#get-v1apiformsaddress-lookup). |
| `POST /v1/api/forms/{key}/uploads` | `forms:write` | Sla één bestand op voor een `file`-veld en krijg een token voor de inzending. Zie [Uploads](./uploads). |
| `POST /v1/api/forms/{key}/submissions` | `forms:write` | Verstuur de antwoorden van één bezoeker. Zie [Inzendingen](./submissions). |

## Houd forms:write-sleutels op je server

Met een sleutel met `forms:write` kan iemand bestanden uploaden en inzendingen versturen namens de merchant, en elke inzending verstuurt mail. **Zet een `forms:write`-sleutel nooit in browsercode**, in een bundel van een mobiele app of op een andere plek die een bezoeker kan lezen. Laad het formulier, upload bestanden en verstuur vanaf je server (een route handler, een server action, een backend-endpoint) en laat de browser alleen met je eigen server praten. Eén sleutel op je server met zowel `forms:read` als `forms:write` (bijvoorbeeld in een omgevingsvariabele `BORKOL_FORMS_KEY`) is prima, want je server doet alle drie de calls. Een aparte sleutel met alleen `forms:read` om formulieren te laden is optioneel.

Omdat elk request dan van het adres van je server komt, geef je het IP-adres van de bezoeker mee in de header `X-Borkol-Visitor-Ip` op de upload- en submit-requests. Borkol past de limieten per uur toe per bezoekersadres (zie [Bezoekers-IP en de limiet per uur](#bezoekers-ip-en-de-limiet-per-uur)).

## Het verloop

1. **Laden**: `GET /v1/api/forms/{key}?locale=nl` op je server. Cache het resultaat en ververs het wanneer de webhook [`form.published`](/nl/api/webhooks/index) binnenkomt.
2. **Renderen**: toon de stappen op volgorde. Toon een stap of veld alleen zolang de regel in `show_if` klopt (zie [Regels](#regels)). Vul eventueel antwoorden vooraf in vanuit de pagina-URL (zie [Vooraf invullen vanuit de URL](#vooraf-invullen-vanuit-de-url)).
3. **Controleren**: controleer elke stap voordat de bezoeker verdergaat, met dezelfde regels als de server. Het pakket `@borkol/forms` implementeert ze (zie [Het pakket @borkol/forms](#het-pakket-borkolforms)).
4. **Uploaden**: elk bestand dat de bezoeker kiest voor een `file`-veld: `POST /v1/api/forms/{key}/uploads` vanaf je server geeft een token terug (zie [Uploads](./uploads)).
5. **Spamcontrole**: geeft het formulier `settings.turnstile_site_key` terug, toon dan de widget van Cloudflare Turnstile en stuur het token mee (zie [Turnstile](#turnstile)).
6. **Versturen**: `POST /v1/api/forms/{key}/submissions` vanaf je server, met de antwoorden, de uploadtokens, het Turnstile-token, de taal van de bezoeker, de paginagegevens en de header met het bezoekers-IP.
7. **Afronden**: bij `201` toon je `thank_you.message` of stuur je de bezoeker naar `thank_you.redirect_url`. Bij `422` toon je elke fout bij de juiste vraag en reset je de Turnstile-widget. Bij `429` vraag je de bezoeker het later opnieuw te proberen.

## Formulieren, versies en status

Een formulier heeft een key, een naam, een status en genummerde versies.

- De **key** kiest de merchant: kleine letters, cijfers en koppeltekens, beginnend met een letter, maximaal 64 tekens (bijvoorbeeld `quote-request`). Hij is uniek per winkel en kan niet meer veranderen zodra het formulier is gepubliceerd.
- Elke keer dat de merchant op **Publiceren** klikt, wordt het huidige concept een nieuwe, bevroren **versie** (1, 2, 3, ...). De publieke API levert altijd alleen de nieuwste gepubliceerde versie; niet-gepubliceerde wijzigingen bereiken je site nooit.
- Een formulier is `draft`, `active` of `archived`. Alleen een **actief** formulier dat minstens één keer is gepubliceerd kun je laden en versturen; al het andere geeft `404`. Publiceren activeert een formulier niet, en activeren of archiveren publiceert niets.
- Elke inzending onthoudt tegen welke versie ze is ingevuld, zodat de inbox van de merchant de vragen en optielabels blijft tonen die de bezoeker zag, ook als latere versies die hebben veranderd.

## Definitie: stappen en velden

De inhoud van een formulier is een lijst **stappen**, elk met een lijst **velden**. Een formulier heeft maximaal 20 stappen en in totaal 150 invoervelden (`heading` en `paragraph` tellen niet mee). Keys van stappen en velden zijn uniek binnen het formulier en voldoen aan `/^[a-z][a-z0-9_]*$/` (maximaal 50 tekens). Antwoorden worden verstuurd en opgeslagen onder de key van het veld.

Een stap:

| Veld | Type | Beschrijving |
| --- | --- | --- |
| `key` | string | Key van de stap. |
| `title` | string | Titel van de stap in de gevraagde taal. |
| `show_if` | object of null | De regel die bepaalt of de stap wordt getoond; `null` als hij altijd wordt getoond. |
| `fields` | array | De velden van de stap, op volgorde. |

Een veld:

| Veld | Type | Beschrijving |
| --- | --- | --- |
| `key` | string | Key van het veld; het antwoord wordt onder deze key verstuurd. |
| `kind` | string | Een van de types hieronder. |
| `label` | string | De vraag. Bij `heading` en `paragraph` is het de tekst die je toont; bij `consent` de tekst naast het vinkje; bij `hidden` de naam die de merchant in de inbox ziet (niet renderen). |
| `help` | string of null | Hulptekst onder de vraag. |
| `placeholder` | string of null | Placeholder van het invoerveld. |
| `required` | boolean | Of een zichtbaar veld moet worden beantwoord. Behandel een ontbrekende waarde als `false`. |
| `show_if` | object of null | De regel van het veld; `null` als het altijd wordt getoond (binnen een zichtbare stap). |
| `config` | object | Instellingen per type, `{}` als er geen zijn. |

Elke tekst is al teruggebracht tot één taal (zie [Talen](#talen)).

## Veldtypes

Invoertypes verzamelen een antwoord; `heading` en `paragraph` tonen alleen tekst en worden nooit verstuurd.

| Type | `config` | Antwoordwaarde |
| --- | --- | --- |
| `text` | `prefill?` | string, maximaal 255 tekens. |
| `textarea` | `prefill?` | string, maximaal 10000 tekens. |
| `number` | `prefill?` | JSON-getal (geheel of decimaal). `0` telt als antwoord. |
| `email` | `prefill?` | string: een geldig e-mailadres, maximaal 255 tekens. |
| `phone` | `prefill?` | string: 5 tot 30 tekens met cijfers, spaties en `()-.`, met een optionele `+` vooraan. |
| `date` | `prefill?` | string `YYYY-MM-DD`. |
| `select` | `options` (lijst van strings), `prefill?` | string: een van `options`. De optiestrings worden getoond zoals ze zijn geschreven, in elke taal. |
| `link` | `prefill?` | object `{url, label?}`: `url` maximaal 2000 tekens en beginnend met `https://`, `http://`, `/` (root-relatief), `mailto:` of `tel:`; `label` optioneel, maximaal 200 tekens. |
| `choice` | `options`, `multiple?`, `min?`, `max?`, `prefill?` | Eén optie-`value` (string), of met `multiple: true` een lijst optiewaarden (dubbele verwijderd), minstens `min` en hoogstens `max` items als die zijn ingesteld. |
| `consent` | `{}` | `true`. Verplicht betekent dat het vakje moet zijn aangevinkt. |
| `hidden` | `source?`: `query` (standaard) of `site` | string, maximaal 500 tekens. Wordt nooit gerenderd; zie [Verborgen velden](#verborgen-velden). |
| `file` | `accept` (`image`, `pdf` of beide), `max_files` (1 tot 10), `max_mb` (1 tot 20) | Een lijst uploadtokens, verstuurd in `files`, niet in `answers`; zie de opmerking hieronder. |
| `address` | `country` (ISO 3166-1 alpha-2, bijvoorbeeld `NL`), `lookup?`, `service_area?` | object `{postal_code, house_number, addition?, street, city, lat?, lon?}`; zie de opmerking over adresvelden hieronder. |
| `heading` | `{}` | Geen. Toon `label` als kop. |
| `paragraph` | `{}` | Geen. Toon `label` als tekst (maximaal 5000 tekens, platte tekst met regeleinden). |

Een `choice`-optie is `{value, label, description, icon}`: `value` is wat je verstuurt (maximaal 100 tekens), `label` de tekst van de optie, `description` een optionele tweede regel (string of null), `icon` een optionele iconnaam (`/^[a-z][a-z0-9-]{0,39}$/`) of `null`. Een `choice`-veld heeft 1 tot 30 opties; een `select`-veld heeft 1 tot 50 optiestrings van maximaal 100 tekens.

**Opties uit een collectie.** Een `choice`-veld kan zijn opties uit een objectcollectie halen in plaats van uit een vaste lijst. Zijn `config` bevat dan `options_source` in plaats van een met de hand geschreven lijst `options`:

```json title="Config"
{
  "multiple": false,
  "options_source": {
    "object_type": "services",
    "label": "title",
    "description": "summary",
    "sort": "position",
    "limit": 30
  }
}
```

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `object_type` | string | Ja | De key van een objecttype van de winkel. |
| `label` | string | Ja | De key van een tekstproperty die als optielabel wordt gebruikt. |
| `description` | string of null | Nee | De key van een tekstproperty die als optiebeschrijving wordt gebruikt. |
| `sort` | string of null | Nee | De key van een getalproperty. Opties staan hierop oplopend gesorteerd, records zonder waarde als laatste; zonder `sort` volgen ze de aanmaakvolgorde. |
| `limit` | integer | Nee | Hoeveel opties worden aangeboden, 1 tot 100. Standaard 30. |

Bij `GET /v1/api/forms/{key}` heeft de server de opties al ingevuld: `config.options` bevat de huidige opties in de gebruikelijke vorm `{value, label, description, icon}` (`icon` is `null`) en `config.options_source` blijft in het object staan. Render `options` en negeer `options_source`. De opties zijn de publiek zichtbare records van het type (concepten en ingeplande records blijven buiten beschouwing). `value` is het adres (de slug) van het record in de standaardtaal van de winkel als het type adressen heeft, anders het record-id; een record zonder adres in de standaardtaal wordt weggelaten. `label` en `description` worden gelezen in de gevraagde taal, met terugval op de standaardtaal. Ze worden bij elk request opgezocht, dus de opties volgen de collectie: ververs een gecachet formulier wanneer een [`object_record.published`-, `object_record.updated`-, `object_record.unpublished`- of `object_record.deleted`](/nl/api/webhooks/index)-webhook binnenkomt. Een inzending wordt gecontroleerd tegen de opties op dat moment, dus een antwoord dat geen optie meer is geeft `Choose one of the options.` Met `multiple: true` zijn `min` en `max` hoogstens `limit`. Een veld heeft `options` of `options_source`, nooit allebei.

Een `consent`-label kan één link bevatten in markdown, `[privacybeleid](https://example.com/privacy)`, altijd `https://`. Render die als link.

**Bestandsvelden.** Een `file`-veld beantwoord je in twee stappen. Upload eerst elk bestand met `POST /v1/api/forms/{key}/uploads` (multipart `field` en `file`); dat controleert het type aan de hand van de inhoud van het bestand en de grootte tegen `max_mb`, en geeft een token terug. Stuur de tokens daarna mee in het object `files` van de inzending, veld-key naar een lijst tokens (maximaal `max_files`), nooit in `answers`: een waarde die je voor een bestandsveld in `answers` stuurt, wordt genegeerd. Een token is 24 uur geldig, hoort bij één formulier en één veld, en werkt voor maar één inzending. Zie [Uploads](./uploads).

**Adresvelden.** Een `address`-veld beantwoord je in `answers` met één object. `postal_code`, `house_number`, `street` en `city` zijn verplicht (een ontbrekend onderdeel geeft `This field is required.`); `addition` is optioneel; `lat` en `lon` zijn optionele getallen (breedtegraad -90 tot 90, lengtegraad -180 tot 180). Spaties aan de randen worden verwijderd en een leeg onderdeel telt als ontbrekend. De server voegt `country` toe uit `config.country`. Een Nederlandse (`NL`) postcode bestaat uit vier cijfers en twee letters, met of zonder spatie of koppelteken, en wordt opgeslagen als `1234 AB`; voor andere landen is een postcode 2 tot 12 tekens. Na het verwijderen van de spaties is `postal_code` hoogstens 12 tekens, `house_number` hoogstens 20, `addition` hoogstens 10, en `street` en `city` elk hoogstens 255. Al het andere dat aan de waarde mankeert (geen object, een ongeldige postcode, een te lang onderdeel, coördinaten buiten bereik) geeft `Must be a valid address.` Een regel kan een adres alleen testen met `filled` of `empty`.

Met `config.lookup: true` kan je formulier `street`, `city`, `lat` en `lon` invullen op basis van de postcode en het huisnummer met [`GET /v1/api/forms/address-lookup`](./forms#get-v1apiformsaddress-lookup). Opzoeken is alleen beschikbaar voor `NL`; de merchant kan het niet aanzetten voor een ander land. De bezoeker kan straat en plaats ook zelf typen, bijvoorbeeld als het opzoeken niets vindt.

`config.service_area` is `{origin: {lat, lon, label?}, radius_km, mode, message}`. `radius_km` is meer dan 0 en hoogstens 1000, `mode` is `notice` of `block` en `message` is de tekst die bij een adres buiten het gebied wordt getoond. De server meet de afstand hemelsbreed in kilometers (één decimaal) van `origin` tot de coördinaten van het adres en bewaart `{distance_km, in_area}` per adresveld in `meta.address` van de inzending, die de merchant in de inbox ziet. Hoe de coördinaten worden bepaald en wanneer `block` een inzending weigert, staat onder [Adresvelden en het werkgebied](./submissions#adresvelden-en-het-werkgebied).

### Wat telt als leeg

Een antwoord is leeg als het `null` is, een string met alleen spaties, tabs of regeleinden, een lege lijst `[]`, een leeg object `{}` of `false`. Al het andere, ook `0`, is een antwoord. Een leeg antwoord op een verplicht zichtbaar veld is een fout; een leeg antwoord op een optioneel veld wordt gewoon niet opgeslagen.

## Regels

`show_if` op een stap of veld bepaalt of die wordt getoond. Het is één voorwaarde, of één groep voorwaarden:

```json title="Rules"
{ "field": "services", "op": "contains", "value": "website" }

{ "all": [
  { "field": "services", "op": "contains", "value": "website" },
  { "field": "budget", "op": "not_equals", "value": "small" }
] }

{ "any": [
  { "field": "job_type", "op": "equals", "value": "roof" },
  { "field": "job_type", "op": "equals", "value": "paint" }
] }
```

`all` klopt als elke voorwaarde klopt, `any` als er minstens één klopt. Groepen kunnen niet genest worden. Een voorwaarde noemt een invoerveld dat **eerder** in het formulier staat: voor de regel van een veld een vraag ervóór (in een eerdere stap of hoger in dezelfde stap); voor de regel van een stap een vraag in een eerdere stap.

| `op` | Klopt als |
| --- | --- |
| `equals` | Een lijstantwoord precies één item heeft en dat gelijk is aan `value`; elk ander antwoord gelijk is aan `value`. Een leeg antwoord is nooit gelijk. |
| `not_equals` | `equals` niet klopt (een leeg antwoord klopt dus wel). |
| `contains` | Een lijstantwoord een item bevat dat gelijk is aan `value`; elk ander antwoord gelijk is aan `value`. |
| `filled` | Het antwoord niet leeg is. `value` wordt genegeerd. |
| `empty` | Het antwoord leeg is. `value` wordt genegeerd. |

`value` is een string, getal of boolean bij `equals`, `not_equals` en `contains`. Waarden worden als tekst vergeleken: strings zoals ze zijn, `true` en `false` als `"true"` en `"false"`, gehele getallen zonder decimalen (`2`, `2.0` en `"2"` zijn gelijk), andere getallen in hun kortste vorm (`2.5`). `true` is gelijk aan `"true"` maar niet aan `1`; objecten en `null` zijn nooit ergens aan gelijk.

Loop het formulier op volgorde door. Een stap wordt getoond als zijn regel klopt; een veld als zijn stap wordt getoond en zijn eigen regel klopt. Een voorwaarde leest een antwoord alleen zolang dat veld wordt getoond: het antwoord van een verborgen veld leest als leeg, dus wie een veld verbergt, verbergt ook alles wat ervan afhangt. De server past bij het versturen precies deze regels toe: antwoorden op velden die op dat moment verborgen zijn, vallen zonder fout weg en zijn niet verplicht. Uploadtokens voor een verborgen bestandsveld vallen ook weg en worden niet geclaimd.

## Verborgen velden

Een `hidden`-veld wordt nooit getoond, maar de waarde wordt met de inzending opgeslagen (bijvoorbeeld een campagnecode of de pagina waar de bezoeker vandaan kwam).

- `source: "query"` (standaard): neem de waarde uit de queryparameter van de pagina-URL met dezelfde naam als de veld-key, bijvoorbeeld `?campaign=spring` voor een veld met key `campaign`.
- `source: "site"`: je site zet de waarde zelf wanneer het formulier wordt getoond.

De waarde is een string van maximaal 500 tekens. Zichtbaarheidsregels gelden ook voor verborgen velden.

## Vooraf invullen vanuit de URL

Een veld met `config.prefill` op `true` mag worden ingevuld vanuit de queryparameter van de pagina-URL met dezelfde naam als de veld-key (`?email=ada@example.com`). De bezoeker kan het nog wijzigen; de server behandelt het als een gewoon antwoord. Toestemmings- en bestandsvelden worden nooit vooraf ingevuld.

## Talen

Formulierteksten worden in het dashboard per taal geschreven. `GET /v1/api/forms/{key}?locale=` geeft elke tekst terug in die taal, met de standaardtaal van de winkel als terugval en daarna een willekeurige taal die een tekst heeft. Een `locale` die niet is ingeschakeld voor de winkel levert de standaardtaal (geen fout). Stuur dezelfde `locale` mee bij de inzending: die bepaalt de taal van het bedankbericht in de response en van de bevestigingsmail aan de bezoeker.

## Bezoekers-IP en de limiet per uur

Elk formulier heeft een limiet op het aantal inzendingen per bezoeker per uur, ingesteld door de merchant (1 tot 100, standaard 5). Elke poging telt, ook pogingen die met `422` op validatie stranden; boven de limiet antwoordt de API `429 {"message": "Too many submissions. Try again later."}`. Het uur begint bij de eerste poging van de bezoeker.

Borkol herkent de bezoeker aan de header `X-Borkol-Visitor-Ip`, die je server vult met het IP-adres van de bezoeker (IPv4 of IPv6). Vertrouw alleen een adres dat je eigen proxy of host zet: de eerste hop van `X-Forwarded-For` kan de bezoeker vervalsen, tenzij een vertrouwde proxy hem overschrijft. Ontbreekt de header of is het geen geldig IP-adres, dan gebruikt Borkol het adres van de verbinding, en dat is je server: al je bezoekers delen dan één limiet. De header wordt alleen op de formulierroutes gelezen.

Het ruwe IP-adres wordt nooit opgeslagen: de inzending bewaart een HMAC-SHA256-hash ervan.

Uploads hebben een eigen limiet: 30 per formulier per bezoeker per uur, op dezelfde manier geteld (zie [Uploads](./uploads)).

## Honeypot

Voeg een invoerveld toe dat mensen nooit zien (verborgen met CSS, niet `type="hidden"`, met `tabindex="-1"` en `autocomplete="off"`) en stuur de waarde mee als `honeypot`. Bots vullen vaak elk veld in. Heeft `honeypot` een andere waarde dan een lege string of `null`, dan antwoordt Borkol `201` met `number: null` en de bedankinstellingen van het formulier, slaat niets op, verstuurt geen mail of webhook, controleert Turnstile niet en telt de poging niet mee voor de limiet per uur.

## Turnstile

Een merchant kan [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) per formulier aanzetten en slaat de Turnstile-site key en secret key van de winkel één keer op in het dashboard. Heeft het formulier Turnstile aan en zijn beide keys ingesteld, dan geeft `GET /v1/api/forms/{key}` `settings.turnstile_site_key` terug; anders ontbreekt het.

- Render de Turnstile-widget met die site key en stuur het token dat hij oplevert mee als `turnstile_token` bij de inzending.
- Borkol controleert het token bij Cloudflare (met het bezoekers-IP uit `X-Borkol-Visitor-Ip`) na de limiet per uur en vóór de controle van de antwoorden. Een ontbrekend, ongeldig, verlopen of al gebruikt token, een fout bij Cloudflare of een controle die langer duurt dan 5 seconden geeft `422` met de fout op `turnstile`: `The spam check failed. Reload the page and try again.`
- **Een token werkt maar één keer.** Omdat het token vóór de antwoorden wordt gecontroleerd, heeft elke `422` (ook een fout antwoord) het opgebruikt. Reset de widget na elke `422` en stuur het nieuwe token mee bij de volgende poging.
- Een mislukte controle telt wel mee voor de limiet per uur.

Heeft het formulier Turnstile aan, maar heeft de winkel niet beide keys ingesteld, dan wordt er niets gecontroleerd: `turnstile_site_key` ontbreekt, een `turnstile_token` wordt genegeerd en inzendingen worden zonder controle geaccepteerd. Het dashboard waarschuwt de merchant hiervoor bij het formulier en op de pagina met Forms-instellingen.

## Wat er na een inzending gebeurt

Borkol slaat de zichtbare antwoorden op (inclusief waarden van `hidden`-velden), samen met de formulierversie, de taal en de paginagegevens die je meestuurt. De `name` van de bezoeker komt uit het veld met key `name`, `email` uit het eerste beantwoorde `email`-veld en `phone` uit het eerste beantwoorde `phone`-veld. Geüploade bestanden worden aan de inzending gekoppeld en privé opgeslagen: de merchant opent ze vanuit de inbox, en er bestaat geen publieke URL. Komt dat e-mailadres overeen met precies één contact van de winkel (hoofdletterongevoelig), dan wordt de inzending aan dat contact gekoppeld; Borkol maakt nooit een contact aan.

Nadat de inzending is opgeslagen, mailt Borkol de merchant, stuurt de bezoeker een bevestiging als het formulier die heeft en de bezoeker een e-mailadres gaf, voegt een melding toe in het dashboard en verstuurt de webhook [`form_submission.created`](/nl/api/webhooks/index). Geen van deze stappen kan het request laten mislukken: eenmaal opgeslagen is het antwoord `201`.

De publieke API heeft geen endpoint om inzendingen terug te lezen. De merchant handelt ze af in de inbox van het dashboard.

## Het pakket @borkol/forms

`@borkol/forms` is een TypeScript-pakket zonder dependencies (MIT) met de types van deze endpoints en dezelfde regels als de server, zodat je site dezelfde stappen en dezelfde fouten kan tonen voordat hij verstuurt. Installeer `@borkol/forms`; React is een optionele peer, alleen gebruikt door `@borkol/forms/react`.

```ts title="Rules"
import { visible, check, prefill, type PublicForm } from "@borkol/forms";

const form: PublicForm = (await res.json()).data;

visible(form, answers);          // { steps: ["what", ...], fields: ["services", ...] }
check(form, answers);            // { errors: { "answers.email": ["Must be an email address."] }, clean: {...} }
check(form, answers, "what");    // check one step only
prefill(form, new URLSearchParams(location.search));
```

- `visible` geeft de keys van de getoonde stappen en velden terug.
- `check` geeft `errors` terug met keys `answers.{key}` en de Engelse meldingen van de server (geëxporteerd als `MESSAGES`, zodat je ze naar je eigen vertalingen kunt omzetten) en `clean`, de antwoorden om te versturen: velden die door hun regels verborgen zijn, weergavetypes en onbekende keys zijn eruit. Waarden van het type `hidden` blijven staan.
- `prefill` leest de antwoorden voor velden met `config.prefill` en `hidden`-velden met bron `query` uit de queryparameters van de URL.

`@borkol/forms/react` rendert de stappen voor je met je eigen componenten:

```tsx title="React"
import { BorkolForm } from "@borkol/forms/react";

<BorkolForm
  form={form}
  locale="nl"
  onSubmit={(body) => submitToMyServer(body)} // resolves to { number, thank_you }
  components={{
    text: ({ field, value, onChange, errors }) => (
      <label>{field.label}<input value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} />{errors[0]}</label>
    ),
    heading: ({ field }) => <h2>{field.label}</h2>,
    nav: ({ isFirst, isLast, back, submitting }) => (
      <>{!isFirst && <button type="button" onClick={back}>Back</button>}<button disabled={submitting}>{isLast ? "Send" : "Next"}</button></>
    ),
    done: ({ result }) => <p>{result.thank_you.message}</p>,
  }}
/>;
```

`onSubmit` krijgt de body van de inzending, `{locale, answers, files?, turnstile_token?}` met de gecontroleerde antwoorden; post die naar je eigen server, die er `meta` en `honeypot` aan kan toevoegen en hem doorstuurt met de `forms:write`-sleutel en de header met het bezoekers-IP. Om de `422`-fouten van de server te tonen, gooi je de response-body (het object met `errors`) vanuit `onSubmit`: fouten op getoonde vragen verschijnen daar, elke andere key (`turnstile`, een vraag die nu verborgen is) gaat naar `formErrors`, dat het `nav`-component en het `formError`-component krijgen. Al het andere dat je gooit (een netwerkfout) toont daar een algemene melding; `submit()` wordt nooit afgewezen.

Meer opties:

- `upload(field, file)` slaat één bestand op voor een `file`-veld: post het als multipart (`field`, `file`) naar een route op je server die het doorstuurt naar `POST /v1/api/forms/{key}/uploads`, en resolve naar de `data` van de response (`{token, name, size}`). Gooi de `422`-body om de melding onder de vraag te tonen. Het antwoord van een bestandsveld is dan de lijst geüploade bestanden; het `file`-component krijgt `upload`, `remove`, `uploading` en `accept` (voor het attribuut `accept` van het invoerveld). Het pakket controleert `max_files` en `max_mb` vóór het uploaden en verstuurt de tokens onder `files`.
- `turnstileToken` is het huidige token van de Turnstile-widget, verstuurd als `turnstile_token`.
- `onServerErrors(errors)` wordt aangeroepen na elke mislukte verzending. Reset daar de Turnstile-widget: het token is opgebruikt.
- `persistKey` bewaart het concept van de bezoeker (antwoorden en huidige stap) in `localStorage[persistKey]` en herstelt het direct na het mounten, zodat een herlaad verdergaat waar de bezoeker was. `initialAnswers` gaan voor bij de keys die ze zetten, bestandsantwoorden worden niet bewaard en het concept wordt na een geslaagde inzending verwijderd. Een concept ouder dan 30 dagen wordt genegeerd en verwijderd. Zonder `localStorage` werkt het formulier zoals voorheen.
- Een `address`-veld krijgt de gewone `components.address`-props (`field`, `value`, `onChange`, `errors`); het opzoeken blijft in je eigen component. `formatAddress`, `normaliseAddress` en `distanceKm` worden vanuit de root van het pakket geëxporteerd.

`useBorkolForm({ form, initialAnswers, persistKey, locale, onSubmit, upload, turnstileToken, onServerErrors })` geeft dezelfde state voor een volledig eigen layout: `step`, `stepIndex`, `steps`, `fields`, `answers`, `errors` (per veld-key), `formErrors`, `setAnswer`, `upload(key, files)`, `removeFile(key, token)`, `uploading`, `next()` (controleert de huidige stap), `back()`, `submit()`, `submitting` en `result` (`result.number` is `null` na een honeypot-treffer). Geef `hidden`-velden met bron `site` mee in `initialAnswers`.

De server blijft leidend: hij controleert elke inzending opnieuw, wat de site ook deed.
