# Forms

The Forms module lets a merchant build contact, quote and intake forms in the client dashboard and receive the answers in a dashboard inbox. Your site does not hard-code the questions: it loads the published form by its **key**, renders it, and sends the visitor's answers back. Borkol checks the answers again on the server, stores the submission, numbers it, emails the merchant and the visitor, and fires a webhook.

All four endpoints live under `/v1/api/forms`, require the `forms` module to be enabled for the store (`403 {"error":"Module not enabled: forms"}` otherwise), and are covered by the store-wide rate limit of 120 requests per minute (see [Errors and rate limits](/api/getting-started/errors-and-rate-limits)).

| Endpoint | Scope | Purpose |
| --- | --- | --- |
| `GET /v1/api/forms/{key}` | `forms:read` | Load the published version of an active form in one language. See [Load a form](./forms). |
| `GET /v1/api/forms/address-lookup` | `forms:read` | Find the street and city for a postal code and house number, for an `address` field. See [Address lookup](./forms#get-v1apiformsaddress-lookup). |
| `POST /v1/api/forms/{key}/uploads` | `forms:write` | Store one file for a `file` field and get a token for the submission. See [Uploads](./uploads). |
| `POST /v1/api/forms/{key}/submissions` | `forms:write` | Send one visitor's answers. See [Submissions](./submissions). |

## Keep forms:write keys on your server

A key with `forms:write` can upload files and send submissions in the merchant's name, and every submission sends mail. **Never put a `forms:write` key in browser code**, a mobile app bundle, or anything else a visitor can read. Load the form, upload files and submit from your server (a route handler, a server action, a backend endpoint) and let the browser talk only to your server. One server-side key with both `forms:read` and `forms:write` (for example in a `BORKOL_FORMS_KEY` environment variable) is fine, since your server makes all three calls. A separate `forms:read`-only key for loading forms is optional.

Because every request then comes from your server's address, pass the visitor's IP address in the `X-Borkol-Visitor-Ip` header on the upload and submit requests. Borkol applies the hourly limits per visitor address (see [Visitor IP and the hourly limit](#visitor-ip-and-the-hourly-limit)).

## The flow

1. **Load** `GET /v1/api/forms/{key}?locale=nl` on your server. Cache it; refresh when the [`form.published`](/api/webhooks/index) webhook arrives.
2. **Render** the steps in order. Show a step or a field only while its `show_if` rule passes (see [Rules](#rules)). Optionally prefill answers from the page URL (see [Prefill from the URL](#prefill-from-the-url)).
3. **Check** each step before moving on, with the same rules the server uses. The `@borkol/forms` package implements them (see [The @borkol/forms package](#the-borkolforms-package)).
4. **Upload** each file the visitor picks for a `file` field: `POST /v1/api/forms/{key}/uploads` from your server returns a token (see [Uploads](./uploads)).
5. **Spam check**: when the form answers with `settings.turnstile_site_key`, show the Cloudflare Turnstile widget and send its token (see [Turnstile](#turnstile)).
6. **Submit** `POST /v1/api/forms/{key}/submissions` from your server with the answers, the upload tokens, the Turnstile token, the visitor's language, the page details and the visitor IP header.
7. **Finish**: on `201`, show `thank_you.message` or send the visitor to `thank_you.redirect_url`. On `422`, show each error on its question and reset the Turnstile widget. On `429`, ask the visitor to try again later.

## Forms, versions and status

A form has a key, a name, a status and numbered versions.

- The **key** is chosen by the merchant: lowercase letters, digits and hyphens, starting with a letter, at most 64 characters (for example `quote-request`). It is unique per store and cannot change once the form has been published.
- Each time the merchant clicks **Publish**, the current draft becomes a new, frozen **version** (1, 2, 3, ...). The public API only ever serves the latest published version; unpublished edits never reach your site.
- A form is `draft`, `active` or `archived`. Only an **active** form that has been published at least once can be loaded and submitted; anything else answers `404`. Publishing does not activate a form, and activating or archiving it does not publish anything.
- Every submission records the version it was answered against, so the merchant's inbox keeps showing the questions and option labels the visitor saw, even after later versions changed them.

## Definition: steps and fields

The form body is a list of **steps**, each with a list of **fields**. A form has at most 20 steps and 150 input fields in total (`heading` and `paragraph` do not count). Step and field keys are unique within the form and match `/^[a-z][a-z0-9_]*$/` (at most 50 characters). Answers are sent and stored under the field key.

A step:

| Field | Type | Description |
| --- | --- | --- |
| `key` | string | Step key. |
| `title` | string | Step title in the requested language. |
| `show_if` | object or null | The rule that decides whether the step is shown; `null` when it is always shown. |
| `fields` | array | The step's fields, in order. |

A field:

| Field | Type | Description |
| --- | --- | --- |
| `key` | string | Field key; the answer is sent under this key. |
| `kind` | string | One of the kinds below. |
| `label` | string | The question. For `heading` and `paragraph` it is the text to show; for `consent` it is the text next to the checkbox; for `hidden` it is the name the merchant sees in the inbox (do not render it). |
| `help` | string or null | Help text under the question. |
| `placeholder` | string or null | Input placeholder. |
| `required` | boolean | Whether a visible field must be answered. Treat a missing value as `false`. |
| `show_if` | object or null | The field's rule; `null` when it is always shown (within a visible step). |
| `config` | object | Kind-specific settings, `{}` when there are none. |

Every text is already resolved to one language (see [Languages](#languages)).

## Field kinds

Input kinds collect an answer; `heading` and `paragraph` only show text and are never submitted.

| Kind | `config` | Answer value |
| --- | --- | --- |
| `text` | `prefill?` | string, at most 255 characters. |
| `textarea` | `prefill?` | string, at most 10000 characters. |
| `number` | `prefill?` | JSON number (integer or decimal). `0` counts as an answer. |
| `email` | `prefill?` | string: a valid email address, at most 255 characters. |
| `phone` | `prefill?` | string: 5 to 30 characters of digits, spaces and `()-.`, with an optional leading `+`. |
| `date` | `prefill?` | string `YYYY-MM-DD`. |
| `select` | `options` (list of strings), `prefill?` | string: one of `options`. The option strings are shown as written, in every language. |
| `link` | `prefill?` | object `{url, label?}`: `url` at most 2000 characters and starting with `https://`, `http://`, `/` (root-relative), `mailto:` or `tel:`; `label` optional, at most 200 characters. |
| `choice` | `options`, `multiple?`, `min?`, `max?`, `prefill?` | One option `value` (string), or with `multiple: true` a list of option values (duplicates removed), at least `min` and at most `max` items when set. |
| `consent` | `{}` | `true`. Required means the box must be ticked. |
| `hidden` | `source?`: `query` (default) or `site` | string, at most 500 characters. Never rendered; see [Hidden fields](#hidden-fields). |
| `file` | `accept` (`image`, `pdf` or both), `max_files` (1 to 10), `max_mb` (1 to 20) | A list of upload tokens, sent in `files`, not in `answers`; see the note below. |
| `address` | `country` (ISO 3166-1 alpha-2, for example `NL`), `lookup?`, `service_area?` | object `{postal_code, house_number, addition?, street, city, lat?, lon?}`; see the note on address fields below. |
| `heading` | `{}` | None. Show `label` as a heading. |
| `paragraph` | `{}` | None. Show `label` as text (up to 5000 characters, plain text with line breaks). |

A `choice` option is `{value, label, description, icon}`: `value` is what you submit (at most 100 characters), `label` the option text, `description` an optional second line (string or null), `icon` an optional icon name (`/^[a-z][a-z0-9-]{0,39}$/`) or `null`. A `choice` field has 1 to 30 options; a `select` field has 1 to 50 option strings of at most 100 characters.

**Options from a collection.** A `choice` field can take its options from an object collection instead of a fixed list. Its `config` then carries `options_source` instead of a hand-written `options` list:

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

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `object_type` | string | Yes | The key of an object type of the store. |
| `label` | string | Yes | The key of a text property used as the option label. |
| `description` | string or null | No | The key of a text property used as the option description. |
| `sort` | string or null | No | The key of a number property. Options are ordered by it ascending, records without a value last; without `sort` they follow creation order. |
| `limit` | integer | No | How many options to offer, 1 to 100. Default 30. |

On `GET /v1/api/forms/{key}` the server has already filled the options in: `config.options` holds the current options in the usual `{value, label, description, icon}` shape (`icon` is `null`) and `config.options_source` stays in the object. Render `options` and ignore `options_source`. The options are the publicly visible records of the type (drafts and scheduled records are left out). `value` is the record's address (slug) in the store's default language when the type has addresses, otherwise the record id; a record without an address in the default language is left out. `label` and `description` are read in the requested language, falling back to the default language. They are looked up on every request, so the options follow the collection: refresh a cached form when an [`object_record.published`, `object_record.updated`, `object_record.unpublished` or `object_record.deleted`](/api/webhooks/index) webhook arrives. A submission is checked against the options at that moment, so an answer that is no longer an option answers `Choose one of the options.` With `multiple: true`, `min` and `max` are at most `limit`. A field has either `options` or `options_source`, never both.

A `consent` label may contain one link written as markdown, `[privacy policy](https://example.com/privacy)`, always `https://`. Render it as a link.

**File fields.** A `file` field is answered in two steps. First upload each file with `POST /v1/api/forms/{key}/uploads` (multipart `field` and `file`), which checks the type by the file's content and the size against `max_mb`, and returns a token. Then send the tokens in the submission's `files` object, field key to a list of tokens (at most `max_files`), never in `answers`: a value sent for a file field in `answers` is ignored. A token is valid for 24 hours, belongs to one form and one field, and works for one submission only. See [Uploads](./uploads).

**Address fields.** An `address` field is answered with one object in `answers`. `postal_code`, `house_number`, `street` and `city` are required (a missing part answers `This field is required.`); `addition` is optional; `lat` and `lon` are optional numbers (latitude -90 to 90, longitude -180 to 180). Surrounding spaces are trimmed and a blank part counts as missing. The server adds `country` from `config.country`. A Dutch (`NL`) postal code is four digits and two letters, with or without a space or hyphen, and is stored as `1234 AB`; for other countries a postal code is 2 to 12 characters. After trimming, `postal_code` is at most 12 characters, `house_number` at most 20, `addition` at most 10, and `street` and `city` at most 255 each. Anything else wrong with the value (not an object, an invalid postal code, a part that is too long, coordinates out of range) answers `Must be a valid address.` A rule can only test an address with `filled` or `empty`.

With `config.lookup: true`, your form can fill `street`, `city`, `lat` and `lon` from the postal code and house number with [`GET /v1/api/forms/address-lookup`](./forms#get-v1apiformsaddress-lookup). Lookup is available for `NL` only; the merchant cannot switch it on for another country. The visitor can still type the street and city themselves, for example when the lookup finds nothing.

`config.service_area` is `{origin: {lat, lon, label?}, radius_km, mode, message}`. `radius_km` is more than 0 and at most 1000, `mode` is `notice` or `block`, and `message` is the text to show for an address outside the area. The server measures the straight-line distance in kilometres (one decimal) from `origin` to the address coordinates, and stores `{distance_km, in_area}` per address field in the submission's `meta.address`, which the merchant sees in the inbox. How the coordinates are decided, and when `block` refuses a submission, is described under [Address fields and the service area](./submissions#address-fields-and-the-service-area).

### What counts as empty

An answer is empty when it is `null`, a string of only spaces, tabs or line breaks, an empty list `[]`, an empty object `{}`, or `false`. Everything else, including `0`, is an answer. An empty answer to a required visible field is an error; an empty answer to an optional field is simply not stored.

## Rules

`show_if` on a step or field decides whether it is shown. It is either one condition, or one group of conditions:

```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` passes when every condition passes, `any` when at least one does. Groups do not nest. A condition names an input field that comes **earlier** in the form: for a field rule, a question before it (in an earlier step or earlier in the same step); for a step rule, a question in an earlier step.

| `op` | Passes when |
| --- | --- |
| `equals` | A list answer has exactly one item and it equals `value`; any other answer equals `value`. An empty answer never equals. |
| `not_equals` | `equals` does not pass (so an empty answer passes). |
| `contains` | A list answer has an item equal to `value`; any other answer equals `value`. |
| `filled` | The answer is not empty. `value` is ignored. |
| `empty` | The answer is empty. `value` is ignored. |

`value` is a string, number or boolean for `equals`, `not_equals` and `contains`. Values are compared as text: strings as they are, `true` and `false` as `"true"` and `"false"`, whole numbers without decimals (`2`, `2.0` and `"2"` are equal), other numbers in their shortest form (`2.5`). `true` equals `"true"` but not `1`; objects and `null` never equal anything.

Walk the form in order. A step is shown when its rule passes; a field when its step is shown and its own rule passes. A condition reads an answer only while that field is shown: the answer of a hidden field reads as empty, so hiding a field also hides everything that depends on it. The server applies exactly these rules on submit: answers to fields that are hidden at that moment are dropped without an error and are not required. Upload tokens sent for a hidden file field are dropped too and are not claimed.

## Hidden fields

A `hidden` field is never shown, but its value is stored with the submission (for example a campaign code or the page the visitor came from).

- `source: "query"` (the default): take the value from the page URL's query parameter named like the field key, for example `?campaign=spring` for a field keyed `campaign`.
- `source: "site"`: your site sets the value itself when it shows the form.

The value is a string of at most 500 characters. Visibility rules apply to hidden fields too.

## Prefill from the URL

A field whose `config.prefill` is `true` may be filled from the page URL's query parameter named like the field key (`?email=ada@example.com`). The visitor can still change it; the server treats it as a normal answer. Consent and file fields are never prefilled.

## Languages

Form texts are written per language in the dashboard. `GET /v1/api/forms/{key}?locale=` returns every text in that language, falling back to the store's default language, then to any language that has a text. A `locale` that is not enabled for the store serves the default language (no error). Send the same `locale` on the submission: it sets the language of the thank-you message in the response and of the confirmation email the visitor receives.

## Visitor IP and the hourly limit

Each form has a limit of submissions per visitor per hour, set by the merchant (1 to 100, default 5). Every attempt counts, including attempts that fail validation with `422`; over the limit the API answers `429 {"message": "Too many submissions. Try again later."}`. The hour starts with the visitor's first attempt.

Borkol identifies the visitor by the `X-Borkol-Visitor-Ip` header, which your server sets to the visitor's IP address (IPv4 or IPv6). Only trust an address your own proxy or host sets: the first hop of `X-Forwarded-For` can be forged by the visitor unless a trusted proxy overwrites it. When the header is missing or is not a valid IP address, Borkol uses the address of the connection, which is your server, and all your visitors then share one limit. The header is only read on the forms routes.

The raw IP address is never stored: the submission keeps an HMAC-SHA256 hash of it.

Uploads have a limit of their own: 30 per form per visitor per hour, counted the same way (see [Uploads](./uploads)).

## Honeypot

Add an input that people never see (hidden with CSS, not `type="hidden"`, with `tabindex="-1"` and `autocomplete="off"`) and send its value as `honeypot`. Bots tend to fill every input. When `honeypot` is any value other than an empty string or `null`, Borkol answers `201` with `number: null` and the form's thank-you settings, stores nothing, sends no mail or webhook, does not check Turnstile, and does not count the attempt toward the hourly limit.

## Turnstile

A merchant can switch on [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) per form, and stores the store's Turnstile site key and secret key once in the dashboard. When the form has Turnstile on and both keys are set, `GET /v1/api/forms/{key}` returns `settings.turnstile_site_key`; it is absent otherwise.

- Render the Turnstile widget with that site key and send the token it produces as `turnstile_token` with the submission.
- Borkol verifies the token with Cloudflare (with the visitor IP from `X-Borkol-Visitor-Ip`) after the hourly limit and before it checks the answers. A missing, invalid, expired or already used token, a Cloudflare error or a check that takes longer than 5 seconds answers `422` with the error on `turnstile`: `The spam check failed. Reload the page and try again.`
- **A token works only once.** Because the token is checked before the answers, any `422` (a wrong answer too) has used it up. Reset the widget after every `422` and send the new token with the next attempt.
- A failed check still counts toward the hourly limit.

When the form has Turnstile on but the store has not set both keys, nothing is verified: `turnstile_site_key` is absent, a `turnstile_token` is ignored and submissions are accepted without the check. The dashboard warns the merchant about this on the form and on the Forms settings page.

## What happens after a submission

Borkol stores the visible answers (including `hidden` field values) together with the form version, the language, and the page details you send. It takes the visitor's `name` from the field keyed `name`, `email` from the first answered `email` field, and `phone` from the first answered `phone` field. Uploaded files are attached to the submission and stored privately: the merchant opens them from the inbox, and no public URL exists. When that email matches exactly one contact of the store (ignoring case), the submission is linked to the contact; Borkol never creates a contact.

After the submission is stored, Borkol emails the merchant, emails the visitor a confirmation when the form has one and the visitor gave an email address, adds a dashboard notification, and sends the [`form_submission.created`](/api/webhooks/index) webhook. None of these can fail the request: once stored, the answer is `201`.

The public API has no endpoint to read submissions back. The merchant handles them in the dashboard inbox.

## The @borkol/forms package

`@borkol/forms` is a zero-dependency TypeScript package (MIT) with the types of these endpoints and the same rules as the server, so your site can show the same steps and the same errors before it submits. Install `@borkol/forms`; React is an optional peer, used only by `@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` returns the keys of the shown steps and fields.
- `check` returns `errors` keyed `answers.{key}` with the server's English messages (exported as `MESSAGES`, so you can map them to your own translations) and `clean`, the answers to send: fields hidden by their rules, display kinds and unknown keys removed. `hidden`-kind values are kept.
- `prefill` reads the answers for fields with `config.prefill` and `hidden` fields with source `query` from URL query parameters.

`@borkol/forms/react` renders the steps for you with your own components:

```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` receives the submission body `{locale, answers, files?, turnstile_token?}` with the checked answers; post it to your own server, which may add `meta` and `honeypot` and sends it on with the `forms:write` key and the visitor IP header. To show the server's `422` errors, throw the response body (the object with `errors`) from `onSubmit`: errors on shown questions appear on them, any other key (`turnstile`, a question that is hidden now) goes to `formErrors`, which the `nav` component and the `formError` component receive. Anything else thrown (a network error) shows a generic message there; `submit()` never rejects.

More options:

- `upload(field, file)` stores one file for a `file` field: post it as multipart (`field`, `file`) to a route on your server that forwards it to `POST /v1/api/forms/{key}/uploads`, and resolve to the response's `data` (`{token, name, size}`). Throw the `422` body to show its message under the question. A file field's answer is then the list of uploaded files; the `file` component receives `upload`, `remove`, `uploading` and `accept` (for the input's `accept` attribute). The package checks `max_files` and `max_mb` before uploading, and sends the tokens under `files`.
- `turnstileToken` is the Turnstile widget's current token, sent as `turnstile_token`.
- `onServerErrors(errors)` is called after every failed submit. Reset the Turnstile widget there: its token is used up.
- `persistKey` keeps the visitor's draft (answers and current step) in `localStorage[persistKey]` and restores it right after mount, so a reload resumes where the visitor was. `initialAnswers` win for the keys they set, file answers are not stored, and the draft is removed after a successful submit. A draft older than 30 days is ignored and removed. Without `localStorage` the form works as before.
- An `address` field gets the normal `components.address` props (`field`, `value`, `onChange`, `errors`); the lookup stays in your component. `formatAddress`, `normaliseAddress` and `distanceKm` are exported from the package root.

`useBorkolForm({ form, initialAnswers, persistKey, locale, onSubmit, upload, turnstileToken, onServerErrors })` gives the same state for a fully custom layout: `step`, `stepIndex`, `steps`, `fields`, `answers`, `errors` (keyed by field key), `formErrors`, `setAnswer`, `upload(key, files)`, `removeFile(key, token)`, `uploading`, `next()` (checks the current step), `back()`, `submit()`, `submitting` and `result` (`result.number` is `null` after a honeypot hit). Pass `hidden` fields with source `site` in `initialAnswers`.

The server stays authoritative: it checks every submission again, whatever the site did.
