POST /f/{form} is public: no session, cookies or CSRF token. It accepts application/x-www-form-urlencoded, multipart/form-data and application/json.
File fields
Send a file field’s file with a multipart/form-data post, or upload it first with a direct upload and send the returned upl_... ID as the field’s value in a JSON submission:
{ "email": "ada@example.com", "cv": "upl_01jcb4m7x2k9p3q8r5t6v0w1yz" }The upload ID above is illustrative. quickforms checks that the upload belongs to this form and field, hasn’t been used or expired and is in storage. See file uploads for the full flow, limits and errors.
HTML form posts
A plain HTML post gets a 303 redirect when the form has a redirect target, otherwise the hosted thank-you page with the form’s success message. If validation fails, the hosted error page lists the problems with a link back to the page the visitor came from.
Fields that are not in the schema are ignored. Strings are trimmed, and empty strings count as missing.
JSON
Send Content-Type: application/json or Accept: application/json to get JSON back. That includes a FormData body posted with fetch and an Accept header.
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"name": "Ada Lovelace", "email": "ada@example.com", "message": "Hello"}'const response = await fetch("https://YOUR-QUICKFORMS-HOST/f/FORM_ID", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ name: "Ada Lovelace", email: "ada@example.com" }),
});
const body = await response.json();
if (response.status === 422) {
console.log(body.errors); // { email: ["The email field must be a valid email address."] }
}{
"message": "Thanks! Your submission has been received.",
"id": "01jca9qf3tz5b8m2k7w4r6x0hn",
"sandbox": false,
"spam": null,
"deliveries": null
}{
"message": "Thanks! Your submission has been received.",
"id": "01jca9qf3tz5b8m2k7w4r6x0hn",
"sandbox": true,
"spam": { "flagged": false, "spam": false, "failed_rules": false, "reasons": [] },
"deliveries": [
{ "channel": "notification", "status": "simulated", "target": "you@yourdomain.com" },
{ "channel": "webhook", "status": "simulated", "target": "https://example.com/hooks" }
]
}Only sandbox responses include the spam filter verdict in spam: flagged is true when the submission was held, spam is the base spam check, failed_rules is the form’s smart rules, and reasons lists the reasons from both. Live responses always have "spam": null, so a flagged live submission gets the same response as any other.
Success message
The message in the response, the hosted thank-you page and the embed all show the form’s settings.success_message. It can use the same {{field_name}} placeholders as the autoresponder, filled from the submission. A field that is empty or not in the form is left blank, and on the thank-you page the values are HTML-escaped.
{ "settings": { "success_message": "Thanks {{name}}! We will reply to {{email}} within a day." } }With that setting, a submission from Ada gets "message": "Thanks Ada! We will reply to ada@example.com within a day." (illustrative values).
Double opt-in
On a form with double opt-in, a submission only sends a confirmation email at first. Its notification email, autoresponder and webhook are held until the person clicks the link in that email. In a sandbox response, deliveries lists only the simulated confirmation email.
Unpublished forms and drafts
A form that has never been published only accepts sandbox submissions. Live submissions get a 404 with {"message": "This form has not been published yet."} (HTML posts get the hosted closed page with the same status).
Sandbox submissions run against the form’s draft, if it has one, so you can test changes before you publish them. Live submissions always use the published version.
Control fields
These names are reserved. They change how the submission is handled and are never stored as data.
| Field | What it does |
|---|---|
_gotcha |
The main honeypot. The default name, renameable per form. See honeypots. |
| Extra honeypots | Any names in the form’s settings.honeypot_fields. Filling one drops the submission. See honeypots. |
cf-turnstile-response |
The Cloudflare Turnstile token, when the form has Turnstile switched on. |
_redirect |
Where to send an HTML post after success. Only honoured for trusted hosts. |
_sandbox |
The form sandbox key, as an alternative to the X-Quickforms-Sandbox header. |
_source |
Set to "embed" by the embed widget and "hosted" by the hosted form page so those submissions are labelled. |
_link |
A personal link token (lnk_...), as an alternative to the X-Quickforms-Link header or ?link= on the endpoint URL. |
CORS
Each form has a list of allowed origins in settings.allowed_origins. The default is ["*"], which allows any origin. Each entry is *, a full origin such as https://example.com, or a wildcard for subdomains such as https://*.example.com. The wildcard must be a whole leading label, so https://*example.com is rejected when you save. A trailing slash is ignored. A POST from an origin that is not on the list gets a 403. Requests without an Origin header, such as server-to-server calls, are not affected. The form’s own hosted page and iframe embed are always allowed, so you don’t need to list them.
Allowed origins only restrict browsers. Anything else can send whatever Origin header it likes, so use them to stop other sites posting from a visitor’s browser, not as authentication.
Preflight OPTIONS requests are answered for the submit endpoint and for schema.json. Allowed request headers are Content-Type, Accept, X-Requested-With and X-Quickforms-Sandbox.
Honeypots
Add a hidden text input named _gotcha. People never see it, bots fill it in. When it has a value, quickforms responds as if the submission worked (with "id": null in JSON, and any placeholders in the success message left blank) but stores and sends nothing. Rename it per form with settings.honeypot_field, or for the whole installation with QUICKFORMS_HONEYPOT_FIELD.
A form can also have up to 5 extra honeypots in settings.honeypot_fields, ideally with realistic names such as website or fax. Filling any honeypot drops the submission in the same way, and honeypot values are never stored. The embed widget adds every honeypot for you. See honeypots for names and an HTML example.
Cloudflare Turnstile
When a form has Turnstile switched on with your own keys, every live submission must include a cf-turnstile-response token from the Turnstile widget. quickforms checks it with Cloudflare before validating the submission, and the token is never stored. Sandbox submissions skip the check.
A missing or rejected token gets a 422 with the error on cf-turnstile-response (HTML posts get the hosted error page):
- “Please complete the security check.” when there is no token.
- “The security check failed. Please try again.” when Cloudflare rejects it.
- “The security check could not be verified. Please try again.” when Cloudflare can’t be reached. The check fails closed, so the submission is not accepted.
Redirects
settings.redirect_urlis where HTML posts go after a successful submission.- A
_redirectfield overrides it only when its host matches one of the form’s allowed origins (other than*) or the host ofredirect_url. Anything else falls back toredirect_url, so the endpoint cannot be used as an open redirect. - With neither set, the visitor sees the hosted thank-you page.
Rate limits and statuses
See spam protection for how rate limiting, honeypots, Turnstile and the spam filter work together.
Submissions are rate limited per form and IP address: 30 a minute by default, set with QUICKFORMS_RATE_LIMIT. Uploads are capped at QUICKFORMS_MAX_UPLOAD_KB (10240 KB by default) and stored on QUICKFORMS_UPLOADS_DISK. Direct upload URLs from POST /f/{form}/uploads are limited to 60 a minute per IP address (see file uploads). Each form also has a ceiling that ignores the visitor’s address: 300 submissions and 300 upload requests a minute by default, set with QUICKFORMS_FORM_LIMIT_SUBMISSIONS and QUICKFORMS_FORM_LIMIT_UPLOADS.
| Status | When | JSON body |
|---|---|---|
201 |
Accepted (JSON clients) | {"message", "id", "sandbox", "spam", "deliveries"} |
303 |
Accepted HTML post with a redirect target | Redirect |
403 |
Form requires a personal link and none was sent | {"message": "...", "code": "link_required"} |
403 |
Origin not allowed | {"message": "This origin is not allowed to submit to this form."} |
404 |
Unknown form ID | Not found |
404 |
Form not published yet (live submissions only) | {"message": "This form has not been published yet."} |
404 |
Personal link token not valid for this form | {"message": "...", "code": "link_invalid"} |
409 |
Personal link already used up | {"message": "...", "code": "link_used"} |
410 |
Form switched off | {"message": "This form is not accepting submissions."} |
410 |
Personal link expired or revoked | {"message": "...", "code": "link_expired"} or "link_revoked" |
422 |
Validation failed | See validation errors |
422 |
Upload ID not found, used, expired, not uploaded or too large | Error on the file field. See file uploads |
422 |
Turnstile token missing or rejected | Error on cf-turnstile-response. See Cloudflare Turnstile |
429 |
Rate limit hit | {"message": "Too many submissions. Please wait a minute and try again."} |