# Form Definition Reference

A form definition is the JSON that describes a form: its fields, steps, conditions,
limits, and spam settings. The builder writes it for you, and the server enforces it
on every submission. Use this page when you need the exact name or value of a setting.

Each example below is a definition patch. Every top-level key you send replaces that
whole key, and keys you leave out stay as they are. To add one field, send every
existing field plus the new one.

These top-level keys can be changed: `fields`, `steps`, `contentBlocks`, `conditions`,
`visibilityRules`, `validationRules`, `limits`, `spam`, `verification`, `settings`,
`allowedOrigins`, and `primaryPublicTarget`.

Recipients, senders, and notification emails aren't part of this patch. Set them on
the form's **Delivery** and **Templates** tabs. See
[Getting Started](/guides/getting-started/).

## Field Names And Paths

`fields` maps a field name to its definition. Names use letters, digits, `_`, and `-`.

Other settings refer to a field by its path:

| Path | Refers to |
| --- | --- |
| `email` | A top-level field |
| `contact.email` | A field inside the `contact` group |
| `guests[]` | Every item of the `guests` list, in steps, content blocks, and visibility rules |
| `guests[0].name` | One item of the list, in conditions and validation rules |

A path that doesn't exist fails with `FORM_CONFIG_FIELD_REF_UNKNOWN`.

## Field Types

`type` takes one of these twelve values. Anything else, such as `file` or `signature`,
fails with `FORM_CONFIG_FIELD_TYPE_INVALID`.

| `type` | Collects | Submitted as |
| --- | --- | --- |
| `string` | Short text | String |
| `textarea` | Long text | String |
| `email` | An email address, always checked for format | String |
| `url` | A web address, always checked for format | String |
| `phone` | A phone number | String |
| `number` | A number | Number, or a string that converts to one |
| `boolean` | Yes or no | `true` or `false` |
| `date` | A date as `YYYY-MM-DD`, always checked | String |
| `select` | One choice from a list | String |
| `checkbox` | One tick box, or several choices | `true` or `false`, or a string or array of strings with `allowedValues` |
| `object` | A group of fields | Object |
| `array` | A repeating item | Array |

Rating and slider questions in the builder are `number` fields. There are no file
upload or signature fields.

## Options For Every Field

| Option | Value | Effect |
| --- | --- | --- |
| `label` | String | The field's name on the form |
| `description` | String | Help text under the field |
| `required` | `true` or `false` | An empty answer fails with `FIELD_REQUIRED` |
| `placeholder` | String | Hint inside the field. Not on `object` or `array` |
| `defaultValue` | Depends on `type` | Starting answer. Not on `object` or `array` |
| `messages` | Object of strings | Your own error text, keyed by rule or by error code |

```json
{
  "fields": {
    "name": {
      "type": "string",
      "label": "Your name",
      "description": "As it appears on your invoice.",
      "placeholder": "Jane Example",
      "required": true,
      "messages": { "required": "Enter your name." }
    }
  }
}
```

A `defaultValue` must match the field: a finite number on `number`, `true` or `false`
on `boolean`, a real `YYYY-MM-DD` date on `date`, and a string on other text types. With
`allowedValues`, it must be one of them.

## Text Fields

These options work on `string`, `textarea`, `email`, `url`, `phone`, and on `date` and
`select` where the table says so.

| Option | Value | Works on | Error code |
| --- | --- | --- | --- |
| `min`, `max` | Length, 0 or more | All text types | `FIELD_STRING_TOO_SHORT`, `FIELD_STRING_TOO_LONG` |
| `exactLength` | Length, 0 or more. Can't combine with `min` or `max` | All text types, `date`, `select` | `FIELD_STRING_LENGTH_INVALID` |
| `pattern`, `flags` | Regular expression up to 500 characters. `flags` takes `i`, `m`, and `u` | All text types | `FIELD_PATTERN_INVALID` |
| `allowedValues` | Array of strings | All text types | `FIELD_VALUE_NOT_ALLOWED` |
| `startsWith`, `endsWith` | Non-empty string | `string`, `textarea`, `email`, `url`, `phone` | `FIELD_STRING_PREFIX_INVALID`, `FIELD_STRING_SUFFIX_INVALID` |
| `contains`, `notContains` | Non-empty string | `string`, `textarea`, `email`, `url`, `phone` | `FIELD_STRING_CONTAINS_REQUIRED`, `FIELD_STRING_CONTAINS_FORBIDDEN` |

