Forms Last updated: 23. Oct 2026
How an HTML form on a published Docly site is submitted, checked and delivered by the platform — and how to post a form to your own API script when you need something else.
Two ways to handle a form
Built-in submission | Your own API script | |
|---|---|---|
What you write | A | A |
Where it goes | Mailed to the workspace owner. Nothing is stored. | Wherever your script sends it: |
Checks |
| Whatever your script checks — nothing is added for you. |
Use it for | Contact, enquiry and sign-up forms. | Another recipient, a stored record (booking, order), or a call to another system. |
Start with the built-in submission. It needs no endpoint, and its validation order, error paths and limits are already in place. See How a form submission is delivered.
The built-in submission
Give the form a fixed, unique id, give every field a name, and load one of the platform submit scripts:
<form id="Contact" data-antispam="on" data-message="Thank you, we will be in touch.">
<input name="Name" required>
<input name="Email" type="email" required>
<textarea name="Message" required></textarea>
<button type="submit">Send</button>
</form>
<script src="/vanilla.submitform.js"></script> /vanilla.submitform.js and /jquery.submitform.js (needs jQuery) behave the same. Docly serves them when the site has no file of that name. Load only one; a second copy is ignored. On a page that uses a master, give the script tag xdt:Transform="Insert" — see Use master pages for shared layout.
The script takes over every form on the page that is not marked data-nosend. It posts the fields as multipart/form-data, together with the form id, to the form's action — or to the current page when there is none. A form submitted without the script is just a POST to the page: nothing is mailed.
What the server does
The server renders the page the form was posted to, finds the form in that output by its id, and then works through these steps. The first one that fails stops the submission:
Step | Fails with |
|---|---|
| "Can't find form tag with id …" — the form must be in the page output with the same id. |
| "DELIVERY FAILED! …" when the owner has no valid e-mail address. The site console says why. |
| "Field '…' is required!" / "Field '…' is not a valid email!" |
| "Send failed due to configuration error! …" |
| Your error message, or "Validation failed". The script is missing: "Configured backend validation script … not found!" |
| "Too many form submissions from this IP address in a short time!" |
| "Antispam verification is required for this form." / "Antispam verification failed …" |
| An error instead of the receipt when the mail cannot be sent. |
A rejection is HTTP 403 with {"ResultCode": …, "Message": "…"}. The submit script shows Message in an alert and shows the form again. Note the order: data-validate runs before the IP limit and the antispam check.
The mail goes to the owner of the workspace — not the person who published the site, and never an address in the markup. Its subject is FORM: <page title> (<form id>). It lists each field as a row, then the form id, the page address, the sender's country and device and — on a site that requires login — the signed-in user. Uploaded files (type="file") are attached. The submission is not stored anywhere in the workspace.
Only fields in the rendered markup are mailed. The server reads the field list from the page, not from the post. A field your browser script adds with a new name is not in the mail; a copy of an existing field (same name) is, as an extra row. The Turnstile token is posted but not mailed for the same reason. A checkbox or radio group left empty is not posted at all, so the server cannot enforce required on it — check it in data-validate.
Attributes on the form
Attribute | What it does |
|---|---|
| Required. Finds the form on the server and appears in the mail subject. |
| Optional. The page the script posts to; default is the current page. The server looks for the same form |
| Shown in place of the form after a successful submission. May contain HTML. Default: "Your form has been submitted!" |
| A file (path from the site root) whose rendered content is shown instead of |
| Redirect to this address after a successful submission, instead of showing a message. |
| An SMTP configuration document (a path such as |
| Name of a validation script: |
| Exactly |
| Query string the script sets on the page address after submitting. Default |
| Sends an analytics event ( |
| The submit script leaves this form alone — use it for forms you post yourself. |
Inside the form — these go on a container element around fields, not on <form>. They only change how the mail is laid out:
Attribute | What it does |
|---|---|
| The fields inside become a section with its own heading. Repeated values of the fields (rows the visitor added in the browser) give one section each, numbered |
| The fields inside become a table: one column per field name, one row per set of posted values. |
Empty extra rows are dropped, and required is checked for the first row and every row that has content. For a worked example with an "add participant" button, see Forms in Docly: Leveraging Data-Group and Data-Table Attributes. Tracking, data-message-file and SMTP set-up are covered in detail in Sending forms by e-mail.
Validation and spam protection
Every public form needs bot protection. There are two routes, and both use Cloudflare Turnstile:
|
| |
|---|---|---|
Set-up | The attribute. Nothing else. | A Turnstile site key in the markup, the secret in |
What the visitor sees | After clicking Send, the form is replaced by Docly's Turnstile widget; the post follows when it passes. | Your widget, inside the form, before sending. |
Server check | Docly verifies the token. | Your script posts the token to Cloudflare with |
Content-Security-Policy | If the site sets one: allow | Allow |
Use data-antispam="on" unless you need your own keys. The full data-validate route — keys, the script, the CSP and the trap in Cloudflare's test keys — is in Protect every form with Turnstile. The two can be combined.
A validation script is an ordinary API script. Each parameter is filled from the posted field of the same name (then the query string), and the form global holds every posted field, including ones not in the markup. Return true to accept. Anything else rejects: a thrown Error becomes the alert text, and a thrown object (throw { … }) is sent back as the JSON body instead.
// #/API/ContactRules.js — used as data-validate="ContactRules"
export default (Message, Phone) => {
if (String(Message).length > 5000) throw new Error("The message is too long.");
if (Phone && !/^[+0-9 ]{5,20}$/.test(Phone)) throw new Error("Check the phone number.");
return true;
}; Required fields and e-mail syntax are already checked by the server, so the script only holds your own rules. See Reading query and form fields in API calls for how values arrive — a field posted twice is an array.
Limits
Limit | Value | Over the limit |
|---|---|---|
Submissions per IP address | Refused once more than 25 forms from that address have been delivered in the last 24 hours (1000 for an address a Docly developer has a session from). Platform-wide; not set per site. | HTTP 403, "Too many form submissions …" |
Request body | 30,000,000 bytes — fields and uploaded files together. | HTTP 413 |
Fields and files in one post | 1024 | HTTP 400 |
Length of a field name | 2048 characters | HTTP 400 |
Length of a field value | 4 MiB | HTTP 400 |
The body and field limits apply before any of your code runs, to the built-in submission and to API scripts alike. Say in the form how large an upload may be. See Request bodies are limited to 30 MB.
Posting to your own API script
When the built-in submission cannot do the job, mark the form data-nosend and post it to an endpoint in #/API/. Without data-nosend the submit script takes the form over.
<form id="Booking" action="/API/Booking" method="post" data-nosend>
<input name="name" required>
<input name="email" type="email" required>
<input name="date" type="date" required>
<button type="submit">Book</button>
</form>
<p id="BookingResult"></p>
<script src="/assets/booking.js"></script> // /assets/booking.js — an external file, so a strict CSP does not block it
document.getElementById("Booking").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(e.target.action, { method: "POST", body: new FormData(e.target) });
const data = await res.json();
document.getElementById("BookingResult").textContent = res.ok ? "Thank you!" : data.Message;
}); // #/API/Booking.js
export default (name, email, date) => {
if (request.method != "POST") throw new Error("Verb not supported");
if (!name || !isEmail(email) || !date) throw new Error("Fill in name, e-mail and date.");
docly.sendForm({ Name: name, Email: email, Date: date }, "Booking", { sendTo: "bookings@example.com" });
return { ok: true };
}; Named parameters. Each parameter gets the posted field (or query parameter) of the same name. Never name one
form— that is a field called "form". Details: Reading query and form fields in API calls.POST only. An endpoint that sends mail or writes anything must refuse other verbs as its first statement. See State-changing API endpoints must require POST.
Nothing is added for you. No per-IP limit, no
data-antispam. Verify a Turnstile token in the endpoint the way the validation script in Protect every form with Turnstile does.docly.sendForm builds the same kind of mail as the built-in submission, but you choose
sendTo,smtp, CC/BCC and attachments; withoutsendToit goes to the workspace owner. It is available only in API scripts. Without your ownsmtp, it counts against the site's e-mail quota and rate limit.Uploaded files are not in
form. Usedocly.getUploads()anddocly.getUploadBase64(fieldName).Storing submissions. A record you must keep (a booking, an order) belongs in a document under
#/, which is never served — see Keep private data under the # folder. Personal data stored there is yours to delete again.
Related
Sending forms by e-mail — every attribute, tracking and SMTP set-up in detail
sendForm and httpFormPost
The API folder and Security Headers in the headers.json file