Vanilla JS
HTML form handling without a backend
A static HTML page has no server, so the moment you add a form you need somewhere for the data to go. Formward is that somewhere: set the form's action to your Formward endpoint and the browser handles the rest. No build step, no framework, no backend - just an HTML file you can host anywhere.
The first form below works with JavaScript disabled and is the whole solution for most static pages. The second snippet layers on a vanilla fetch() submit so you can keep the user on the page and show success or error inline when scripting is available.
EU-hosted · One HTML file plus an EU endpoint is the simplest GDPR-friendly form you can build: no server of your own to audit, and submissions stored in Sweden. Data leaves the EU only if you add Turnstile or forward it somewhere yourself.
The HTML example
Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.
html
<!-- 1) No JavaScript required - a standard POST. -->
<form action="https://forms.formward.eu/f/<FORM_ID>" method="POST">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- Honeypot: leave empty. -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="display:none" aria-hidden="true" />
<button type="submit">Send</button>
</form>
<!-- 2) Optional vanilla-JS AJAX submit for inline feedback. -->
<form id="contact" action="https://forms.formward.eu/f/<FORM_ID>" method="POST">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="display:none" aria-hidden="true" />
<button type="submit">Send</button>
<p id="status" role="status"></p>
</form>
<script>
const form = document.getElementById("contact");
const out = document.getElementById("status");
form.addEventListener("submit", async (e) => {
e.preventDefault();
out.textContent = "Sending…";
try {
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
// { ok: true, id, files, success } or { ok: false, error }
const data = await res.json();
if (res.ok && data.ok) {
form.reset();
out.textContent = "Thanks - message sent.";
} else if (res.status === 429) {
out.textContent = "Too many messages. Please wait a minute.";
} else if (res.status === 422 && data.error === "consent_required") {
out.textContent = "Please tick the consent box.";
} else {
out.textContent = "Could not send. Please try again later.";
}
} catch {
out.textContent = "Could not send. Check your connection.";
}
});
</script>Success and error handling
- Form one needs nothing but HTML: the browser POSTs to Formward and follows the 302 redirect (or lands on your thank-you page).
- Form two calls e.preventDefault() and sends Accept: application/json, so Formward returns { ok: true, id, files, success } and the visitor stays put.
- FormData(form) serialises every named field, including file inputs - the JSON response reports how many were stored in its files count. File uploads need the Professional or Business plan.
- Every response this endpoint can give is shown with a real example in the sections below, so you can decide what your visitors see in each case.
Send the HTML form to your email
With instant notifications on (the default for a new form), every submission is emailed to the notification address on the form, so a plain HTML form becomes a working email form without a mail server, a PHP mailer script or SMTP credentials in the page. The visitor fills in the form, the browser posts it to Formward, and the message is in your inbox seconds later. A form can instead batch notifications into an hourly, daily or weekly digest, switch them off, or send link-only notifications that carry no submission content.
Two optional fields shape that email. Name the visitor's address field _replyto and the notification's Reply-To is set to it, so answering from your mail client reaches the visitor. A hidden _subject field sets the subject line, useful when several forms notify the same inbox; a custom subject configured in the form's notification settings takes precedence over it.
The email is a copy, not the only record: the submission itself is stored in Sweden and stays in the dashboard with search, export and spam filtering, so a lost or filtered notification never loses the message.
html
<form action="https://forms.formward.eu/f/<FORM_ID>" method="POST">
<!-- Reply-To of the notification email -->
<input type="email" name="_replyto" placeholder="Your email" required />
<!-- Subject line of the notification email -->
<input type="hidden" name="_subject" value="New message from the website" />
<textarea name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="display:none" aria-hidden="true" />
<button type="submit">Send</button>
</form>What a successful submission returns
A classic form post (form one) gets a 302 redirect. Formward picks the target in this order: a _redirect field in the form, then the redirect URL in the form's settings, then Formward's default thank-you response. A _redirect value can be a path such as /thanks, which is resolved against the page that sent the form, or a full URL whose origin is in the form's allowed origins. Anything else is ignored, so the field cannot be abused as an open redirect.
A fetch post (form two) gets 200 and JSON. id is the submission id, files is the number of stored files, and success carries the confirmation text set in the form's settings, so you can show the same message the hosted form would. When the form has a redirect URL configured, the JSON also includes redirect.
http
<!-- Classic post: send the visitor to your own page -->
<input type="hidden" name="_redirect" value="/thanks" />
HTTP/1.1 302 Found
Location: https://example.com/thanks
# Fetch post with Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"id": "3f2a9c1e-5b7d-4e2a-9c61-0d8f2b4a7e10",
"files": 0,
"success": {
"title": "Thank you!",
"body": "Your response has been received.",
"ctaLabel": "",
"ctaUrl": ""
}
}Validation errors (422)
Your HTML required attributes stop most mistakes in the browser, but Formward also checks on its side. If you turn on consent capture and make it required, a post without a ticked _consent box is refused. Two global limits apply to every form: at most 150 fields, and at most 50,000 characters in one field.
Nothing is stored and nothing counts against your plan when a submission is refused. A fetch post gets the JSON below; a classic post gets a short HTML error page instead, so visitors never see raw JSON.
http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{ "ok": false, "error": "consent_required" }
# The other 422 errors:
{ "ok": false, "error": "too_many_fields" }
{ "ok": false, "error": "field_too_long", "field": "message" }Spam: the honeypot and Turnstile
When a bot fills the hidden _gotcha field, Formward still answers with a success response (200 for fetch, a redirect for a classic post). The submission is filed in your spam folder, no notification is sent, and it does not count against your plan. Bots get no signal to adapt to.
If you switch on Cloudflare Turnstile for the form, a post with a missing or invalid token is refused with 403 and is not stored. Turnstile is optional and off by default; Cloudflare is a US provider and receives challenge data only, not the form answers.
http
HTTP/1.1 403 Forbidden
Content-Type: application/json
{ "ok": false, "error": "captcha failed" }Plan limit (402) and rate limit (429)
When the account has used its monthly submissions, new posts get 402 and are not stored, and the form owner gets an email about it. Free includes 50 submissions a month; Professional and Business accept 20% over their limit before this hard stop. The numbers for each plan are on the plans page.
Too many posts from one address to one form in a minute get 429 with a Retry-After header in seconds. The default allows 50 a minute, and you can set a stricter per-form limit in the form's settings.
If you set allowed origins for the form, a post from any other site gets 403 with the error origin not allowed.
http
HTTP/1.1 402 Payment Required
Content-Type: application/json
{ "ok": false, "error": "submission limit reached", "upgrade": true }
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{ "ok": false, "error": "rate limit" }Spam protection
Every example above includes the _gotcha honeypot field. It is hidden from real users and must stay empty. When a bot fills it, Formward files the submission as spam and still answers with a normal success response, so the bot learns nothing and nothing reaches your inbox. Rate limits and the origin allowlist are checked on Formward's side as well. For a stricter gate, switch on Cloudflare Turnstile in the form's settings and add the widget; it adds a cf-turnstile-response field that Formward verifies on receipt. Turnstile is optional, and Cloudflare is a US provider, listed as such in the sub-processor register. The spam docs have the widget markup.
The JSON response
A standard POST gets a 302 redirect (or your thank-you page). Send the Accept: application/json header - as every fetch() example above does - and Formward returns JSON instead:
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true, "id": "3f2a9c1e-...", "files": 0, "success": { "title": "Thank you!", ... } }files is the number of uploaded files stored and success carries the confirmation text from the form's settings. When you set a redirect URL for the form, the reply also has a redirect field your code can send the visitor to. On failure the body is { ok: false, error } with a 4xx status: 422 when required consent is missing or a field is too long, 403 when a Turnstile check fails, 429 for too many submissions from one address, 402 when the account is over its plan limit. See responses and redirects for the full table and the AJAX docs for CORS.
Other frameworks
Collect your first HTML submission
Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.