```json
{
  "fields": {
    "reference": {
      "type": "string",
      "label": "Reference",
      "min": 4,
      "max": 20,
      "startsWith": "REF-",
      "pattern": "^REF-[0-9]+$"
    },
    "message": {
      "type": "textarea",
      "label": "Message",
      "required": true,
      "max": 5000,
      "trim": true
    }
  }
}
```

### Masked And Hidden Text

On `string` only, `inputType` is `password` or `hidden`. It changes how the field shows.
Validation still treats it as a string.

```json
{
  "fields": {
    "access_code": { "type": "string", "label": "Access code", "inputType": "password" },
    "campaign": { "type": "string", "inputType": "hidden", "defaultValue": "spring" }
  }
}
```

### Format Checks On Text

These options work on `string` fields. `countryCode` and `regionCodeCountry` also work
on `select`.

| Option | Value | Error code |
| --- | --- | --- |
| `uuidVersion` | `any`, `v4`, or `v7` | `FIELD_UUID_INVALID` |
| `countryCode` | `iso2` | `FIELD_COUNTRY_CODE_INVALID` |
| `postalCodeCountry` | `CA` or `US` | `FIELD_POSTAL_CODE_INVALID` |
| `regionCodeCountry` | `CA` or `US` | `FIELD_REGION_CODE_INVALID` |
| `timeFormat` | `HH:mm` or `HH:mm:ss` | `FIELD_TIME_INVALID` |
| `dateTimeFormat` | `iso8601`, which needs a time zone | `FIELD_DATETIME_INVALID` |

```json
{
  "fields": {
    "postal_code": { "type": "string", "label": "Postal code", "postalCodeCountry": "CA" },
    "province": { "type": "select", "label": "Province", "regionCodeCountry": "CA", "allowedValues": ["QC", "ON"] },
    "pickup_time": { "type": "string", "label": "Pickup time", "timeFormat": "HH:mm" }
  }
}
```

## Email Fields

`email` answers are always checked for format, and fail with `FIELD_EMAIL_INVALID`.
`normalizeEmail` lowercases the address before it's checked and stored.

```json
{
  "fields": {
    "email": { "type": "email", "label": "Email", "required": true, "max": 254, "trim": true, "normalizeEmail": true }
  }
}
```

## URL Fields

`url` answers are always checked, and accept `http` and `https` by default. Use
`allowedProtocols` to change that. `javascript`, `data`, and `vbscript` aren't allowed.
A bad address fails with `FIELD_URL_INVALID`, and another protocol with
`FIELD_URL_PROTOCOL_NOT_ALLOWED`.

```json
{
  "fields": {
    "website": { "type": "url", "label": "Website", "allowedProtocols": ["https"] }
  }
}
```

## Phone Fields

A `phone` field accepts any text by default. `phoneFormat` takes `e164`, which requires
a number such as `+15145550123`, and fails with `FIELD_PHONE_INVALID`.

```json
{
  "fields": {
    "phone": { "type": "phone", "label": "Phone", "phoneFormat": "e164" }
  }
}
```

## Number Fields

| Option | Value | Error code |
| --- | --- | --- |
| `min`, `max` | The lowest and highest value, 0 or more | `FIELD_NUMBER_TOO_SMALL`, `FIELD_NUMBER_TOO_LARGE` |
| `integer` | `true` for whole numbers only | `FIELD_INTEGER_REQUIRED` |
| `maxDecimalPlaces` | Whole number, 0 or more | `FIELD_DECIMAL_PLACES_INVALID` |

A value that can't be read as a number fails with `FIELD_TYPE_INVALID`. `min` and `max`
can't be negative, on any field type.

```json
{
  "fields": {
    "rating": { "type": "number", "label": "Your rating", "integer": true, "min": 1, "max": 5 },
    "budget": { "type": "number", "label": "Budget", "maxDecimalPlaces": 2 }
  }
}
```

## Date Fields

`date` answers must be real calendar dates as `YYYY-MM-DD`. `dateMin` and `dateMax` take
the same format, and include the date you give.

