> ## Documentation Index
> Fetch the complete documentation index at: https://formcarry.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# JavaScript

> Plain JavaScript sends a form to formcarry with fetch and shows the answer without leaving the page.

The form keeps its `action` and `method`, so it posts on its own until the script runs. The same request carries files, a spam blocker token and validation errors, and Axios or `XMLHttpRequest` can send it for code that uses them.

## Prerequisites

Before you start, you need:

* A formcarry account. [Sign up](https://app.formcarry.com/register) is free.
* A form in the [dashboard](https://app.formcarry.com). Its endpoint is on the form's Setup page. The examples use `https://formcarry.com/s/AbC123xyz`; put yours in its place.

## Fetch

To send a form with `fetch`, keep `action` and `method="POST"` on the form, post a `FormData` built from it on submit, and send `Accept: application/json`:

```html theme={null}
<form id="contact" action="https://formcarry.com/s/AbC123xyz" method="POST">
  <input type="email" name="email" placeholder="Email">
  <textarea name="message" placeholder="Message"></textarea>
  <button type="submit">Send</button>
  <p id="result"></p>
</form>

<script>
  const form = document.getElementById("contact")
  const result = document.getElementById("result")

  form.addEventListener("submit", async (e) => {
    e.preventDefault()
    const res = await fetch(form.action, {
      method: "POST",
      headers: { "Accept": "application/json" },
      body: new FormData(form),
    })
    const data = await res.json()
    result.textContent = data.message
  })
</script>
```

Until the script has run, the browser posts the form itself and the visitor lands on the thank you page. After that, the answer is JSON.

The `email` field is the visitor's address, so it becomes the reply to address of your notification email and the recipient of the auto response. A stored submission answers with `code: 200`. A refused one answers with the reason in `message`, see [What every form needs](/docs/what-every-form-needs).

Send `Accept: application/json` rather than leaving it out, otherwise the answer is the thank you page's HTML and `res.json()` throws.

To refuse a second click before the answer arrives, disable the submit button and enable it again after:

```js theme={null}
const button = form.querySelector("button[type=submit]")

form.addEventListener("submit", async (e) => {
  e.preventDefault()
  button.disabled = true
  try {
    const res = await fetch(form.action, {
      method: "POST",
      headers: { "Accept": "application/json" },
      body: new FormData(form),
    })
    const data = await res.json()
    result.textContent = data.message
  } catch {
    result.textContent = "No answer from formcarry. Send again."
  } finally {
    button.disabled = false
  }
})
```

A second submission within 15 seconds from the same address gets a `429`, so the button stays disabled until the answer is in. `fetch` rejects only when no answer came back; a refused submission resolves, with the reason in `data.message`.

To post an object instead, send it as JSON with `Content-Type: application/json`:

```js theme={null}
const fields = { email: form.elements.email.value, message: form.elements.message.value }

const res = await fetch(form.action, {
  method: "POST",
  headers: { "Accept": "application/json", "Content-Type": "application/json" },
  body: JSON.stringify(fields),
})
```

The keys are the field names formcarry stores.

## Files

To send files, add file inputs to the form from the first example and keep the `FormData` post:

```html theme={null}
<input type="file" name="attachment">
<input type="file" name="photos" accept=".jpg, .png" multiple>
```

The script does not change. A `multiple` input sends every chosen file under its name.

For `File` objects from elsewhere, append each one under its own name:

```js theme={null}
const data = new FormData(form)
files.forEach((file, i) => data.append(`file-${i}`, file, file.name))
```

Leave `Content-Type` to the browser rather than setting it, otherwise the multipart boundary is missing and the upload fails.

For small files, stay with JSON and send each file as a data URL, one key per file, never an array:

```js theme={null}
const toDataUrl = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })

const attachment = await toDataUrl(form.elements.attachment.files[0])

body: JSON.stringify({ ...fields, attachment }),
```

Use data URLs for small files. Using them with larger files bloats the request by about a third and reaches the 50 MB limit sooner.

<Note>
  Files are stored on paid plans only. Free plans store the rest of the submission without them.
</Note>

## Spam blocker

To add a challenge, load its script, put a `div` with the widget class and your site key inside the form, and paste the secret key into the form's settings under Form Security:

| Challenge    | Script                                                    | Widget class   | Token field             |
| ------------ | --------------------------------------------------------- | -------------- | ----------------------- |
| reCAPTCHA v2 | `https://www.google.com/recaptcha/api.js`                 | `g-recaptcha`  | `g-recaptcha-response`  |
| reCAPTCHA v3 | `https://www.google.com/recaptcha/api.js?render=SITE_KEY` | none           | `g-recaptcha-response`  |
| hCaptcha     | `https://js.hcaptcha.com/1/api.js`                        | `h-captcha`    | `h-captcha-response`    |
| Turnstile    | `https://challenges.cloudflare.com/turnstile/v0/api.js`   | `cf-turnstile` | `cf-turnstile-response` |

With reCAPTCHA v2:

```html theme={null}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>

<!-- inside the form, before the submit button -->
<div class="g-recaptcha" data-sitekey="SITE_KEY"></div>
```

The reCAPTCHA v2, hCaptcha and Turnstile widgets each add a field named after their token to the form, so the `FormData` post above carries it.

With reCAPTCHA v3 there is no widget. Load the script with your site key, add a hidden input for the token, and ask for a token when the visitor submits:

```html theme={null}
<script src="https://www.google.com/recaptcha/api.js?render=SITE_KEY"></script>

<!-- inside the form -->
<input type="hidden" name="g-recaptcha-response">

<script>
  form.addEventListener("submit", (e) => {
    e.preventDefault()
    grecaptcha.ready(() => {
      grecaptcha.execute("SITE_KEY", { action: "submit" }).then(async (token) => {
        form.elements["g-recaptcha-response"].value = token
        const res = await fetch(form.action, {
          method: "POST",
          headers: { "Accept": "application/json" },
          body: new FormData(form),
        })
        const data = await res.json()
        result.textContent = data.message
      })
    })
  })
</script>
```

With a JSON post, read the token and add it by hand:

```js theme={null}
body: JSON.stringify({ ...fields, "g-recaptcha-response": form.elements["g-recaptcha-response"].value }),
```

Refuse to send while the token is empty rather than posting anyway, otherwise formcarry answers `403` for reCAPTCHA or `400` for the others:

```js theme={null}
if (!form.elements["g-recaptcha-response"].value) {
  result.textContent = "Complete the challenge first."
  return
}
```

Add `localhost` to the challenge's allowed domains while you test.

## Validation errors

A `422` carries `errors`, one entry per failing field, each with a `message`. To mark each failing field invalid and let the browser report it:

```js theme={null}
if (data.code === 422) {
  for (const name in data.errors) {
    const field = form.elements[name]
    field.setCustomValidity(data.errors[name].message)
    field.addEventListener("input", () => field.setCustomValidity(""), { once: true })
  }
  form.reportValidity()
}
```

The browser shows each message next to its field in its own style. Clear the custom validity on the next input rather than leaving it set, otherwise the browser refuses to submit the form again.

To show each message in your own markup instead, mark the field and add a span after it:

```js theme={null}
if (data.code === 422) {
  for (const name in data.errors) {
    const field = form.elements[name]
    field.classList.add("fc-field-error")
    const note = document.createElement("span")
    note.className = "fc-field-error-message"
    note.textContent = data.errors[name].message
    field.after(note)
  }
}
```

Remove the class and the spans before the next post rather than leaving them, otherwise old messages stay next to fields that now pass.

## Axios

The same post with Axios from its CDN script, with `form` and `result` from the first example:

```html theme={null}
<script src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js"></script>
<script>
  form.addEventListener("submit", async (e) => {
    e.preventDefault()
    try {
      const { data } = await axios.post(form.action, new FormData(form), {
        headers: { Accept: "application/json" },
      })
      result.textContent = data.message
    } catch (err) {
      result.textContent = err.response ? err.response.data.message : "No answer from formcarry. Send again."
    }
  })
</script>
```

Leave `Content-Type` to Axios rather than setting it, otherwise the `FormData` upload loses its boundary. Axios rejects on any status outside `2xx`, so formcarry's answer to a refused submission is in `err.response.data`. A request that got no answer has no `response`, so check for it before reading it.

To post an object instead, pass it in place of the `FormData`; Axios sends it as JSON:

```js theme={null}
const { data } = await axios.post(form.action, fields, {
  headers: { Accept: "application/json" },
})
```

## XMLHttpRequest

For code that cannot use `fetch`, the same post with `XMLHttpRequest`:

```js theme={null}
var xhr = new XMLHttpRequest()
xhr.open("POST", "https://formcarry.com/s/AbC123xyz")
xhr.setRequestHeader("Accept", "application/json")
xhr.onload = function () {
  var data = JSON.parse(xhr.responseText)
  result.textContent = data.message
}
xhr.onerror = function () {
  result.textContent = "No answer from formcarry. Send again."
}
xhr.send(new FormData(form))
```

`onload` runs for `422` and `403` too, so read `data.code`. `onerror` runs only when no answer came back.

For a JSON body, set `Content-Type` and send a string:

```js theme={null}
xhr.setRequestHeader("Content-Type", "application/json")
xhr.send(JSON.stringify(fields))
```

## What's next

* [Field validations](/docs/features/field-validations): the rules you can set per field.
* [Spam protection](/docs/features/spam-protection): the challenges, the filter and the honeypot.
* [Email notifications](/docs/features/email-notifications): the auto response the visitor gets, keyed on `email`.

Stuck? Write to [help@formcarry.com](mailto:help@formcarry.com). Include the form id.


## Related topics

- [Accept and Manage File Uploads](/docs/features/file-uploads.md)
- [Thank You Pages and Redirects](/docs/features/thank-you-pages.md)
- [Squarespace](/docs/builders/squarespace.md)
- [WordPress](/docs/builders/wordpress.md)
- [Quickstart](/docs/quickstart.md)
