# Submissions

A submission is one visitor's answers to one form. Send it from your server, never from the browser: the `forms:write` key it needs can send mail in the merchant's name (see [Keep forms:write keys on your server](./index#keep-formswrite-keys-on-your-server)).

## POST /v1/api/forms/{key}/submissions

Checks the answers against the form's published version, claims the uploaded files, stores the submission, numbers it, and answers with the thank-you settings. Requires the `forms:write` scope. The form must be active and published.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | Yes | The form key, for example `quote-request`. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | string | Yes | `Bearer {forms_write_key}`. |
| `Content-Type` | string | Yes | `application/json`. |
| `X-Borkol-Visitor-Ip` | string | No | The visitor's IP address (IPv4 or IPv6). The hourly limit counts per visitor address, and the Turnstile check is sent with it. Missing or not a valid IP address: the address of the connection (your server) is used instead. See [Visitor IP and the hourly limit](./index#visitor-ip-and-the-hourly-limit). |
| `User-Agent` | string | No | Stored with the submission (first 500 characters) and shown in the inbox as the browser. Forward the visitor's own `User-Agent` if you want it there. |

### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `answers` | object | Yes | Answers keyed by field key; may be `{}`. Value types per kind are listed under [Field kinds](./index#field-kinds). Unknown keys, `heading` and `paragraph` fields, and answers to fields that are hidden by their rules are dropped without an error. |
| `locale` | string | No | The visitor's language, at most 16 characters, for example `nl`. A language enabled for the store sets the language of `thank_you.message` and of the visitor's confirmation email; anything else uses the store's default language. |
| `files` | object | No | File answers: field key to a list of upload tokens from [`POST /v1/api/forms/{key}/uploads`](./uploads), for example `{"photos": ["9b2f0c3e-..."]}`. At most the field's `max_files` tokens. Required file fields are answered here; a value for a file field in `answers` is ignored. |
| `turnstile_token` | string | Conditional | The token of the Cloudflare Turnstile widget. Required when the form returns `settings.turnstile_site_key`; ignored otherwise. Single use: get a new one after every `422`. See [Turnstile](./index#turnstile). |
| `meta` | object | No | Page details shown in the merchant's inbox. Anything not listed below is ignored. |
| `meta.page_url` | string | No | The page the form was on. Kept only when it is an absolute `http://` or `https://` URL of at most 2000 characters; otherwise dropped. |
| `meta.referrer` | string | No | The page the visitor came from. Same rule as `page_url`. |
| `meta.utm` | object | No | Campaign values, for example `{"source": "google", "campaign": "spring"}`. String values only; the first 10 keys of at most 200 characters are kept and values are cut to 200 characters. |
| `honeypot` | string | No | The value of an input people never see. Any value other than an empty string or `null`: the request is treated as a bot (see below). |

```bash title="Request"
curl -X POST "https://api.borkol.com/v1/api/forms/quote-request/submissions" \
  -H "Authorization: Bearer {forms_write_key}" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "X-Borkol-Visitor-Ip: 203.0.113.24" \
  -d '{
    "locale": "en",
    "answers": {
      "services": ["website"],
      "pages": 8,
      "campaign": "spring",
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "consent": true
    },
    "files": {
      "brief": ["9b2f0c3e-5a1d-4f7e-8c2b-1d3e4f5a6b7c"]
    },
    "turnstile_token": "0.AbCdEf...",
    "meta": {
      "page_url": "https://example.com/quote",
      "referrer": "https://www.google.com/",
      "utm": { "source": "google" }
    },
    "honeypot": ""
  }'
```

```javascript title="Request (JS)"
// A Next.js route handler: the browser posts { locale, answers, files, turnstile_token } here, never to Borkol.
export async function POST(request) {
  const body = await request.json();
  // Only trust an IP header your own proxy or host sets.
  const visitorIp = request.headers.get("x-real-ip") ?? "";
  const res = await fetch("https://api.borkol.com/v1/api/forms/quote-request/submissions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BORKOL_FORMS_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "X-Borkol-Visitor-Ip": visitorIp,
      "User-Agent": request.headers.get("user-agent") ?? "",
    },
    body: JSON.stringify({ ...body, meta: { page_url: request.headers.get("referer") ?? undefined } }),
  });
  return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
```

Status: `201 Created`. The result is wrapped in `data`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `number` | string or null | always | The submission number, for example `INQ-0042`, from the store's inquiry number series (by default `INQ-` and a four-digit running number). `null` only for a honeypot hit. |
| `thank_you.message` | string or null | always | The thank-you message in the submission's language, or `null` when the merchant wrote none. |
| `thank_you.redirect_url` | string or null | always | An `https://` address to send the visitor to, or `null` to show the message. |

```json title="Response"
{
  "data": {
    "number": "INQ-0042",
    "thank_you": {
      "message": "Thanks! We will get back to you soon.",
      "redirect_url": null
    }
  }
}
```

### How a submission is checked

In this order:

1. The form is looked up: `404` when it is not active or never published.
2. **Honeypot.** When `honeypot` is any value other than an empty string or `null`, the API answers `201` with `{"number": null, "thank_you": {...}}` straight away. Nothing is stored, no mail or webhook is sent, Turnstile is not checked, and the attempt does not count toward the hourly limit. Do not tell the visitor anything different: the response looks like a success on purpose.
3. **Hourly limit.** The attempt is counted for this form and visitor address (from `X-Borkol-Visitor-Ip`). Over the form's limit (the merchant sets 1 to 100 per hour, default 5) the API answers `429`. Every counted attempt uses up the budget, including those that then fail with `422`.
4. **Turnstile**, only when the form returns `settings.turnstile_site_key`. The `turnstile_token` is verified with Cloudflare; a failure answers `422` on `turnstile`. The token is used up from here on, whatever happens next.
5. **Answers.** The body shape is checked, then the answers against the published version: rules decide which steps and fields are shown, answers to fields hidden by their rules are dropped and not required, required visible fields must be answered, and every value must fit its kind. Upload tokens in `files` are checked as a list here (`files.{key}` errors).
6. **Files.** Only when the answers pass: each token of a visible file field is claimed. Every token must exist, belong to this form and this field, be unused and not expired, and appear once; otherwise the API answers `422` on `files.{key}`. Claiming happens in the same transaction as storing, so a rejected submission claims nothing: the visitor can fix the answers and send the same tokens again (with a new Turnstile token).
7. **Stored.** The visible answers (including `hidden` field values) are stored with the version, the language and `meta`; the claimed files are attached to the submission. The raw visitor IP is never stored, only an HMAC-SHA256 hash of it. The submission is linked to a contact when its email matches exactly one contact of the store (ignoring case); no contact is ever created.
8. **Announced** after the answer is stored: email to the merchant, confirmation email to the visitor (when the form has one switched on and the visitor gave an email), a dashboard notification and the [`form_submission.created`](/api/webhooks/index) webhook. A failure in any of these is recorded for the merchant and never changes the `201`.

### Address fields and the service area

For each visible `address` field with a lookup (`config.lookup: true`), the server looks up the postal code and house number again before it checks the service area:

- **Found:** the coordinates of the match replace `lat` and `lon` when you sent none, or when yours are more than 1 km from the match.
- **Not found:** the coordinates you sent cannot be verified, so `lat` and `lon` are dropped (`null`).
- **Lookup unavailable:** the coordinates you sent are kept as they are.

For a field with `config.service_area`, the server then measures the straight-line distance from the origin to the address coordinates and stores `{distance_km, in_area}` for the field in the submission's `meta.address`, keyed by field key (the merchant sees it in the inbox). `distance_km` and `in_area` are `null` when the address has no coordinates. A field without a service area adds nothing to `meta.address`.

- `mode: "notice"`: the submission is accepted whatever the verdict; the visitor has already seen the message in your form.
- `mode: "block"`: a submission whose address is known to be outside (`in_area` is `false`) answers `422` on `answers.{key}` with the field's `message` in the submission's language. An address without coordinates (not found, or lookup unavailable without coordinates from you) is accepted. `block` needs `lookup: true`, because only a looked-up address has verified coordinates.

### Errors

**422 validation.** The standard envelope (see [Errors and rate limits](/api/getting-started/errors-and-rate-limits)), with errors keyed by path:

| Key | When |
| --- | --- |
| `answers` | `answers` is missing or not an object. |
| `locale` | Not a string, or longer than 16 characters. |
| `files`, `meta` | Present but not an object. |
| `turnstile` | The form has Turnstile on and the token is missing, invalid, expired or already used, or Cloudflare could not confirm it. |
| `answers.{key}` | The answer to a visible field is missing or does not fit its kind. |
| `files.{key}` | A required visible `file` field has no tokens, the value is not a list of tokens or has more than `max_files`, or a token cannot be claimed. |

```json title="Response"
{
  "message": "This field is required. (and 1 more error)",
  "errors": {
    "answers.name": ["This field is required."],
    "answers.email": ["Must be an email address."]
  }
}
```

The messages on `answers.{key}` and `files.{key}`:

| Message | Kind |
| --- | --- |
| `This field is required.` | Any required field left empty, except `consent`. |
| `You must accept this.` | `consent`: required and left empty (`false` counts as empty), or an answer that is neither `true` nor empty. |
| `Must be text of at most 255 characters.` | `text`. |
| `Must be text of at most 10000 characters.` | `textarea`. |
| `Must be a number.` | `number`. |
| `Must be an email address.` | `email`. |
| `Must be a phone number.` | `phone`. |
| `Must be a date in YYYY-MM-DD format.` | `date`. |
| `Must be one of the configured options.` | `select`. |
| `Must be a link.`, `A link needs a url.`, `A link must be absolute, root-relative, mailto: or tel:.`, `A link label may be at most 200 characters.` | `link`. |
| `Choose one of the options.` | `choice`: a value that is not an option (or not a list when `multiple`). |
| `Choose at least {min}.`, `Choose at most {max}.` | `choice` with `multiple`, outside `min` or `max`. |
| `Must be text of at most 500 characters.` | `hidden`. |
| `Must be a valid address.` | `address`: not an object, an invalid postal code or coordinates out of range. A missing postal code, house number, street or city answers `This field is required.` |
| `Must be a list of files.`, `At most {max} files are allowed.` | `file`. |
| `This upload is invalid or has expired. Upload the file again.` | `file`: a token that does not exist, belongs to another form or field, was already used, has expired (24 hours after the upload), or appears twice. |

The message on `turnstile` is `The spam check failed. Reload the page and try again.` After any `422`, reset the Turnstile widget: the token was used.

`@borkol/forms` returns exactly these messages from `check` (except the upload and Turnstile messages, which only the server can give), so you can show the server's errors and your own the same way.

**429 hourly limit.**

```json title="Response"
{
  "message": "Too many submissions. Try again later."
}
```

This response carries no `Retry-After` header. The store-wide limit of 120 requests per minute can also answer `429`, with the message `Too Many Attempts.` and a `Retry-After` header.

**Other errors.**

- `404` when no form of the store has that key, or it is a draft, archived, never published or deleted.
- `403` `{"error":"Module not enabled: forms"}` when the forms module is off for the store, and `403` `{"message":"Invalid ability provided."}` when the key lacks `forms:write`.
- `401` as on every `/v1/api` route.