| Rule | Error code |
| --- | --- |
| Not a real date | `FIELD_DATE_INVALID` |
| Before `dateMin` | `FIELD_DATE_TOO_EARLY` |
| After `dateMax` | `FIELD_DATE_TOO_LATE` |

```json
{
  "fields": {
    "event_date": { "type": "date", "label": "Event date", "dateMin": "2027-01-01", "dateMax": "2027-12-31" }
  }
}
```

## Choice Fields

Use `select` for one choice. List the choices in `allowedValues`.

```json
{
  "fields": {
    "department": { "type": "select", "label": "Department", "required": true, "allowedValues": ["Sales", "Support"] }
  }
}
```

### Several Choices

Use `checkbox` with `allowedValues`. `minSelections` and `maxSelections` set the range,
and fail with `FIELD_SELECTION_TOO_FEW` and `FIELD_SELECTION_TOO_MANY`. `defaultValue` is
a string or an array of strings from the list.

```json
{
  "fields": {
    "interests": {
      "type": "checkbox",
      "label": "Interests",
      "allowedValues": ["Workshops", "Newsletter", "Events"],
      "minSelections": 1,
      "maxSelections": 2,
      "defaultValue": ["Newsletter"]
    }
  }
}
```

### One Tick Box

Without `allowedValues`, a `checkbox` is one tick box, and `boolean` works the same way.
`defaultValue` is `true` or `false`. Add `accepted` when the box must be ticked, such as
for consent. It fails with `FIELD_ACCEPTED_REQUIRED`.

```json
{
  "fields": {
    "consent": { "type": "checkbox", "label": "I agree to the terms", "required": true, "accepted": true },
    "subscribe": { "type": "boolean", "label": "Send me the newsletter", "defaultValue": false }
  }
}
```

## Groups

`type: "object"` groups fields under a shared name. It takes `label`, `description`,
`required`, `messages`, and `fields`. Groups can nest up to 8 levels. Answers arrive as an
object, and their paths look like `contact.email`.

```json
{
  "fields": {
    "contact": {
      "type": "object",
      "label": "Contact",
      "fields": {
        "name": { "type": "string", "label": "Name", "required": true },
        "email": { "type": "email", "label": "Email", "required": true }
      }
    }
  }
}
```

## Lists

`type: "array"` repeats one item. `item` holds any field type, including a group.

| Option | Value | Error code |
| --- | --- | --- |
| `minItems` | Whole number, 0 or more | `FIELD_ARRAY_TOO_SHORT` |
| `maxItems` | Whole number, 0 or more | `FIELD_ARRAY_TOO_LONG` |
| `uniqueItems` | `true` to refuse repeats | `FIELD_ARRAY_DUPLICATES` |

```json
{
  "fields": {
    "guests": {
      "type": "array",
      "label": "Guests",
      "minItems": 1,
      "maxItems": 5,
      "item": {
        "type": "object",
        "fields": {
          "name": { "type": "string", "label": "Guest name", "required": true }
        }
      }
    }
  }
}
```

## Cleaning Answers

Processors clean an answer before it's validated. The result is the value that's stored
and delivered. They work on `string`, `textarea`, `email`, `url`, `phone`, `date`, and
`select`, and a field can have up to 25 of each.

`sanitizers` run first, then `trim`, `collapseWhitespace`, and `normalizeEmail`, then
`transformers`, then `caseTransform`.

| Sanitizer `type` | What it does |
| --- | --- |
| `trim` | Removes spaces at the start and end |
| `collapseWhitespace` | Turns any run of spaces and line breaks into one space |
| `normalizeNewlines` | Makes every line break the same |
| `stripTags` | Removes anything written like an HTML tag |
| `removeUrls` | Removes web addresses that start with `http://`, `https://`, or `www.` |
| `removeEmoji` | Removes emoji |
| `digitsOnly` | Keeps only digits. On `string` and `phone` only |

| Transformer `type` | Also needs | What it does |
| --- | --- | --- |
| `case` | `mode`: `upper` or `lower` | Changes the case of the whole answer |
| `map` | `values`: up to 100 strings | Swaps one whole answer for another. The answer must match exactly |

