Integration
JavaScript Helper
Complete guide to the StaticForm.js client-side library for form submission, validation error display, and CAPTCHA integration (reCAPTCHA, Turnstile, hCaptcha).
On this page
- Installation
- How It Works
- Basic Example
- StaticForm.attach(form, options)
- Options
- Style Options
- Error Display
- Field-Specific Errors
- General Errors
- Error Clearing
- Complete Error Markup Example
- Success Behaviour
- Options
- Button mode (default)
- Replace mode
- Banner mode
- Disabling built-in success behaviour
- Custom Events
- Event Flow
- Loading State Example
- Success Message Example
- Error Handling Example
- CAPTCHA Integration
- Why only reCAPTCHA v3 needs siteKey in the helper config
- Opting out of script auto-loading
- reCAPTCHA v3
- reCAPTCHA v2
- Cloudflare Turnstile
- hCaptcha
- Using Without the Helper
- Custom fetch() calls
- Full Working Example
The StaticForm JavaScript helper is a lightweight, dependency-free script that handles everything between your HTML form and the StaticForm API. It intercepts form submissions, sends them via fetch, maps validation errors to individual fields, and handles CAPTCHA token injection for reCAPTCHA v3, reCAPTCHA v2, Cloudflare Turnstile, and hCaptcha.
You don’t need this library to use StaticForm. A plain HTML form with action and method="POST" works fine. But the helper gives you a much better user experience: inline error messages, loading states, and no full-page reloads.
Installation
Add the script to your page. It exposes a single global: window.StaticForm.
<script src="https://staticform.app/scripts/staticform.js" defer></script>
The script is ~7.5 KB minified (~2.6 KB gzipped) and has no dependencies.
How It Works
When you call StaticForm.attach(form, options), the library:
- Prevents the default form submit, so there is no page reload
- Collects all form data using the native
FormDataAPI - Injects a CAPTCHA token if you configured a provider (reCAPTCHA v3/v2, Turnstile, or hCaptcha)
- Sends the data via
fetchasmultipart/form-datato your endpoint - Parses the response. On success, it resets the form (and the CAPTCHA widget) and calls your callback. If your form is configured for HTTP Redirect, it follows the redirect and navigates the browser to the configured success or error URL
- Maps validation errors to fields. On failure (non-redirect mode), it shows error messages next to the relevant inputs and highlights them
Throughout this process, it dispatches custom events on the form element so you can hook into the submission lifecycle (loading states, success messages, etc.).
Basic Example
Here’s a minimal contact form with the helper:
<form id="contact-form">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" />
<p data-sf-error="name"></p>
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" />
<p data-sf-error="email"></p>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message"></textarea>
<p data-sf-error="message"></p>
</div>
<p data-sf-general-error></p>
<button type="submit">Send Message</button>
</form>
<script src="https://staticform.app/scripts/staticform.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
StaticForm.attach(document.getElementById('contact-form'), {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
});
});
</script>
That’s it. When the user submits the form, the helper sends the data. If there are validation errors, it displays them next to the relevant fields.
Why the
DOMContentLoadedwrapper? The HTML spec ignoresdeferon inline<script>blocks, so without the wrapper the inline call would run before the deferredstaticform.jsfinishes loading and throwReferenceError: StaticForm is not defined. Waiting forDOMContentLoadedguarantees both the DOM andstaticform.jsare ready.
StaticForm.attach(form, options)
This is the only method exposed by the library. It takes two arguments:
| Argument | Type | Description |
|---|---|---|
form | HTMLFormElement | The form element to attach to. |
options | object | Configuration object (see below). |
Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
endpoint | string | Yes | The full StaticForm submission URL for your form. Format: https://api.staticform.app/api/v1/forms/{FORM_ID} | |
captcha | object | No | CAPTCHA configuration. See CAPTCHA Integration. | |
success | object | No | { type: 'button' } | Controls what happens after a successful submission. See Success Behaviour. |
loading | object | No | Opt-in built-in loading state for the submit button. Pass {} to enable, or { buttonText: 'Sending…' } to also change the button text while fetching. | |
styles | object | No | See below | Overrides for visual styling of error states, focus states, and the success button color. |
onSuccess | function(response) | No | Callback fired after a successful submission. In JSON mode, receives the parsed response body. In HTTP Redirect mode, receives { redirected: true, url: string } and the browser navigates to the redirect URL immediately after. | |
onError | function(errors) | No | Callback fired after a failed submission. Receives the array of error objects after they’ve been mapped to fields. |
Style Options
The styles object lets you customize how errors and focus states look. All values are CSS strings.
| Property | Default | Description |
|---|---|---|
errorColor | '#ef4444' | Color for error text (not currently applied to text, but available for your use). |
focusColor | 'rgba(39, 52, 105, 0.4)' | Border color when an input is focused (and not in error state). |
focusShadow | '0 0 0 3px rgba(39, 52, 105, 0.06)' | Box shadow applied on input focus. |
defaultBorderStyle | '1.5px solid rgba(39, 52, 105, 0.12)' | The default border style for all inputs. Applied on load and restored after errors clear. |
errorBorderStyle | '1.5px solid #ef4444' | Border style applied to inputs that have a validation error. |
successColor | '#16a34a' | Background color applied to the submit button in success: { type: 'button' } mode. |
Example with custom styles:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
styles: {
errorColor: '#dc2626',
focusColor: 'rgba(59, 130, 246, 0.5)',
focusShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
defaultBorderStyle: '1px solid #d1d5db',
errorBorderStyle: '1px solid #dc2626',
},
});
Error Display
The helper uses HTML attributes to find where to display errors. There are two types of errors:
Field-Specific Errors
Add a data-sf-error attribute to an element next to each input. The attribute value must match the input’s name:
<input type="email" name="email" />
<p data-sf-error="email"></p>
When the API returns a validation error for email, the helper:
- Sets the text content of the
data-sf-error="email"element to the error message - Makes the error element visible (
display: block) - Changes the border of the matching
<input name="email">to theerrorBorderStyle
When the user starts typing in that input, the error is automatically cleared.
General Errors
For errors that aren’t tied to a specific field (e.g. “Something went wrong”, network errors), add:
<p data-sf-general-error></p>
The API returns general errors with the name generalErrors. The helper also shows a general error when a network request fails entirely.
Error Clearing
Errors are cleared in two ways:
- On new submit: all errors are cleared before a new submission
- On input: when the user types in an errored field, that field’s error is cleared immediately
Complete Error Markup Example
<form id="my-form">
<!-- Field with inline error -->
<div class="field">
<label>Name</label>
<input type="text" name="name" required />
<p data-sf-error="name" style="display: none; color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;"></p>
</div>
<!-- Another field -->
<div class="field">
<label>Email</label>
<input type="email" name="email" required />
<p data-sf-error="email" style="display: none; color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;"></p>
</div>
<!-- General error (network issues, server errors, etc.) -->
<div data-sf-general-error style="display: none; color: #ef4444; padding: 0.75rem; background: #fef2f2; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
<button type="submit">Submit</button>
</form>
Tip: Hide error elements by default with
style="display: none". The helper will show them when needed and hide them again when the error is resolved.
Success Behaviour
The success option controls what the helper does after a successful (non-redirect) submission. Four modes are available:
type | What happens |
|---|---|
'button' (default) | The submit button turns green and shows buttonText. Resets after duration seconds (0 = stays green). |
'replace' | The <form> is hidden and the nearest [data-sf-success] element is shown. |
'banner' | The nearest [data-sf-success] element is shown without hiding the form. Auto-dismisses after duration seconds (0 = stays visible). |
'none' | No built-in behaviour. Use the sf:success event or onSuccess callback to handle it yourself. |
Options
| Property | Default | Description |
|---|---|---|
type | 'button' | Which success mode to use (see table above). |
buttonText | '✓' | Button label shown in button mode. |
message | 'Thank you! Your message has been sent.' | Used as a fallback if no [data-sf-success] element is found (not rendered by the helper — provide your own element). |
duration | 3 | Seconds before auto-reset (button) or auto-dismiss (banner). Set to 0 to disable. |
Button mode (default)
No extra HTML needed. The submit button animates to green on success:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
success: {
type: 'button',
buttonText: '✓', // text shown on the button after success
duration: 3, // seconds before the button resets (0 = never)
},
});
Customize the color via styles.successColor:
StaticForm.attach(form, {
endpoint: '...',
success: { type: 'button' },
styles: { successColor: '#2563eb' },
});
Replace mode
Hides the form and shows a [data-sf-success] element. Place it as a sibling of the <form> or anywhere inside the form’s parent:
<form id="contact-form">
<!-- ...fields... -->
<button type="submit">Send</button>
</form>
<div data-sf-success style="display: none;">
Thank you! We'll be in touch soon.
</div>
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
success: { type: 'replace' },
});
Banner mode
Shows a [data-sf-success] element without hiding the form. Useful when you want the form to remain visible for reuse:
<form id="contact-form">
<div data-sf-success style="display: none;">
Message sent — we'll reply within one business day.
</div>
<!-- ...fields... -->
<button type="submit">Send</button>
</form>
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
success: {
type: 'banner',
duration: 5, // auto-dismiss after 5 seconds; 0 = stays visible
},
});
Disabling built-in success behaviour
Pass success: { type: 'none' } to opt out and handle success yourself via events:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
success: { type: 'none' },
});
form.addEventListener('sf:success', function () {
document.getElementById('my-custom-success').style.display = 'block';
});
Custom Events
The helper dispatches CustomEvents on the form element at each stage of the submission lifecycle. Use these to build loading states, success messages, analytics tracking, etc.
| Event | event.detail | When it fires |
|---|---|---|
sf:submitting | Immediately when the user clicks submit, before the fetch starts. | |
sf:success | Parsed response body, or { redirected: true, url: string } in redirect mode | After a successful submission (HTTP 200). The form has already been reset at this point. In HTTP Redirect mode, the browser navigates to the redirect URL immediately after this event fires. |
sf:error | Array of error objects | After a failed submission. Errors have already been mapped to fields. Each error is { name: string, reason: string }. |
sf:settled | After either success or error. Always fires, like finally in a promise. |
Event Flow
User clicks Submit
→ sf:submitting
→ fetch(endpoint)
→ (on success) sf:success → sf:settled
→ (on error) sf:error → sf:settled
Loading State Example
The simplest way is to use the built-in loading option, which disables the button and sets aria-busy="true" while the fetch is in flight:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
loading: { buttonText: 'Sending…' }, // pass {} to disable without changing text
});
For custom loading UIs, listen to the sf:submitting and sf:settled events instead:
var submitBtn = form.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;
form.addEventListener('sf:submitting', function () {
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
});
form.addEventListener('sf:settled', function () {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
});
Success Message Example
The simplest way is to use the built-in success option — see Success Behaviour for the full reference. For fully custom success UIs, listen to the sf:success event:
form.addEventListener('sf:success', function () {
document.getElementById('success-message').style.display = 'block';
});
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
success: { type: 'none' }, // disable built-in behaviour when using your own
});
Error Handling Example
form.addEventListener('sf:error', function (e) {
// e.detail is an array like:
// [{ name: "email", reason: "A valid email address is required." }]
console.log('Validation errors:', e.detail);
// The helper has already displayed the errors next to the fields,
// but you can do additional handling here (e.g. analytics)
});
CAPTCHA Integration
The helper supports four CAPTCHA providers out of the box. You configure them via the captcha option:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: {
type: 'turnstile', // 'recaptcha-v3' | 'recaptcha-v2' | 'turnstile' | 'hcaptcha'
siteKey: 'YOUR_SITE_KEY', // required for recaptcha-v3
action: 'submit', // optional, recaptcha-v3 only
},
});
| Provider | type value | Token field sent to API | Where the site key goes |
|---|---|---|---|
| Google reCAPTCHA v3 | 'recaptcha-v3' | g-recaptcha-response | captcha.siteKey (helper config) |
| Google reCAPTCHA v2 | 'recaptcha-v2' | g-recaptcha-response | data-sitekey on the widget <div> |
| Cloudflare Turnstile | 'turnstile' | cf-turnstile-response | data-sitekey on the widget <div> |
| hCaptcha | 'hcaptcha' | h-captcha-response | data-sitekey on the widget <div> |
For all providers, the helper will:
- Auto-load the provider’s loader script if it isn’t already on the page, so you don’t need to add the
<script src="https://...">tag yourself - Look up the current token before submitting and inject it into the form data
- Reset the CAPTCHA widget after the request finishes (success or error) so a new token can be generated for the next submission
In all cases the secret key must be configured in your form settings in the StaticForm dashboard (under CAPTCHA settings).
Why only reCAPTCHA v3 needs siteKey in the helper config
This is the most common point of confusion, so it’s worth being explicit about. The three widget-based providers (reCAPTCHA v2, Cloudflare Turnstile, hCaptcha) all read their site key directly from the data-sitekey attribute on the widget <div> you place inside your form. The provider’s own script handles rendering the widget, solving the challenge, and writing a hidden <input> with the resulting token into the surrounding form. The helper never touches the site key; it just calls getResponse() on the global once the user submits.
reCAPTCHA v3 is the exception because it has no widget. There’s nothing in the DOM for Google’s script to read a site key from, so the helper has to:
- Bake the key into the script URL itself (
https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY) when it auto-loads the script - Pass it again to
grecaptcha.execute(siteKey, { action })on every submit to generate a fresh token
Both of those need the key passed through the helper config. That’s the only reason captcha.siteKey exists.
Opting out of script auto-loading
If you’d rather load the provider’s script yourself (for example, to apply a custom CSP nonce, host it on your own CDN, or control where in the page it appears), set loadScript: false:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: {
type: 'turnstile',
loadScript: false, // you'll add the <script> tag yourself
},
});
The auto-loader is also a no-op when the relevant global (grecaptcha / turnstile / hcaptcha) is already defined or when a matching <script> tag is already present in the document, so calling attach() on multiple forms on the same page only injects the script once.
reCAPTCHA v3
reCAPTCHA v3 is invisible. There is no widget, so the helper calls grecaptcha.execute() on each submit to generate a fresh token. Just configure the helper:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: {
type: 'recaptcha-v3',
siteKey: 'YOUR_SITE_KEY',
action: 'submit', // optional, defaults to 'submit'
},
});
The helper will inject https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY for you. If you’ve opted out of auto-loading via loadScript: false, include that script tag yourself.
If the reCAPTCHA script fails to load or token generation fails, the helper submits without a token and the API will reject the submission with a CAPTCHA error.
reCAPTCHA v2
reCAPTCHA v2 is the classic “I’m not a robot” checkbox. Render the widget anywhere inside your form. Google’s script will inject a hidden g-recaptcha-response input that the helper will pick up automatically.
1. Add the widget inside your form:
<form id="contact-form">
<!-- ...your fields... -->
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
2. Configure the helper:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: { type: 'recaptcha-v2' },
});
The helper auto-loads https://www.google.com/recaptcha/api.js for you. Set loadScript: false if you’d rather include the <script> tag yourself.
Cloudflare Turnstile
Turnstile is Cloudflare’s privacy-friendly CAPTCHA. It auto-renders into any element with class cf-turnstile and inserts a hidden cf-turnstile-response input into the surrounding form.
1. Add the widget inside your form:
<form id="contact-form">
<!-- ...your fields... -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
2. Configure the helper:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: { type: 'turnstile' },
});
The helper auto-loads https://challenges.cloudflare.com/turnstile/v0/api.js for you. Set loadScript: false if you’d rather include the <script> tag yourself.
hCaptcha
hCaptcha is a privacy-focused alternative to reCAPTCHA. Like Turnstile and v2, it auto-renders into elements with class h-captcha and injects a hidden h-captcha-response input.
1. Add the widget inside your form:
<form id="contact-form">
<!-- ...your fields... -->
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
2. Configure the helper:
StaticForm.attach(form, {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
captcha: { type: 'hcaptcha' },
});
The helper auto-loads https://js.hcaptcha.com/1/api.js for you. Set loadScript: false if you’d rather include the <script> tag yourself.
Using Without the Helper
You don’t need this library at all. A standard HTML form works:
<form method="POST" action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
This will submit the form as a standard POST and the response depends on your form’s response type configuration:
- JSON mode: returns a JSON response (you’ll see raw JSON in the browser)
- HTTP Redirect mode: redirects the user to your configured success/error URL
The JavaScript helper is most useful when you want:
- No full-page reload on submit
- Inline validation error messages next to each field
- Loading states and success messages
- Automatic reCAPTCHA token injection
Custom fetch() calls
If you submit via your own fetch() call instead of a plain HTML form, always include the X-Requested-With: XMLHttpRequest header:
fetch('https://api.staticform.app/api/v1/forms/YOUR_FORM_ID', {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
Without this header, the API treats the request as a plain browser POST and returns HTTP redirects instead of JSON. This causes two problems for fetch() callers:
- Errors are silently swallowed. If your form has a redirect URL configured for errors,
fetch()follows the redirect automatically and returns a200from the error page. Your code has no way to know the submission failed. - Payment flows break. If payment is enabled, the API redirects to Stripe Checkout instead of returning the Stripe URL as JSON, so your code never gets a chance to handle it.
With the header set, all responses come back as JSON regardless of your form’s redirect configuration.
Full Working Example
Here’s a complete, copy-paste-ready contact form:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Contact</title>
<!-- Optional: reCAPTCHA v3 -->
<!-- <script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script> -->
</head>
<body>
<form id="contact-form" style="max-width: 500px; margin: 2rem auto;">
<div style="margin-bottom: 1rem;">
<label for="name" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Name</label>
<input type="text" id="name" name="name" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;" />
<p data-sf-error="name" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
</div>
<div style="margin-bottom: 1rem;">
<label for="email" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Email</label>
<input type="email" id="email" name="email" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;" />
<p data-sf-error="email" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
</div>
<div style="margin-bottom: 1rem;">
<label for="message" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Message</label>
<textarea id="message" name="message" rows="4" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;"></textarea>
<p data-sf-error="message" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
</div>
<div data-sf-general-error style="display: none; color: #ef4444; padding: 0.75rem; background: #fef2f2; border-radius: 0.5rem; margin-bottom: 1rem;"></div>
<button type="submit" id="submit-btn" style="padding: 0.625rem 1.5rem; background: #273469; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-weight: 600;">
Send Message
</button>
</form>
<script src="https://staticform.app/scripts/staticform.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
StaticForm.attach(document.getElementById('contact-form'), {
endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
// captcha: { type: 'turnstile' }, // Cloudflare Turnstile
// captcha: { type: 'hcaptcha' }, // hCaptcha
// captcha: { type: 'recaptcha-v2' }, // reCAPTCHA v2
// captcha: { type: 'recaptcha-v3', siteKey: 'YOUR_SITE_KEY' }, // reCAPTCHA v3
success: { type: 'button', buttonText: '✓', duration: 3 },
loading: { buttonText: 'Sending…' },
});
});
</script>
</body>
</html>