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.
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
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
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.
JavaScriptconst 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
consolelines with your own UI, for example writing the message into an element withrole="status"orrole="alert". - 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, returns200and stores nothing new.Success{ "ok": true, "submission_id": "5f0c2b8e-3d1a-4c6e-9b7f-2a8d4e6c1b90", "message": "Submission received" }A failure returns an error object.
error.messageis written to be shown to the visitor as it is; branch onerror.codeif 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
| Code | Status | What it means |
|---|---|---|
| validation_failed | 422 | The submission had no fields. Check every input has a name. |
| rate_limited | 429 | Too many submissions from this visitor. Ask them to wait a moment. |
| origin_not_allowed | 403 | The page is not on the form’s allowed domains. |
| form_disabled | 403 | The form is paused, or the workspace is not active. |
| form_not_found | 404 | The key is wrong or was rotated. Check the endpoint URL. |
| challenge_failed | 403 | The Turnstile token was missing or invalid. |
| quota_exceeded | 429 | The workspace reached its monthly ceiling (the plan limit, or twice it on paid plans with overage). |
| payload_too_large | 413 | The body is over the size limit. |
| unsupported_content_type | 415 | The request had no Content-Type, or one the endpoint does not read. |
| service_unavailable | 503 | Nothing 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:
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});- Do not set
Content-Type. The browser addsmultipart/form-datawith the boundary it needs; setting it by hand breaks the body. - Keep the
Acceptheader. The endpoint chooses between JSON and a redirect from the request headers, and this makes the choice explicit instead of relying on the defaultfetchsends. - 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.
// 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.
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.