```json
{
  "fields": {
    "province": {
      "type": "string",
      "label": "Province",
      "sanitizers": [{ "type": "trim" }, { "type": "collapseWhitespace" }],
      "transformers": [{ "type": "case", "mode": "upper" }, { "type": "map", "values": { "QC": "Québec" } }]
    }
  }
}
```

### Shortcut Options

| Option | Value | Works on |
| --- | --- | --- |
| `trim` | `true` | Text fields |
| `collapseWhitespace` | `true` | `string`, `textarea` |
| `normalizeEmail` | `true` | `email` |
| `caseTransform` | `upper` or `lower` | `string`, `textarea`, `select` |

### Pipelines

`pipeline` runs sanitizers, transformers, and validators in one list that you order
yourself. It can't be combined with `sanitizers`, `transformers`, `validators`, `trim`,
`collapseWhitespace`, `normalizeEmail`, or `caseTransform`, fails with
`FORM_CONFIG_FIELD_OPTION_CONFLICT` if you try, and needs at least one step. The app's
**Processing** tab shows a pipeline without letting you edit it, so prefer the two lists
above.

```json
{
  "fields": {
    "code": {
      "type": "string",
      "label": "Code",
      "pipeline": [
        { "step": "sanitizer", "sanitizer": { "type": "trim" } },
        { "step": "validator", "validator": { "type": "startsWith", "value": "A", "code": "CODE_PREFIX", "message": "Start the code with A." } },
        { "step": "transformer", "transformer": { "type": "case", "mode": "lower" } }
      ]
    }
  }
}
```

## Validators

Add checks to a text field with `validators`. Each one needs a `type`, a `code` in capitals
such as `CUSTOM_POSTAL_CODE`, and the `message` to show. Validation responses return your
`code` and `message`.

| `type` | Also takes | Passes when the answer is |
| --- | --- | --- |
| `email` | | An email address |
| `url` | `allowedProtocols` | A web address |
| `domain` | | A domain name |
| `phoneE164` | `format`: `e164` | A number such as `+15145550123` |
| `postalCode` | `country`: `CA` or `US`, required | A postal or ZIP code |
| `regionCode` | `country`: `CA` or `US`, required | A province or state code |
| `countryCode` | `format`: `iso2` | A two-letter country code |
| `uuid` | `version`: `any`, `v4`, or `v7` | A UUID |
| `ip` | | An IPv4 or IPv6 address |
| `ipv4` | | An IPv4 address |
| `ipv6` | | An IPv6 address |
| `contains` | `value`, required | Contains the text |
| `notContains` | `value`, required | Doesn't contain the text |
| `startsWith` | `value`, required | Starts with the text |
| `endsWith` | `value`, required | Ends with the text |
| `regex` | `pattern`, required, up to 500 characters. `flags`: `i`, `m`, `u` | Matches the expression |

```json
{
  "fields": {
    "postal_code": {
      "type": "string",
      "label": "Postal code",
      "validators": [
        { "type": "postalCode", "country": "CA", "code": "CUSTOM_POSTAL_CODE", "message": "Enter a valid postal code." }
      ]
    },
    "sku": {
      "type": "string",
      "label": "Product code",
      "validators": [
        { "type": "regex", "pattern": "^[A-Z]{3}-[0-9]{4}$", "code": "CUSTOM_SKU", "message": "Use a code such as ABC-1234." }
      ]
    }
  }
}
```

## Conditions

A condition is true or false for a set of answers. It's used by visibility rules,
validation rules, steps, and content blocks.

| Operator | `value` | True when the answer |
| --- | --- | --- |
| `isFilled` | None | Isn't empty |
| `isEmpty` | None | Is empty |
| `equals` | Any | Equals the value |
| `notEquals` | Any | Doesn't equal the value |
| `in` | Array | Is in the array |
| `notIn` | Array | Isn't in the array |
| `greaterThan` | Number or text | Is greater |
| `greaterThanOrEqual` | Number or text | Is greater or equal |
| `lessThan` | Number or text | Is less |
| `lessThanOrEqual` | Number or text | Is less or equal |
| `matches` | Regular expression | Matches the expression |

Combine conditions with `all`, `any`, and `not`. A condition can nest up to 5 levels, and a
group can hold up to 25 conditions. An empty group fails with
`FORM_CONFIG_CONDITION_REQUIRED`.

