Skip to content
← All use cases
Use case · Contact form API

A contact form API you call with fetch()

The same endpoint that takes a plain HTML form also takes JSON. Send a POST with an Accept: application/json header and you get JSON back instead of a redirect, so your page decides what the visitor sees next. No API key, no SDK and no server route of your own.

const ENDPOINT = "https://forms.formward.eu/f/YOUR_FORM_ID";

async function sendMessage(fields) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(fields),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(body.error ?? res.statusText), { status: res.status, body });
  return body; // { ok: true, id: "...", files: 0 }
}

await sendMessage({
  name: "Ada",
  email: "ada@example.com",
  message: "Do you ship to Norway?",
  _gotcha: "", // honeypot: keep it empty
});

Every key in the object becomes a field on the submission. Keys that start with an underscore, such as _gotcha and _replyto, are control fields that change how the endpoint behaves. It works on every plan, including Free with 50 submissions a month.

The response shape

A successful submission returns 200 with ok: true, the submission id and a count of stored files. The body can also carry the success message set on the form, and a redirect URL if you configured one, so you can reuse them in your own UI.

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://www.example.com

{ "ok": true, "id": "4f6c1e2a-...", "files": 0 }

Every error has the same shape: ok: false and an error string. A few add detail, such as upgrade: true on plan limits or the field name on a value that is too long.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "ok": false, "error": "consent_required" }

Status codes to handle

CodeMeaning
200Accepted. The body has ok: true and the submission id.
400The body is not a JSON object, or it carries more files than allowed.
402The monthly submission quota is used up. The body adds upgrade: true.
403The origin is not on the allowed list, or the Turnstile check failed.
404No active form with that ID.
405You sent a GET. The endpoint only takes POST.
413The body or an uploaded file is too large.
422Required consent is missing, or there are too many fields or an overlong value.
429Rate limited. Wait for the number of seconds in Retry-After.

For a contact form, three branches cover almost everything: success, 429 (ask the visitor to wait) and everything else (show a fallback, such as your email address). The 402, 403 and 404 cases are configuration problems you fix once in the dashboard, not something a visitor can act on.

CORS and allowed origins

A JSON request from another origin triggers a CORS preflight. The endpoint answers it and allows the Content-Type and X-Requested-With headers. Accept: application/json is the preferred way to ask for a JSON response: it is a safe header and needs no permission. X-Requested-With also works, but other custom headers are not in the allowed list, so the browser blocks the request after the preflight.

The allowed-origins list in the form settings is the real gate. When it is empty, any site may post. When it has entries, the request's Origin must match one of them exactly, scheme and host included, so add both https://example.com and https://www.example.com if you serve both. Anything else gets a 403, and the response carries no CORS header for that origin.

API keys are for reading, not submitting

Formward has two separate surfaces. The submission endpoint on forms.formward.eu is public and keyed only by the form ID, because it has to work from any visitor's browser. The REST API on formward.eu/api/v1 reads and manages forms and submissions, and that one needs an API key.

Keep the two apart. An API key in front-end code would let anyone read every submission the key can see. If you want submissions in your own system, see the form submission API page for the REST API and webhooks.

Progressive enhancement

The sturdiest version is a normal HTML form that JavaScript takes over when it can. If the script fails to load, the form still posts and the visitor lands on your thank-you page. This version sends form-encoded data, so there is no preflight at all:

<form id="contact" action="https://forms.formward.eu/f/YOUR_FORM_ID" method="POST">
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <input name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
  <button>Send</button>
  <p role="status" aria-live="polite"></p>
</form>

<script>
  const form = document.getElementById("contact");
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const status = form.querySelector("[role=status]");
    status.textContent = "Sending...";
    try {
      const res = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new URLSearchParams(new FormData(form)),
      });
      if (res.ok) {
        form.reset();
        status.textContent = "Thanks, your message is on its way.";
      } else if (res.status === 429) {
        status.textContent = "Too many attempts. Please wait a minute and try again.";
      } else {
        status.textContent = "Something went wrong. Please try again or email us.";
      }
    } catch {
      // Offline, blocked or a network error: fetch() rejects before any response.
      status.textContent = "Could not reach the server. Check your connection and try again.";
    }
  });
</script>

The aria-live region announces the result to screen reader users, who would otherwise get no signal that anything happened.

Where the data lives

Formward stores submissions, file attachments and backups in Sweden, on servers run by Hostup AB. Notification email goes out through Mailjet from an EU data centre. Submitter IP addresses are replaced with a keyed HMAC-SHA-256 hash on receipt, so the raw address is never written to disk. On Professional and Business, AI features run on Mistral AI in France and can be switched off per form.

Data leaves the EU only where you send it: a notification inbox hosted outside the EU, a webhook URL, a Slack channel or a Google Sheet. Cloudflare Turnstile is a US service, and it is off unless you switch it on for a form. The details are in the sub-processor register, the data residency page and the published DPA. For the wider picture, see GDPR form backend.

Questions

Do I need an API key to submit a contact form?
No. Submitting only needs the form ID, which is public by design. API keys belong to the REST API for reading and managing submissions, and they should never be put in browser code.
Should I send JSON or form-encoded data?
Both are accepted and stored the same way. Form-encoded data built with URLSearchParams is a simple request, so the browser skips the CORS preflight. JSON is handier when the data does not come from a form element. Files need multipart/form-data.
Why do I get a 403 on localhost?
The form's allowed-origins list is filled in and your local address is not on it. Add the exact origin, such as http://localhost:3000, while you develop, and remove it before launch if you like.
Can a bot get a success response without the message being stored?
Yes, on purpose. When the honeypot field is filled in, the endpoint answers like a normal success so the bot learns nothing, and files the submission as spam. A real visitor never fills a hidden field, so this does not affect them.
Can I send the request from my own server instead?
You can, but read the serverless forms page first. Every request then comes from your server's IP address, so the per-IP rate limit counts all your visitors together, and a request with no Origin header is refused once you have an allowed-origins list.

Try the API with a real form

Create a form, paste its ID into the fetch() call and send a test from your browser console.

Contact form API: submit JSON with fetch() | Formward