AJAX contact form

The same contact fields, sent with fetch as JSON, so the visitor stays on your page and sees a short status.

What you are copying

This starter sends the same contact fields with fetch, so the visitor stays on your page and reads a short status.

The form still has an action and method POST. If JavaScript does not run, the browser does a normal post instead.

That fallback matters for older browsers and for people who block scripts. You do not maintain two forms.

The primary snippet sends JSON. It sets Content-Type to application/json and Accept to application/json.

Success is the boolean success in the JSON. When it is true, the snippet shows the response message, or a short thank-you, and resets the form.

When it is false, the snippet shows the response message. Formgong writes that message in the language of _lang.

JSON and FormData

A JSON body must list the fields you want to send. The snippet includes access_key, _lang, name, email, phone, message and botcheck.

If you later uncomment Turnstile, the JSON path also copies cf-turnstile-response when that field has a value.

The extra snippet uses FormData instead of JSON. The browser sets the content type, including the boundary. Do not set Content-Type yourself.

FormData picks up every field in the form, including a Turnstile token if the widget is inside the form.

Both paths send Accept: application/json. Without that header, a normal browser form post gets the thank-you page, not JSON.

Do not send both a JSON body and a multipart body. Pick one. The two snippets are alternatives, not layers.

Spam, Telegram and limits

Keep the honeypot named botcheck, empty, off screen, with tabindex -1. A filled honeypot is stored as spam and is not delivered.

Field names stay short so Telegram can print them in bold. Do not rename email to a long sentence.

The access key stays in the page. Moving it to a server secret does not make the form safer, and this snippet has no server.

On the free plan, Telegram is immediate and email is a daily digest. The inbox keeps submissions for 30 days.

The free plan includes 300 submissions a month. A spam submission does not use one of those.

This preview does not post. Wire the snippet on your site, then send a test from that page.

Formgong runs the service. For help with the product, write to support@formgong.com.

Preview

Form code

HTML
<form id="fg-ajax" action="https://formgong.com/submit" method="POST" style="position:relative">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <input type="hidden" name="_lang" value="en">
  <label>Name <input type="text" name="name" autocomplete="name" required></label>
  <label>Email <input type="email" name="email" autocomplete="email" required></label>
  <label>Phone <input type="tel" name="phone" autocomplete="tel"></label>
  <label>Message <textarea name="message" required></textarea></label>
  <div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
  <label>Leave this field empty <input type="text" name="botcheck" tabindex="-1" autocomplete="off"></label>
</div>
  <!--
Optional. Turn Turnstile on for this form, then remove these comment marks and paste your site key.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" data-language="en"></div>
-->
  <button type="submit">Send</button>
  <p data-status role="status"></p>
</form>
<script>
(function () {
  var form = document.getElementById("fg-ajax");
  if (!form) return;
  var status = form.querySelector("[data-status]");
  form.addEventListener("submit", function (event) {
    event.preventDefault();
    var data = {
      access_key: form.access_key.value,
      _lang: form._lang.value,
      name: form.name.value,
      email: form.email.value,
      phone: form.phone.value,
      message: form.message.value,
      botcheck: form.botcheck.value
    };
    var token = form.querySelector('[name="cf-turnstile-response"]');
    if (token && token.value) data["cf-turnstile-response"] = token.value;
    var body = JSON.stringify(data);
    fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: body
    }).then(function (res) { return res.json(); }).then(function (result) {
      if (status) status.textContent = result && result.success ? (result.message || "Sent. Thank you!") : ((result && result.message) || "Could not send. Please try again.");
      if (result && result.success) form.reset();
    }).catch(function () {
      if (status) status.textContent = "Could not send. Please try again.";
    });
  });
})();
</script>
Same form with FormData instead of JSON
Same form with FormData instead of JSON
<form id="fg-ajax" action="https://formgong.com/submit" method="POST" style="position:relative">
  <input type="hidden" name="access_key" value="fk_your_access_key">
  <input type="hidden" name="_lang" value="en">
  <label>Name <input type="text" name="name" autocomplete="name" required></label>
  <label>Email <input type="email" name="email" autocomplete="email" required></label>
  <label>Phone <input type="tel" name="phone" autocomplete="tel"></label>
  <label>Message <textarea name="message" required></textarea></label>
  <div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
  <label>Leave this field empty <input type="text" name="botcheck" tabindex="-1" autocomplete="off"></label>
</div>
  <!--
Optional. Turn Turnstile on for this form, then remove these comment marks and paste your site key.
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" data-language="en"></div>
-->
  <button type="submit">Send</button>
  <p data-status role="status"></p>
</form>
<script>
(function () {
  var form = document.getElementById("fg-ajax");
  if (!form) return;
  var status = form.querySelector("[data-status]");
  form.addEventListener("submit", function (event) {
    event.preventDefault();
    var body = new FormData(form);
    fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: body
    }).then(function (res) { return res.json(); }).then(function (result) {
      if (status) status.textContent = result && result.success ? (result.message || "Sent. Thank you!") : ((result && result.message) || "Could not send. Please try again.");
      if (result && result.success) form.reset();
    }).catch(function () {
      if (status) status.textContent = "Could not send. Please try again.";
    });
  });
})();
</script>

Questions and answers

Why does the form still have an action URL?

So the post still works when JavaScript is off. The script calls preventDefault only when it runs.

Should I send JSON or FormData?

Either is valid. JSON is explicit about each field. FormData is shorter when you add inputs later, because it reads the form.

The status line stays empty. What failed?

Check that Accept is application/json, that the access key matches a form you own, and that the page is served over http or https, not as a local file.

Can this form upload a file?

Not in this starter. File fields need attachments turned on and a valid Turnstile token. Use the dashboard docs for that setup.