The next example is complete. It shows `details` when `type` is `Commercial` and `size` is
over 10, or when `type` isn't filled in.

```json
{
  "fields": {
    "type": { "type": "select", "label": "Type", "allowedValues": ["Personal", "Commercial"] },
    "size": { "type": "number", "label": "Team size" },
    "details": { "type": "textarea", "label": "Details" }
  },
  "visibilityRules": [
    {
      "id": "show-details",
      "path": "details",
      "default": "hidden",
      "state": "visible",
      "when": {
        "any": [
          { "all": [
            { "path": "type", "operator": "equals", "value": "Commercial" },
            { "path": "size", "operator": "greaterThan", "value": 10 }
          ] },
          { "not": { "path": "type", "operator": "isFilled" } }
        ]
      }
    }
  ]
}
```

### Named Conditions

`conditions` stores up to 100 conditions by name, for reuse in the app. Each has an `id`, and
optionally a `label` up to 200 characters, a `description` up to 2,000, and a `when`. A rule
keeps its own copy of the logic, so a named condition doesn't change how a form behaves.

```json
{
  "fields": {
    "type": { "type": "select", "label": "Type", "allowedValues": ["Personal", "Commercial"] }
  },
  "conditions": [
    {
      "id": "is-commercial",
      "label": "Commercial project",
      "when": { "path": "type", "operator": "equals", "value": "Commercial" }
    }
  ]
}
```

## Visibility Rules

A visibility rule shows or hides one field or group. When `when` is true, the field takes
`state`. Otherwise it takes `default`, which is `visible` if you leave it out. A form can have up
to 100 rules, with one rule per field.

The server applies these rules too. Answers to a hidden field are dropped, and its checks don't
run.

```json
{
  "fields": {
    "meal": { "type": "select", "label": "Meal", "allowedValues": ["Standard", "Other"] },
    "meal_details": { "type": "string", "label": "Tell us more" }
  },
  "visibilityRules": [
    {
      "id": "show-meal-details",
      "path": "meal_details",
      "default": "hidden",
      "state": "visible",
      "when": { "path": "meal", "operator": "equals", "value": "Other" }
    }
  ]
}
```

## Validation Rules

A validation rule checks a field only when a condition is true. `then` holds up to 25 actions,
each with a `path` and a `validator`. A form can have up to 100 rules.

