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 <form> with an id and the submit script. No server code.

A #/API/<Name>.js endpoint and the browser code that posts to it.

Where it goes

Mailed to the workspace owner. Nothing is stored.

Wherever your script sends it: docly.sendForm to any address, or a document you write.

Checks

required and type="email" re-checked on the server, data-validate, data-antispam, a per-IP limit.

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

  1. Find <form id="…"> in the rendered page.

"Can't find form tag with id …" — the form must be in the page output with the same id.

  1. Look up the recipient: the workspace owner.

"DELIVERY FAILED! …" when the owner has no valid e-mail address. The site console says why.

  1. Read the fields named in the form's markup. Re-check required and type="email" (multiple allows a list).

"Field '…' is required!" / "Field '…' is not a valid email!"

  1. data-smtp: find the SMTP configuration.

"Send failed due to configuration error! …"

  1. data-validate: run #/API/<Name>.js. It must return true.

Your error message, or "Validation failed". The script is missing: "Configured backend validation script … not found!"

  1. Per-IP limit.

"Too many form submissions from this IP address in a short time!"

  1. data-antispam="on": verify the Turnstile token.

"Antispam verification is required for this form." / "Antispam verification failed …"

  1. Send the mail, then answer with the receipt message.

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

id

Required. Finds the form on the server and appears in the mail subject.

action

Optional. The page the script posts to; default is the current page. The server looks for the same form id in that page.

data-message

Shown in place of the form after a successful submission. May contain HTML. Default: "Your form has been submitted!"

data-message-file

A file (path from the site root) whose rendered content is shown instead of data-message.

data-message-url

Redirect to this address after a successful submission, instead of showing a message.

data-smtp

An SMTP configuration document (a path such as #/MySmtp, or its id) to send through. Not a recipient — an e-mail address here fails. The user the publication runs as must be able to read it. Without it the platform's mail account sends.

data-validate

Name of a validation script: data-validate="Turnstile" runs #/API/Turnstile.js. See below.

data-antispam

Exactly on: Docly's own Turnstile check, no keys needed. See below.

data-submitted-url

Query string the script sets on the page address after submitting. Default submit-success / submit-error; your value replaces both.

data-track-event

Sends an analytics event (gtag, otherwise dataLayer) and a docly:form-submit DOM event. Empty: docly_form_submit / docly_form_error; a value x: x / x_error.

data-nosend

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

data-group="Title"

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 Title #1, Title #2 …

data-table="Title"

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:

data-antispam="on"

data-validate with your own Turnstile keys

Set-up

The attribute. Nothing else.

A Turnstile site key in the markup, the secret in #/Config, and a validation script.

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 docly.httpFormPost and throws on failure.

Content-Security-Policy

If the site sets one: allow https://security.docly.net in script-src and frame-src.

Allow https://challenges.cloudflare.com in script-src, connect-src and frame-src.

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; without sendTo it goes to the workspace owner. It is available only in API scripts. Without your own smtp, it counts against the site's e-mail quota and rate limit.

  • Uploaded files are not in form. Use docly.getUploads() and docly.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