Forms

Submissions

Send a visitor's answers and upload tokens to an active form from your server, with the visitor IP header, honeypot, hourly limit, Turnstile, validation errors and the thank-you response.

View as Markdown

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

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

NameTypeRequiredDescription
keystringYesThe form key, for example quote-request.

Headers

NameTypeRequiredDescription
AuthorizationstringYesBearer {forms_write_key}.
Content-TypestringYesapplication/json.
X-Borkol-Visitor-IpstringNoThe 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.
User-AgentstringNoStored 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

NameTypeRequiredDescription
answersobjectYesAnswers keyed by field key; may be {}. Value types per kind are listed under Field kinds. Unknown keys, heading and paragraph fields, and answers to fields that are hidden by their rules are dropped without an error.
localestringNoThe 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.
filesobjectNoFile answers: field key to a list of upload tokens from POST /v1/api/forms/{key}/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_tokenstringConditionalThe 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.
metaobjectNoPage details shown in the merchant's inbox. Anything not listed below is ignored.
meta.page_urlstringNoThe page the form was on. Kept only when it is an absolute http:// or https:// URL of at most 2000 characters; otherwise dropped.
meta.referrerstringNoThe page the visitor came from. Same rule as page_url.
meta.utmobjectNoCampaign 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.
honeypotstringNoThe 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).
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": ""
  }'
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.

NameTypeRequiredDescription
numberstring or nullalwaysThe 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.messagestring or nullalwaysThe thank-you message in the submission's language, or null when the merchant wrote none.
thank_you.redirect_urlstring or nullalwaysAn https:// address to send the visitor to, or null to show the message.
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 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), with errors keyed by path:

KeyWhen
answersanswers is missing or not an object.
localeNot a string, or longer than 16 characters.
files, metaPresent but not an object.
turnstileThe 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.
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}:

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

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.