AJAX & fetch

Submit a form with fetch, get JSON back and show the result without a page load. Covers error codes, FormData, CORS and idempotency keys.

10 minWorks on every planUpdated Sep 22, 2026

Before you start

Submitting with fetch keeps the visitor on your page and lets you show success or an error right next to the form. The endpoint answers with JSON whenever the request says Accept: application/json or sends a JSON body. Cross-origin requests are allowed, so this works from any site.

You need a form endpoint from the dashboard. If you use React or Next.js, their guides wrap the same pattern in a component: see React and Next.js.

  1. 1

    Start from a working form

    Begin with a form that already works without JavaScript. If the script fails to load, the browser still posts it normally.

    HTML
    <form id="contact-form" action="https://mtform.co/f/your-form-key" method="POST">
      <label>
        Name
        <input name="name" autocomplete="name" required>
      </label>
    
      <label>
        Email
        <input type="email" name="email" autocomplete="email" required>
      </label>
    
      <label>
        Message
        <textarea name="message" required></textarea>
      </label>
    
      <!-- Spam trap: hidden from people, tempting to bots. Leave it empty. -->
      <input
        type="text"
        name="_mtf_honeypot"
        tabindex="-1"
        autocomplete="off"
        aria-hidden="true"
        style="position:absolute;left:-9999px"
      >
    
      <button type="submit">Send</button>
    </form>
  2. 2

    Intercept the submit

    This script stops the normal post, sends the fields as JSON, and treats any 2xx as success. It resets the form only on success, so a failed send never wipes what the visitor typed.

    JavaScript
    const form = document.querySelector('#contact-form');
    
    form.addEventListener('submit', async (event) => {
      event.preventDefault();
    
      const button = form.querySelector('button[type="submit"]');
      button.disabled = true;
    
      try {
        const response = await fetch('https://mtform.co/f/your-form-key', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(Object.fromEntries(new FormData(form))),
        });
    
        const result = await response.json();
    
        if (!response.ok) {
          throw new Error(result.error?.message ?? 'Unable to send your message.');
        }
    
        form.reset();
        // Replace with your own success UI.
        console.log('Sent', result.submission_id);
      } catch (error) {
        // Values are preserved because we never reset on failure.
        console.error(error.message);
      } finally {
        button.disabled = false;
      }
    });

    Replace the two console lines with your own UI, for example writing the message into an element with role="status" or role="alert".

  3. 3

    Read the response

    A new submission returns 201. A repeat of one already accepted, sent with the same idempotency key or resent unchanged within a minute, returns 200 and stores nothing new.

    Success
    {
      "ok": true,
      "submission_id": "5f0c2b8e-3d1a-4c6e-9b7f-2a8d4e6c1b90",
      "message": "Submission received"
    }

    A failure returns an error object. error.message is written to be shown to the visitor as it is; branch on error.code if you want your own wording.

    Error · 429 Too Many Requests
    {
      "ok": false,
      "error": {
        "code": "rate_limited",
        "message": "Too many submissions. Please wait a moment and try again."
      },
      "request_id": "0b6f7c1e-8a2d-4f3b-9c5e-1d7a2e4b6f80"
    }
    Spam still gets a 2xx. A submission caught by the honeypot or the spam filter is answered like a real one, so a bot learns nothing. Your success message will show for it too, which is fine.

Error codes to handle

CodeStatusWhat it means
validation_failed422The submission had no fields. Check every input has a name.
rate_limited429Too many submissions from this visitor. Ask them to wait a moment.
origin_not_allowed403The page is not on the form’s allowed domains.
form_disabled403The form is paused, or the workspace is not active.
form_not_found404The key is wrong or was rotated. Check the endpoint URL.
challenge_failed403The Turnstile token was missing or invalid.
quota_exceeded429The workspace reached its monthly ceiling (the plan limit, or twice it on paid plans with overage).
payload_too_large413The body is over the size limit.
unsupported_content_type415The request had no Content-Type, or one the endpoint does not read.
service_unavailable503Nothing was stored. Safe to retry.

Only validation_failed can carry a fields object, and today it only reports an empty submission. Keep your own required attributes for per-field checks. The complete list is in the API reference.

Sending FormData instead of JSON

JSON is fine for text fields. To send files, or to skip building an object, pass the FormData itself and ask for JSON back:

JavaScript
const response = await fetch(form.action, {
  method: 'POST',
  headers: { Accept: 'application/json' },
  body: new FormData(form),
});
  • Do not set Content-Type. The browser adds multipart/form-data with the boundary it needs; setting it by hand breaks the body.
  • Keep the Accept header. The endpoint chooses between JSON and a redirect from the request headers, and this makes the choice explicit instead of relying on the default fetch sends.
  • Attachments are covered in File uploads.

Retries and idempotency

If you retry automatically, for example after a 503 or a network error, send an Idempotency-Key header. A retry with the same key is answered with success instead of storing a second submission. Keys are honoured for 24 hours.

JavaScript
// One key per message. Make a new one after each successful send.
let idempotencyKey = crypto.randomUUID();

const response = await fetch(form.action, {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: new FormData(form),
});

if (response.ok) idempotencyKey = crypto.randomUUID();

Reusing a key with a different body is refused with idempotency_conflict, so generate a fresh key for each new message, not one per session.

Only two custom request headers are allowed. Beyond the headers browsers always allow, such as Accept, the endpoint's CORS policy accepts Content-Type and Idempotency-Key. A wrapper that adds anything else, such as Authorization or X-Requested-With, fails the browser's preflight check and the request never arrives.

For a 429, do not retry in a loop. The limits are short and recover on their own; see Rate limits.

Next steps