The `validator` is any type from [Validators](#validators), or one of these:

| `type` | Also takes | Passes when |
| --- | --- | --- |
| `required` | | The field isn't empty |
| `allOf` | `validators` | Every validator in the list passes |
| `anyOf` | `validators` | At least one validator in the list passes |

Each needs a `code` and a `message`. `allOf` and `anyOf` lists hold up to 25 validators, and can
nest up to 3 levels.

```json
{
  "fields": {
    "type": { "type": "select", "label": "Type", "allowedValues": ["Personal", "Commercial"] },
    "company": { "type": "string", "label": "Company" }
  },
  "validationRules": [
    {
      "id": "company-if-commercial",
      "when": { "path": "type", "operator": "equals", "value": "Commercial" },
      "then": [
        {
          "path": "company",
          "validator": { "type": "required", "code": "COMPANY_REQUIRED", "message": "Enter the company name." }
        }
      ]
    }
  ]
}
```

## Steps

`steps` groups fields into pages. With two or more, hosted pages and script embeds show one at a
time, with Next, Back, and a counter. A form can have up to 50 steps.

| Option | Value | Effect |
| --- | --- | --- |
| `id` | Unique string | Names the step, including for step validation |
| `fields` | 1 to 100 paths | The fields in this step. List conditional ones too |
| `label` | String | The step's title |
| `summary` | String up to 2,000 characters | Shown under the title |
| `validateOn` | `stepSubmit`, `change`, or `blur` | `change` lets Next skip the server check. The others check first |
| `visibleWhen` | Condition | Hides the step and skips its fields unless this is true |

Submitting always checks the whole form. See
[Sections And Multi-Step Forms](/guides/multi-step-forms/).

```json
{
  "fields": {
    "name": { "type": "string", "label": "Name", "required": true },
    "email": { "type": "email", "label": "Email", "required": true },
    "attending": { "type": "boolean", "label": "Will you attend?" },
    "meal": { "type": "select", "label": "Meal", "allowedValues": ["Standard", "Vegetarian"] }
  },
  "steps": [
    { "id": "about", "label": "About you", "summary": "How to reach you.", "fields": ["name", "email", "attending"], "validateOn": "stepSubmit" },
    {
      "id": "seat",
      "label": "Your seat",
      "fields": ["meal"],
      "visibleWhen": { "path": "attending", "operator": "equals", "value": true }
    }
  ]
}
```

## Content Blocks

A content block puts text or a divider among the fields. It holds no answer, so it never
reaches validation, submissions, or email. A form can have up to 100.

| Option | Value | Effect |
| --- | --- | --- |
| `id` | Unique string | Names the block |
| `type` | `text` or `separator` | A separator shows a line, with `title` as its label if set |
| `title` | String up to 200 characters | A heading |
| `description`, `body` | Strings up to 5,000 characters | Paragraphs, on `text` |
| `before`, `after` | Field path | Where it goes. Use one of them. With neither, it ends the form |
| `visibleWhen` | Condition | Shows the block only when this is true |

```json
{
  "fields": {
    "name": { "type": "string", "label": "Name" },
    "seats": { "type": "number", "label": "Seats", "integer": true, "min": 1 }
  },
  "contentBlocks": [
    { "id": "intro", "type": "text", "title": "Seats are limited", "body": "Register early to save your spot.", "before": "name" },
    { "id": "divider", "type": "separator", "after": "name" },
    {
      "id": "group-note",
      "type": "text",
      "body": "We'll contact you about group rates.",
      "after": "seats",
      "visibleWhen": { "path": "seats", "operator": "greaterThan", "value": 5 }
    }
  ]
}
```

## Limits

`limits` takes `duplicateWindowSeconds`, `rateLimits`, and `quotas`. Any other key is refused:
the request fails with HTTP 422 and `VALIDATION_FAILED`, with an issue coded `FORM_CONFIG_UNKNOWN_KEY`. See [Limits And Spam Protection](/guides/spam-protection/) for what
each one does.

| Option | Value |
| --- | --- |
| `duplicateWindowSeconds` | Whole number. Anything under 60 acts as 60 |
| `rateLimits[].id` | Unique string |
| `rateLimits[].scope` | `ipHash` per visitor, `form`, `workspace`, `origin`, `deviceToken`, or `idempotencyKey` |
| `rateLimits[].windowSeconds` | Whole number, 1 to 604,800, which is 7 days |
| `rateLimits[].max` | Whole number, 1 to 100,000 |
| `rateLimits[].message` | Your own message |
| `quotas[].id` | Unique string |
| `quotas[].scope` | `form` or `workspace` |
| `quotas[].period` | `day`, `week`, `month`, or `lifetime` |
| `quotas[].maxAccepted` | Whole number, 1 or more |
| `quotas[].message` | Your own message |

```json
{
  "limits": {
    "duplicateWindowSeconds": 300,
    "rateLimits": [
      { "id": "per-visitor", "scope": "ipHash", "windowSeconds": 600, "max": 10 },
      { "id": "whole-form", "scope": "form", "windowSeconds": 3600, "max": 300 }
    ],
    "quotas": [
      { "id": "cap", "scope": "form", "period": "lifetime", "maxAccepted": 200, "message": "Registration is full." }
    ]
  }
}
```

The response code stays `RATE_LIMITED` or `FORM_QUOTA_REACHED` whatever the `message` says.

On the Free plan a form accepts at most 1,000 submissions an hour, whatever its `rateLimits`
say. Above that it returns `RATE_LIMITED`.

Open and close dates aren't part of `limits`. Set them under **Response window** on the
**Publish** tab.

## Spam Checks

| Option | Value | Effect |
| --- | --- | --- |
| `honeypotField` | Field path | Rejects a submission with this field filled. New forms use `_formerie_hp`. Leave it out to turn the check off |
| `minSubmitTimeMs` | Whole number, 0 or more | Rejects a form sent faster than this, when the client sends `metadata.startedAt`. `0` turns it off |
| `maxLinks` | Whole number, 0 or more | Rejects a submission with more links than this, across all its answers. `0` rejects any link. Leave it out to turn the check off |

```json
{
  "spam": {
    "honeypotField": "_formerie_hp",
    "minSubmitTimeMs": 1000,
    "maxLinks": 3
  }
}
```

## Human Verification

| Option | Value | Effect |
| --- | --- | --- |
| `provider` | `turnstile`, `recaptcha-v2`, or `recaptcha-v3` | The challenge to show |
| `mode` | `managed` or `custom` | `managed` uses Formerie's keys, on `turnstile` only. Always set it |
| `required` | `true` or `false` | Whether a submission must pass |
| `siteKey` | Public site key | Needed with `custom` when `required` is `true` |
| `minScore` | 0 to 1 | Lowest score to accept, on `recaptcha-v3` only |
| `expectedAction` | String | The action the token must carry, on `recaptcha-v3` only |

```json
{
  "verification": {
    "provider": "turnstile",
    "mode": "managed",
    "required": true
  }
}
```

With your own keys, enter the secret key in the app, under **Human verification** in the form's
**Settings**. It's encrypted, and it stays in place when you change the rest of `verification` with the
same provider. Until one is saved, `required` has to stay `false`, as in this example, or the change fails
with `FORM_CONFIG_VERIFICATION_SECRET_REQUIRED`.

```json
{
  "verification": {
    "provider": "recaptcha-v3",
    "mode": "custom",
    "required": false,
    "siteKey": "your-public-site-key",
    "minScore": 0.5,
    "expectedAction": "contact"
  }
}
```

## Allowed Origins

`allowedOrigins` lists the sites whose browser code can call the form directly. Each entry is an
exact origin with no path, and no wildcards. See
[Allowed Origins](/guides/spam-protection/#allowed-origins) for when you need them.

```json
{
  "allowedOrigins": ["https://www.example.com", "https://example.com", "http://localhost:5173"]
}
```

## Public Address

`primaryPublicTarget` sets the address the form uses as its main one.

| `kind` | Also needs | Address |
| --- | --- | --- |
| `workspace` | | The workspace path on `forms.formerie.com` |
| `formerie` | | The same Formerie address |
| `runtimeHostname` | `hostname`, and `bindingKind`: `workspace` or `form` | Your own hostname. Connect it first under **Domains** |

```json
{
  "primaryPublicTarget": {
    "kind": "runtimeHostname",
    "hostname": "forms.example.com",
    "bindingKind": "workspace"
  }
}
```

## Form Behaviour

`settings.behavior` holds what happens around the fields. `settings` replaces as a whole, so
send `behavior`, `branding`, and `layout` together.

| Option | Value |
| --- | --- |
| `language` | A language tag such as `en-CA` or `fr-CA` |
| `submitButtonLabel` | Up to 80 characters |
| `successMessage` | Up to 280 characters |
| `successRedirectUrl` | An `http` or `https` address, up to 500 characters |

## Branding

`settings.branding` styles the hosted page and script embed.

| Option | Value |
| --- | --- |
| `brandName` | Up to 80 characters |
| `logoUrl` | An `http` or `https` address, up to 500 characters |
| `density` | `compact`, `comfortable`, or `spacious` |
| `fontPreset` | `arial`, `georgia`, `helvetica`, `inter`, `mono`, `system`, `times`, or `verdana` |
| `previewTheme` | `light` or `dark`. Visitors get the one you choose |
| `radius` | `none`, `soft`, or `round` |
| `themes.light`, `themes.dark` | Colours as `#RRGGBB` |

Each theme takes `accentColor`, `accentTextColor`, `backgroundColor`, `borderColor`, `errorColor`,
`errorTextColor`, `fieldBackgroundColor`, `mutedTextColor`, `surfaceColor`, and `textColor`.

Custom CSS isn't applied to hosted pages.

## Layout

`settings.layout` sets the width of the hosted page.

| Option | Value |
| --- | --- |
| `containerWidthMode` | `contained`, `custom`, or `full` |
| `containerMinWidth`, `containerMaxWidth` | A length such as `44rem`, `720px`, `90%`, or `72ch`, with `custom` |

```json
{
  "settings": {
    "behavior": {
      "language": "fr-CA",
      "submitButtonLabel": "Envoyer",
      "successMessage": "Merci, nous vous répondrons sous peu."
    },
    "branding": {
      "brandName": "Example",
      "density": "comfortable",
      "fontPreset": "inter",
      "previewTheme": "light",
      "radius": "soft",
      "themes": { "light": { "accentColor": "#007c72" } }
    },
    "layout": { "containerWidthMode": "custom", "containerMaxWidth": "44rem" }
  }
}
```

## Size Limits

| What | Limit |
| --- | --- |
| Fields on a form, counting those inside groups and lists | 250 |
| Nesting of groups and lists | 8 levels |
| Steps | 50 |
| Paths in a step | 100 |
| Step summary | 2,000 characters |
| Content blocks | 100 |
| Content block title | 200 characters |
| Content block description or body | 5,000 characters each |
| Named conditions | 100 |
| Named condition label, description | 200, 2,000 characters |
| Visibility rules | 100 |
| Validation rules | 100 |
| Actions in a validation rule | 25 |
| Conditions in a rule | 25 |
| Condition nesting | 5 levels |
| Validators in an `allOf` or `anyOf` | 25, nested up to 3 levels |
| Sanitizers, transformers, or pipeline steps on a field | 25 of each |
| Entries in a `map` transformer | 100 |
| Regular expression | 500 characters |
| A submission's JSON body | 100,000 bytes |

## Reading Definition Errors

A rejected definition returns `VALIDATION_FAILED` with `issues`, and each issue has a `path`, a
stable `code`, and a `message`. Fix those paths and send the patch again.

| Code | What's wrong |
| --- | --- |
| `FORM_CONFIG_UNKNOWN_KEY` | A key isn't a setting there. `suggestion` names the right key when one is obvious |
| `FORM_CONFIG_RATE_LIMIT_TOO_HIGH` | A `rateLimits` window or `max` is above its upper bound |
| `FORM_CONFIG_FIELD_TYPE_INVALID` | `type` isn't one of the twelve field types |
| `FORM_CONFIG_FIELD_OPTION_INVALID` | That option doesn't work on this field type |
| `FORM_CONFIG_FIELD_OPTION_CONFLICT` | Two options can't be used together |
| `FORM_CONFIG_FIELD_REF_UNKNOWN` | A path names a field that doesn't exist |
| `FORM_CONFIG_FIELDS_REQUIRED` | The form has no fields |
| `FORM_CONFIG_NUMBER_TOO_SMALL` | A number is below its minimum |
| `FORM_CONFIG_RANGE_INVALID` | A minimum is above its maximum |
| `FORM_CONFIG_DEFAULT_VALUE_INVALID` | `defaultValue` doesn't suit the field |
| `FORM_CONFIG_DEFAULT_VALUE_NOT_ALLOWED` | `defaultValue` isn't in `allowedValues` |
| `FORM_CONFIG_CODE_INVALID` | A `code` isn't in capitals, digits, and `_` |
| `FORM_CONFIG_VALIDATOR_INVALID` | The validator `type` doesn't exist |
| `FORM_CONFIG_CONDITION_OPERATOR_INVALID` | The condition `operator` doesn't exist |
| `FORM_CONFIG_ID_DUPLICATE` | Two entries in a list share an `id` |
| `FORM_CONFIG_ORIGIN_WILDCARD` | An allowed origin uses `*` |
| `FORM_CONFIG_ORIGIN_INVALID` | An allowed origin has a path, or isn't `http` or `https` |

### Blocked At Publish

A form that can't work saves as a draft, but it can't be published. These codes show as
blockers on the **Publish** tab:

| Code | What's wrong |
| --- | --- |
| `FORM_CONFIG_CHOICES_REQUIRED` | A question has no choices, so nobody can answer it. List them in `allowedValues` |
| `FORM_CONFIG_CHOICE_BLANK` | One of the choices is blank |
| `FORM_CONFIG_CHOICE_DUPLICATE` | A choice is listed twice |
| `FORM_CONFIG_STEP_FIELD_DUPLICATE` | A field is in more than one step |

### Warnings

`FORM_DEFINITION_UNKNOWN_KEYS` is a warning under **Delivery readiness**, and it doesn't stop
anything. The form holds settings that do nothing, usually from before these keys were refused.
Save that section again without them.
