Forms

Forms

How a site loads a merchant's form, renders its steps and rules, uploads files and sends submissions from its server, with field kinds, rule semantics, spam checks, keys and limits.

View as Markdown

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).

EndpointScopePurpose
GET /v1/api/forms/{key}forms:readLoad the published version of an active form in one language. See Load a form.
GET /v1/api/forms/address-lookupforms:readFind the street and city for a postal code and house number, for an address field. See Address lookup.
POST /v1/api/forms/{key}/uploadsforms:writeStore one file for a file field and get a token for the submission. See Uploads.
POST /v1/api/forms/{key}/submissionsforms:writeSend one visitor's answers. See 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).

The flow

  1. Load GET /v1/api/forms/{key}?locale=nl on your server. Cache it; refresh when the form.published webhook arrives.
  2. Render the steps in order. Show a step or a field only while its show_if rule passes (see Rules). Optionally prefill answers from the page URL (see 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).
  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).
  5. Spam check: when the form answers with settings.turnstile_site_key, show the Cloudflare Turnstile widget and send its token (see 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:

FieldTypeDescription
keystringStep key.
titlestringStep title in the requested language.
show_ifobject or nullThe rule that decides whether the step is shown; null when it is always shown.
fieldsarrayThe step's fields, in order.

A field:

FieldTypeDescription
keystringField key; the answer is sent under this key.
kindstringOne of the kinds below.
labelstringThe 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).
helpstring or nullHelp text under the question.
placeholderstring or nullInput placeholder.
requiredbooleanWhether a visible field must be answered. Treat a missing value as false.
show_ifobject or nullThe field's rule; null when it is always shown (within a visible step).
configobjectKind-specific settings, {} when there are none.

Every text is already resolved to one language (see Languages).

Field kinds

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

KindconfigAnswer value
textprefill?string, at most 255 characters.
textareaprefill?string, at most 10000 characters.
numberprefill?JSON number (integer or decimal). 0 counts as an answer.
emailprefill?string: a valid email address, at most 255 characters.
phoneprefill?string: 5 to 30 characters of digits, spaces and ()-., with an optional leading +.
dateprefill?string YYYY-MM-DD.
selectoptions (list of strings), prefill?string: one of options. The option strings are shown as written, in every language.
linkprefill?object {url, label?}: url at most 2000 characters and starting with https://, http://, / (root-relative), mailto: or tel:; label optional, at most 200 characters.
choiceoptions, 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.
hiddensource?: query (default) or sitestring, at most 500 characters. Never rendered; see Hidden fields.
fileaccept (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.
addresscountry (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:

Config
{
  "multiple": false,
  "options_source": {
    "object_type": "services",
    "label": "title",
    "description": "summary",
    "sort": "position",
    "limit": 30
  }
}
NameTypeRequiredDescription
object_typestringYesThe key of an object type of the store.
labelstringYesThe key of a text property used as the option label.
descriptionstring or nullNoThe key of a text property used as the option description.
sortstring or nullNoThe key of a number property. Options are ordered by it ascending, records without a value last; without sort they follow creation order.
limitintegerNoHow 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 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.

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. 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.

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:

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.

opPasses when
equalsA list answer has exactly one item and it equals value; any other answer equals value. An empty answer never equals.
not_equalsequals does not pass (so an empty answer passes).
containsA list answer has an item equal to value; any other answer equals value.
filledThe answer is not empty. value is ignored.
emptyThe 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).

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 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 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.

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:

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.