Skip to content
Building Your Form

AJAX and JSON submissions

Submit forms with AJAX, FormData, or JSON using the JavaScript fetch API and Qubeforms. Handle validation errors, redirects, and retries in your website.

1. Add the external form stylesheet

Save the CSS below as a separate file in your website. HTML examples load it from /static/assets/css/form-helper.css; React and Vue import ./form-helper.css beside the component. Adjust the path to match your site and confirm the file loads before publishing. The generic .form-helper class moves the text input off-screen without the hidden attribute or display: none. Keep tabindex="-1", autocomplete="off", and aria-hidden="true". New forms use fax_number; existing forms keep their saved name. Match Settings → Delivery & spam → Honeypot field in HTML, JSON, and drafts. To rename an existing trap, update both the setting and the website together. Never use a name that also collects a real visitor answer.

.form-helper {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
    pointer-events: none;
}

2. Prepare the HTML

Create an active form, allow your website origin, and replace the public key below. This example assumes CAPTCHA is not required. Its native POST still works if JavaScript is unavailable; JavaScript adds feedback without leaving the page. Labels and a status region help visitors understand the form and hear request updates.

<link rel="stylesheet" href="/static/assets/css/form-helper.css">
<form id="ajax-contact" action="https://api.qubeforms.io/v1/cf_pk_YOUR_KEY/submissions" method="post">
  <fieldset>
    <legend>Contact us</legend>
    <label for="ajax-email">Email (required)</label>
    <input id="ajax-email" name="email" type="email" autocomplete="email" required>
    <label for="ajax-message">Message (required)</label>
    <textarea id="ajax-message" name="message" maxlength="20000" required></textarea>
    <input class="form-helper" name="fax_number" type="text" tabindex="-1" autocomplete="off" aria-hidden="true">
    <button type="submit">Send</button>
  </fieldset>
  <p id="ajax-feedback" role="status" aria-live="polite"></p>
</form>

3. Submit FormData with fetch

Place this script after the form. Capture FormData before disabling the fieldset, because disabled controls are excluded. Let the browser set Content-Type and the multipart boundary. The handler prevents duplicate clicks, checks both HTTP and JSON success, and preserves values after errors. It does not automatically retry uncertain requests.

<script>
const form = document.querySelector('#ajax-contact');
const fieldset = form.querySelector('fieldset');
const feedback = document.querySelector('#ajax-feedback');
let sending = false;

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (sending || !form.reportValidity()) return;
  const body = new FormData(form);
  sending = true;
  fieldset.disabled = true;
  fieldset.setAttribute('aria-busy', 'true');
  feedback.textContent = 'Sending…';
  try {
    const response = await fetch(form.action, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body,
    });
    const result = await response.json().catch(() => null);
    if (!response.ok) {
      const delay = response.headers.get('Retry-After');
      feedback.textContent = result?.error?.message || `Request failed (HTTP ${response.status}).`;
      if (response.status === 429 && delay) {
        feedback.textContent += ` Wait at least ${delay} seconds before trying again.`;
      }
      return;
    }
    if (result?.ok !== true) {
      feedback.textContent = 'We could not confirm receipt. Check before sending again.';
      return;
    }
    form.reset();
    feedback.textContent = 'Your submission was received.';
  } catch {
    feedback.textContent = 'We could not confirm receipt. Check before sending again.';
  } finally {
    sending = false;
    fieldset.disabled = false;
    fieldset.removeAttribute('aria-busy');
  }
});
</script>

4. Send JSON instead when there are no files

For the two text fields above, replace only the fetch call with this version; body still refers to the captured FormData. Build the JSON object explicitly so repeated fields or files are not silently lost. For checkbox groups use body.getAll() with the exact control name. JSON cannot upload file bytes. Include any additional control fields, such as a CAPTCHA token or requested redirect, when adapting the example.

const response = await fetch(form.action, {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: body.get('email'),
    message: body.get('message'),
    fax_number: body.get('fax_number'),
  }),
});

5. Handle receipt and validation honestly

JSON submission success uses 201 with ok: true; the record may be in Inbox or Spam, and delivery is asynchronous. 422 can mean invalid fields, CAPTCHA, redirects, uploads, or spam rejection. 403 can mean an origin rule, 404 an unavailable form, and 429 a rate or usage limit. Use error messages as text, not HTML. An unreadable response or a network failure can occur after receipt, so avoid claiming the submission was definitely lost.

6. Plan retries and redirects

Before adding retries, implement one Idempotency-Key per intentional submission and reuse it with the same payload for that attempt. Generating a new key on every retry does not prevent duplicates. With Accept: application/json, an approved _cf_redirect is returned as redirect_url without navigating the page. The example shows inline success; see Safe thank-you redirects if you want navigation after confirmed receipt.

7. Verify browser behavior

Test success, a rejected request, an unreadable response, and repeated clicks. Check that values survive failures and controls become usable again. Test with keyboard-only navigation and at narrow screen widths. Do not use mode: no-cors, manually set Origin, or include dashboard cookies. For uploads and multi-step forms, use Adding file uploads and Multi-step drafts.

Keep private credentials private

Public form keys can appear in browser code. Read keys, management credentials, and webhook signing secrets must stay on the server.