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 MarkdownA 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).
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
| 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. |
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. 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, 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. |
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). |
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. |
How a submission is checked
In this order:
- The form is looked up:
404when it is not active or never published. - Honeypot. When
honeypotis any value other than an empty string ornull, the API answers201with{"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. - 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 answers429. Every counted attempt uses up the budget, including those that then fail with422. - Turnstile, only when the form returns
settings.turnstile_site_key. Theturnstile_tokenis verified with Cloudflare; a failure answers422onturnstile. The token is used up from here on, whatever happens next. - 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
filesare checked as a list here (files.{key}errors). - 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
422onfiles.{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). - Stored. The visible answers (including
hiddenfield values) are stored with the version, the language andmeta; 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. - 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.createdwebhook. A failure in any of these is recorded for the merchant and never changes the201.
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
latandlonwhen you sent none, or when yours are more than 1 km from the match. - Not found: the coordinates you sent cannot be verified, so
latandlonare 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_areaisfalse) answers422onanswers.{key}with the field'smessagein the submission's language. An address without coordinates (not found, or lookup unavailable without coordinates from you) is accepted.blockneedslookup: 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:
| 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. |
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.
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.
404when 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, and403{"message":"Invalid ability provided."}when the key lacksforms:write.401as on every/v1/apiroute.
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": ""
}'// 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" } });
}{
"data": {
"number": "INQ-0042",
"thank_you": {
"message": "Thanks! We will get back to you soon.",
"redirect_url": null
}
}
}{
"message": "This field is required. (and 1 more error)",
"errors": {
"answers.name": ["This field is required."],
"answers.email": ["Must be an email address."]
}
}{
"message": "Too many submissions. Try again later."
}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. |
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. 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, 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. |
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). |
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": ""
}'// 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. |
{
"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:
- The form is looked up:
404when it is not active or never published. - Honeypot. When
honeypotis any value other than an empty string ornull, the API answers201with{"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. - 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 answers429. Every counted attempt uses up the budget, including those that then fail with422. - Turnstile, only when the form returns
settings.turnstile_site_key. Theturnstile_tokenis verified with Cloudflare; a failure answers422onturnstile. The token is used up from here on, whatever happens next. - 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
filesare checked as a list here (files.{key}errors). - 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
422onfiles.{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). - Stored. The visible answers (including
hiddenfield values) are stored with the version, the language andmeta; 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. - 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.createdwebhook. A failure in any of these is recorded for the merchant and never changes the201.
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
latandlonwhen you sent none, or when yours are more than 1 km from the match. - Not found: the coordinates you sent cannot be verified, so
latandlonare 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_areaisfalse) answers422onanswers.{key}with the field'smessagein the submission's language. An address without coordinates (not found, or lookup unavailable without coordinates from you) is accepted.blockneedslookup: 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:
| 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. |
{
"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.
{
"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.
404when 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, and403{"message":"Invalid ability provided."}when the key lacksforms:write.401as on every/v1/apiroute.