To add hCaptcha to an HTML form, load https://js.hcaptcha.com/1/api.js, put <div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div> inside the form, and send the form to a server that verifies the h-captcha-response token with your secret key. With FormSubmit you paste the site key and secret into Settings → Captcha and point the form at https://formsubmit.app/f/YOUR_FORM_ID. FormSubmit verifies every token server-side and rejects submissions that fail, so you don't write any backend code.
This guide explains how the hCaptcha token flow works, walks through a complete form, then covers submitting with fetch, invisible mode, React, and the alternatives: Cloudflare Turnstile and Google reCAPTCHA.
How hCaptcha works
hCaptcha has two halves, and both are required.
- In the browser, the hCaptcha script turns the
div.h-captchaelement into a widget. When the visitor passes the check (a checkbox, sometimes followed by an image challenge), the widget writes a token into a hidden field namedh-captcha-response. Because that field sits inside your form, the browser submits it along with the visitor's other fields. - On the server, whatever receives the form sends the token and your secret key to hCaptcha's siteverify endpoint,
https://api.hcaptcha.com/siteverify. hCaptcha replies withsuccess: trueorfalse. Only then does the server accept or reject the submission.
The site key is public and lives in your HTML. The secret key must never appear in your page.
Why a browser-only captcha is useless
A spam bot doesn't need to load your page. It can read your form's action once and send POST requests straight to that URL. If nothing on the server checks the token, the bot leaves the field empty and the submission goes through. Hiding or disabling the submit button until the captcha is solved doesn't help either, because the bot never sees the button.
That's why the verification step matters, and it's the part a static site can't do alone. FormSubmit does it for you: when a captcha provider is enabled for a form, every submission must include a valid token or it's rejected with the error code captcha_failed. Your secret is encrypted at rest and never shown again in full after you save it. The token itself is checked and discarded; it doesn't appear in your submissions or notification emails.
Add hCaptcha to your form
Create an hCaptcha site
Sign in to the hCaptcha dashboard and add a new site. Copy the site key for that site, and the secret key from your account settings. Add every hostname the form runs on, for example example.com and www.example.com. hCaptcha refuses to issue tokens on hostnames that aren't listed.
Paste the keys into FormSubmit
In your FormSubmit dashboard, open the form, go to Settings → Captcha, choose hCaptcha, paste the site key and secret, and save. From this point the form only accepts submissions with a valid h-captcha-response token, so finish the next step before you deploy.
Add the widget inside the form
Load the hCaptcha script once on the page and place the h-captcha div between your fields and the submit button. It must be inside the <form> element, or the token field won't be submitted.
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<form action="https://formsubmit.app/f/YOUR_FORM_ID" method="POST">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<!-- Honeypot: hidden from people, filled in by many bots -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>Replace YOUR_FORM_ID with the ID from your endpoint URL and YOUR_SITE_KEY with your hCaptcha site key. If you need a refresher on the individual inputs, see HTML form elements.
Test it
Deploy the page to a domain you added in the hCaptcha dashboard, solve the captcha and submit. The submission should appear in your dashboard and inbox. Then try submitting without solving it: the browser is redirected to an error page explaining that the captcha check failed.
Submitting with fetch
If you submit with JavaScript to keep visitors on the page, new FormData(form) picks up h-captcha-response automatically, as long as the widget is inside the form. Send Accept: application/json and FormSubmit replies with JSON in the shape { "ok": true } or { "ok": false, "error": { "code": "...", "message": "..." } } instead of redirecting.
A token can only be verified once, so reset the widget after any failed attempt. Otherwise the next try sends the same, already-used token.
const form = document.querySelector("#contact");
const status = document.querySelector("#status");
const renderedAt = Date.now();
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
data.set("_ts", String(renderedAt)); // time-to-submit spam check
if (!data.get("h-captcha-response")) {
status.textContent = "Please complete the captcha.";
return;
}
const res = await fetch(form.action, {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
const json = await res.json();
if (json.ok) {
form.reset();
hcaptcha.reset();
status.textContent = "Thanks, your message was sent.";
} else {
hcaptcha.reset(); // tokens are single use
status.textContent =
json.error.code === "captcha_failed"
? "The captcha check failed. Please try again."
: json.error.message;
}
});The AJAX and JavaScript docs list the other error codes you can branch on.
Invisible hCaptcha
Invisible mode removes the checkbox. Add data-size="invisible" and a data-callback naming a global function, then call hcaptcha.execute() when the visitor submits. hCaptcha runs its check in the background, may show a challenge if it's unsure about the visitor, and calls your callback with the token once it's done.
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<form id="contact" action="https://formsubmit.app/f/YOUR_FORM_ID" method="POST">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
<div class="h-captcha"
data-sitekey="YOUR_SITE_KEY"
data-size="invisible"
data-callback="onCaptchaPassed"></div>
<button type="submit">Send</button>
</form>
<script>
const form = document.getElementById("contact");
form.addEventListener("submit", function (event) {
event.preventDefault();
hcaptcha.execute(); // runs the check, then calls onCaptchaPassed
});
function onCaptchaPassed(token) {
// The token is already in the form's h-captcha-response field.
form.submit(); // form.submit() doesn't fire the submit event again
}
</script>For the fetch version, replace form.submit() in the callback with the request from the previous section. If you render more than one widget on a page, keep the ID returned by hcaptcha.render() and pass it to hcaptcha.execute(widgetId) and hcaptcha.reset(widgetId).
React
In React, use the official @hcaptcha/react-hcaptcha package. Render <HCaptcha sitekey="YOUR_SITE_KEY" onVerify={setToken} ref={captchaRef} />, append the token to your FormData as h-captcha-response, and call captchaRef.current.resetCaptcha() after a failed submission. The rest of the request is the same as the React contact form guide.
Alternatives: Turnstile and reCAPTCHA
FormSubmit supports four providers with the same bring-your-own-keys setup. Only the script, the widget markup and the token field name change.
| Provider | Token field | What visitors see | Cost |
|---|---|---|---|
| hCaptcha | h-captcha-response | Checkbox, sometimes an image challenge; invisible mode available | Free tier, paid plans for extra features |
| Cloudflare Turnstile | cf-turnstile-response | Usually nothing, occasionally a checkbox | Free |
| reCAPTCHA v2 | g-recaptcha-response | Checkbox, sometimes an image challenge | Free monthly allowance, paid above it |
| reCAPTCHA v3 | g-recaptcha-response | Nothing; each visitor gets a score | Free monthly allowance, paid above it |
For reCAPTCHA v3, FormSubmit rejects scores below the form's minimum score, which defaults to 0.5. Copy-paste snippets for each provider are in the captcha docs.
If you just want fewer bots and aren't tied to a provider, Turnstile is usually the better choice: it's free and most visitors never see it. Choose hCaptcha if you already use it elsewhere, prefer its privacy terms, or want a visible challenge on a form that's being targeted.
Keep the honeypot on
A captcha doesn't replace FormSubmit's other spam checks; it runs alongside them. Keep the _gotcha honeypot field in your form. It costs visitors nothing, and it still catches bots that get past the captcha, for example by paying a solving service. Content filtering, rate limits and duplicate detection keep running too. See spam protection for the full set and how to stop form spam for when to add each layer. For the trade-offs between the two approaches, read honeypot vs captcha.
Troubleshooting
Every submission fails with captcha_failed. Check that the h-captcha div is inside the <form> element, that the site key in your HTML and the secret in FormSubmit come from the same hCaptcha account and site, and that the domain you're testing on is listed for that site in the hCaptcha dashboard.
The widget doesn't work on localhost. hCaptcha doesn't issue tokens for hostnames that aren't registered, and it doesn't support localhost or 127.0.0.1 as a site hostname. For local development, either map a test hostname such as dev.example.com to 127.0.0.1 in your hosts file and add it in the dashboard, or use hCaptcha's published test keys (site key 10000000-ffff-ffff-ffff-000000000001, secret 0x0000000000000000000000000000000000000000). The test secret accepts any token from the test site key, so switch FormSubmit back to your real secret before going live.
It works sometimes but not always. hCaptcha tokens expire after a short time. A visitor who solves the captcha and then spends several minutes on the message will send an expired token. Handle captcha_failed in your JavaScript by resetting the widget and asking them to solve it again, or use invisible mode, which generates the token at the moment of submission.
The second submission always fails. Each token can be verified only once. After a successful or failed fetch submission, call hcaptcha.reset() so the next attempt gets a fresh token. Also disable the submit button while a request is in flight, so a double click doesn't send the same token twice.
The widget doesn't appear. Make sure the script tag loads (check the browser console for content security policy errors) and that data-sitekey is spelled correctly. If you add the form to the page after load, for example in a single-page app, render the widget yourself with hcaptcha.render() instead of relying on the automatic h-captcha class scan.