Skip to content

The submit endpoint

POST /f/{form} accepts HTML form posts and JSON from any site.

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_url is where HTML posts go after a successful submission.
  • A _redirect field overrides it only when its host matches one of the form’s allowed origins (other than *) or the host of redirect_url. Anything else falls back to redirect_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."}

Type to search…

↑↓ navigate↵ selectEsc close