Widget configuration
captcha.js is designed to work without configuration: set a site key, mark a
form, and you're done. Everything below is optional - reach for it when you need a
non-default base URL, custom copy, or want to place the status element yourself.
/challenge below is shorthand for /api/v1/captcha/challenge - the
only endpoint the widget calls. Your backend calls /verify separately (see
backend examples). The full base URL the widget
uses is documented under window.CAPTCHA_BASE_URL below.
Global configuration
Set these window variables before the script loads:
| Variable | Type | Default | Purpose |
|---|---|---|---|
window.CAPTCHA_SITE_KEY |
string | none | Required. The public site key from your project. |
window.CAPTCHA_BASE_URL |
string | Derived from the script origin + /api/v1, falls back to https://captchaapi.eu/api/v1. |
Override when you self-host the API or proxy it behind your own domain. |
window.CAPTCHA_LOCALE |
string | <html lang> attribute, else en. |
Language code (e.g. de, fr, cs) for the status messages. |
window.CAPTCHA_MESSAGES |
object | none | Per-state message overrides. Any keys you set replace the defaults for the resolved locale. |
window.CAPTCHA_TIMINGS |
object | { fetchTimeoutMs: 3000, retryBackoffsMs: [800] } |
Advanced. Override the per-request timeout and retry backoff for /challenge. See Retry and timeout below. |
window.CAPTCHA_PRELOAD |
string | "lazy" |
When the widget fires /challenge. "lazy" (default) waits for first form interaction; "eager" fires on page load. See Preload mode below for the quota trade-off. |
window.CAPTCHA_DEBUG |
boolean | false |
When true, the widget logs the end-to-end timing breakdown (challenge round-trip, PoW solve duration + iteration count, total) to the browser console. Useful for verifying performance on your own hardware before going live. See Debug mode below. |
window.CAPTCHA_MODE |
string | "submit" |
How the widget finishes the submission. "submit" (default) calls native form.submit(). "event" dispatches a captchaapi:solved CustomEvent and lets you handle submission yourself - required for Livewire, Inertia, htmx, and any fetch-based form. See Submit mode below. |
<script>
window.CAPTCHA_SITE_KEY = 'pk_live_abc123';
window.CAPTCHA_LOCALE = 'de';
window.CAPTCHA_MESSAGES = {
ready: 'Schutz aktiv - Absenden sicher',
};
</script>
<script src="https://captchaapi.eu/captcha.js" defer></script>
Form markup
The widget looks for two attributes in your HTML:
| Attribute | Placement | What it does |
|---|---|---|
data-captcha |
on each <form> you want protected |
Marks the form for auto-init. Without this, nothing runs. |
data-captcha-status |
on a child element inside that form (typically a <span>) |
Optional but recommended. Place this on a child element (typically a <div> or <span>) wherever you want the widget to display its status. Without it, the widget runs silently - submission still works, only the visible signal is absent. See Status element states for the six states, default colors, and how to style or override them. |
data-captcha-preload |
on the <form>, with value "eager" or "lazy" |
Optional. Per-form override of window.CAPTCHA_PRELOAD. Useful when one page mixes forms with different conversion rates (e.g. eager on the demo form, lazy on a below-the-fold contact form). Form attribute wins over the global setting. |
data-captcha-debug |
on the <form>, bare presence or value "true" / "false" |
Optional. Per-form override of window.CAPTCHA_DEBUG. Bare attribute (data-captcha-debug) and "true" both enable; "false" explicitly disables (overrides a global true). Form attribute wins over the global setting. |
data-captcha-mode |
on the <form>, value "submit" or "event" |
Optional. Per-form override of window.CAPTCHA_MODE. Use "event" on Livewire / Inertia / htmx / fetch forms where a native POST navigation is wrong - the widget will dispatch a captchaapi:solved CustomEvent for you to handle instead. Form attribute wins over the global setting. |
<form action="/signup" method="POST" data-captcha>
<!-- your fields -->
<span data-captcha-status class="text-sm text-slate-500"></span>
<button type="submit">Create account</button>
</form>
Hidden input
On submit, the widget injects (or updates) a single hidden input on your form:
captchaapi_response- an opaque"<token>.<solution>"string your backend sends to the verify endpoint with your secret key.
If your form already has an input with this name, the widget updates it in place rather than duplicating. Don't rename it - your backend verification code should read the same field.
Status element states
Place <div data-captcha-status></div> wherever you want the widget to
report its status (typically inside or near the form). The widget writes an icon + a
localised message into it on every state transition and sets a data-captcha-state
attribute reflecting the current state name.
The status element is opt-in. Forms without a
[data-captcha-status] child run silently - submission still works, only the
visible signal is absent. There's no auto-injection of a status element.
| State | Default color | When | Default English message |
|---|---|---|---|
waiting | #6b7280 grey | Page load, lazy mode, when you've pre-placed the status element. No challenge has fired yet - the widget signals its presence. | Protection standby |
idle | #6b7280 grey | Challenge request in flight. | Preparing protection… |
solving | #6b7280 grey | Worker is hashing for a nonce. | Verifying form protection… |
ready | #059669 emerald | Solution found; safe to submit. | Protection active |
error | #dc2626 red | Network, 5xx, or worker failure. | Verification unavailable |
rate_limited | #d97706 amber | Challenge returned 429. Live countdown when the server sent retry_after; static fallback when it didn't. | Please try again in N seconds - or - Too many requests - please try again later |
In lazy mode, waiting shows on page load when you've pre-placed the status
element. First user interaction transitions it through idle → solving →
ready as normal. In eager mode the widget skips waiting entirely - it goes
straight to idle because the challenge fires on page load.
Styling the status element
Default colors are applied automatically via a low-specificity stylesheet the widget injects on first use. You don't have to write any CSS to get the colors above.
To override the color of one specific state, write a higher-specificity rule (two attribute selectors instead of one):
[data-captcha-status][data-captcha-state="ready"] { color: #047857; }
[data-captcha-status][data-captcha-state="error"] { color: #b91c1c; }
Specificity (0,2,0) beats the widget default (0,1,0) regardless of which stylesheet loads
first. No !important needed.
To take over styling completely (Tailwind classes, your design system,
etc.), add the data-captcha-no-color attribute to suppress widget defaults
and let your CSS through cleanly:
<div data-captcha-status data-captcha-no-color
class="text-cyan-700 dark:text-cyan-300"></div>
With this attribute, the widget skips the color rule entirely so your Tailwind classes
(or any other CSS) win without specificity gymnastics. Note that waiting and
ready share the shield icon and would be visually identical without color -
if you opt out of widget defaults, consider supplying per-state colors in your own CSS so
the two states stay distinguishable.
Supported locales
Messages ship in 10 languages: en, de, fr,
es, it, pl, nl, pt,
cs, ro. The resolver checks window.CAPTCHA_LOCALE
first, then the lang attribute on <html>, then falls back
to English. Region suffixes are stripped (de-AT → de).
Behaviour you should know about
- Lazy-loaded by default. No challenge is requested until the user touches the form (pointerdown, keydown, touchstart, or input). Page load is free, and a visitor who bounces never costs you a challenge. You can switch individual forms or the entire site to eager preload - see Preload mode.
- Solving runs in the background. After the worker finds a nonce, the widget holds the
"<token>.<solution>"string in memory. By the time the user clicks submit, the response is ready - there's no extra round-trip on submit. - Submit is intercepted. The widget calls
e.preventDefault(), injects the hiddencaptchaapi_responseinput, then callsform.submit(). Your ownsubmitlisteners added after the widget initialises will fire normally - but listeners that re-submit viafetchneed to read the hidden input from the form after the widget's handler completes. If you're using Livewire, Inertia, htmx, or any other non-native submitter, switch to event mode (see Submit mode). - Submit button is disabled while solving. It's re-enabled whether the flow succeeds or fails.
- The response is validated server-side. The widget sends whatever response it has; your backend's verify call rejects expired or already-used responses. There's no client-side clock check - that would misfire on devices with drifted time.
- On service failure, submission is blocked. If the widget cannot reach
/challengeafter retries (see below), the form is not submitted and an error state is shown. No response = no submission, by design. - No cookies, no storage. State lives in JavaScript variables for the page's lifetime only.
- Livewire
wire:navigateworks. The widget listens for thelivewire:navigatedevent in addition toDOMContentLoaded. Forms inserted by SPA navigation get discovered and initialized automatically; forms that survive across navigations (e.g. inside a@persistblock) skip re-init thanks to a per-form idempotency guard. No additional configuration needed - load the widget once in your persistent layout and SPA navigation just works.
Preload mode
Lazy (default): the widget fires /challenge only when the visitor
first interacts with the form - a click, keypress, or focus via typing. A visitor who lands
and bounces never consumes a challenge against your monthly quota. The trade-off is a very
small delay on submit (usually invisible, since most users take at least a second to fill
the form while the widget solves in the background).
Eager: the widget fires /challenge on page load, so by the time
the user submits the response is always ready. The trade-off is one challenge
per pageview, regardless of whether the visitor ever submits. For a landing
page with 100 000 monthly views but 5 000 submissions, that's a 20× hit on your challenge
counter. Opt in only when your conversion funnel is thick enough to justify it - for
instance on a single-purpose form page where most visitors do submit.
Setting the mode
Global default for all forms on the page:
<script>
window.CAPTCHA_PRELOAD = 'eager'; // or 'lazy' - default is 'lazy'
</script>
Per-form override (wins over the global setting):
<form data-captcha data-captcha-preload="eager" action="/signup" method="POST">
<!-- fields -->
</form>
Typical mix: set the global to lazy, then opt specific high-conversion forms
into eager with the form attribute. For example, the demo form on a product page
might be eager, while a below-the-fold contact form stays lazy.
Showing the widget on page load
In lazy mode the widget is invisible until the visitor interacts. If you'd rather signal
the CAPTCHA's presence from page load - a trust cue that the form is protected - place a
<span data-captcha-status> in your markup. The widget will populate it with the
waiting state ("Protection standby") immediately on load, without firing any
request. First interaction transitions it through idle → solving →
ready as usual.
In eager mode the widget goes straight to idle (spinner) on load, skipping
waiting - there's no standby phase because the challenge is already in flight.
What happens after submit
In submit mode (default), the form navigates after submission, so the widget
does nothing further. One /challenge per page session, period.
In event mode (Livewire / htmx / fetch flows where the page stays alive), the widget needs to be ready for a possible second submit (e.g. user fixes a validation error and resubmits). The behaviour mirrors the project's preload mode:
- Eager + event: a fresh
/challengefires immediately after each submit so the next click is instant. Costs one extra challenge per submit, plus one trailing challenge when the visitor never resubmits. - Lazy + event (default): the widget rearms its interaction listeners. The next pointer / key / input event triggers the follow-on preload - typically while the visitor is reading validation errors and starting to fix a field, so the response is ready by the time they click submit again. If the submit succeeded and they never touch the form again, no extra challenge is fired.
For a typical Livewire single-submit flow this means one /challenge per
successful submission, not two. The dashboard's monthly challenge counter reflects this
difference.
Debug mode
Debug mode logs an end-to-end timing breakdown to the browser console so you can verify the widget's performance on your own hardware before pushing it to production. The widget is published unobfuscated for the same reason - every claim should be independently verifiable.
Off by default. Two ways to turn it on:
<!-- Global: every form on the page -->
<script>window.CAPTCHA_DEBUG = true;</script>
<!-- Per-form: bare attribute or explicit value -->
<form data-captcha data-captcha-debug action="/signup" method="POST">
<form data-captcha data-captcha-debug="true" action="/signup" method="POST">
<!-- Force off, even when the global is enabled -->
<form data-captcha data-captcha-debug="false" action="/signup" method="POST">
Once enabled, on every successful challenge the widget logs five lines to console.log:
[captchaapi] starting challenge flow {baseUrl: "…", siteKey: "…", platform: "MacIntel", cores: 8}
[captchaapi] challenge issued in 47ms (network RTT + server compute)
[captchaapi] PoW solved in 92ms (2847 iterations, target=0x0001ffff)
[captchaapi] response ready (token.solution staged in hidden input)
[captchaapi] total end-to-end 139ms
On a failed challenge, the widget logs a single line with the error code instead - no timing data when the flow didn't complete:
[captchaapi] error: network_error (challenge fetch)
Reading the numbers:
challenge issued- round-trip to/challenge. Includes your network RTT tocaptchaapi.eu(Hetzner Nuremberg, Germany) plus server-side compute. From a typical EU connection this is 30-100 ms.PoW solved- pure CPU work in the Web Worker on the visitor's device. Independent of network. The "iterations" count is the number of SHA-256 evaluations needed to find a nonce below the difficulty target. The base curve is the same on every plan; per-IP request rate and sitekey-wide attack state are what move iteration count (see Difficulty curve).response ready- the"<token>.<solution>"string is staged in the hidden input. No network here; the widget doesn't call/verify- your backend does.total end-to-end- the full pipeline from first issue to the response in hand. This is what your visitor experiences before the form is ready to submit.
Debug mode is part of the public API, not a hidden dev knob. Turn it on in staging or even in production when you want to capture real-world numbers from your visitors' browsers without instrumenting the page yourself. Output volume is five log lines per submission on a healthy flow - negligible. Strip it before going to production only if you're worried about leaking the debug lines to end-users (the lines are visible in their console).
Submit mode
By default the widget calls form.submit() after solving the proof-of-work -
a native browser submit that fires a fresh POST and navigates the page. That's the right
choice for plain HTML forms. It's the wrong choice for any framework that intercepts form
submission with its own transport: Livewire (AJAX), Inertia, htmx, or anything that
re-serialises the form via fetch. A native submit bypasses your framework
entirely and you'd see a full-page POST instead of the SPA round-trip you wired up.
Switch to event mode for those flows. The widget still injects the
captchaapi_response hidden input, but instead of calling form.submit()
it dispatches a captchaapi:solved CustomEvent on the form. Your listener
finishes the submission however the framework expects - calling a Livewire method,
triggering an htmx request, sending a fetch, etc. The event bubbles, so a
single listener on a parent element can catch responses from any nested form.
Setting the mode
Per-form (recommended - most pages mix protected forms with non-protected ones):
<form data-captcha data-captcha-mode="event" id="signup">
<input type="email" name="email">
<button type="submit">Create account</button>
</form>
<script>
document.getElementById('signup').addEventListener('captchaapi:solved', function (e) {
// e.detail.response is the "token.solution" string, also already in the
// captchaapi_response hidden input on the form.
// Finish the submission however your stack expects - fetch / htmx / Livewire / Inertia.
fetch('/signup', {
method: 'POST',
body: new FormData(e.target),
});
});
</script>
Global default for every form on the page:
<script>
window.CAPTCHA_MODE = 'event';
</script>
On a Livewire / Inertia / Alpine SPA the global default is the cleanest setting - every protected form on every page submits via the framework, never via a native POST. The per-form attribute then exists for the rare exception (e.g. a third-party-hosted form embedded in your SPA that does need a native submit).
Laravel + Livewire shortcut
For Laravel apps the official
captchaapi/laravel
package wraps the event hook in a Livewire trait + Blade component, so you don't write the
listener yourself:
composer require captchaapi/laravel
php artisan vendor:publish --tag=captchaapi-config
Then in your Livewire component:
use Captchaapi\Laravel\Concerns\WithCaptcha;
use Livewire\Component;
class RegisterForm extends Component
{
use WithCaptcha;
public string $email = '';
public function register(): void
{
// captchaapi_response is verified automatically via the trait's rule
$this->validate(['email' => 'required|email']);
}
}
And the view uses the package's Blade wrapper, which sets
data-captcha-mode="event", includes the hidden captchaapi_response
input, and dispatches to your action when the response arrives:
<x-captchaapi::widget />
<x-captchaapi::livewire-form action="register">
<input wire:model="email" type="email" required>
<button type="submit">Register</button>
</x-captchaapi::livewire-form>
Source on GitHub. Full configuration reference and test-mode bypass are documented in the package README.
Inertia (React / Vue)
Inertia forms are controlled by useForm(), not by a native form submit, so
the declarative data-captcha attribute and event mode don't apply - there's no
native submit to intercept. Instead load the widget once in your persistent layout so
window.captchaapi.solve() is available, then call it in your submit handler and
merge the response into the payload with Inertia's transform().
solve() runs one full challenge → proof-of-work cycle and resolves with the
captchaapi_response string. It's single-use - call it once per submit, never
reuse a resolved value.
React - the official
@captchaapi/react
package wraps solve() in a hook with unmount-safe abort handling and error
state, so you don't write that plumbing yourself:
npm install @captchaapi/react
import { useForm } from '@inertiajs/react'
import { useCaptcha } from '@captchaapi/react'
export default function ContactForm() {
const form = useForm({ email: '' })
const { solve, solving, error } = useCaptcha()
async function submit(e) {
e.preventDefault()
let response
try {
response = await solve()
} catch {
return // error state is set, render it below
}
form.transform((data) => ({ ...data, captchaapi_response: response })).post('/contact')
}
return (
<form onSubmit={submit}>
<input value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
{(form.errors.captchaapi_response || error) &&
<span>{form.errors.captchaapi_response ?? 'Verification failed, please try again.'}</span>}
<button type="submit" disabled={form.processing || solving}>Send</button>
</form>
)
}
Source and full API on GitHub. Without
the package, call window.captchaapi.solve() directly - same pattern, you
manage the abort and loading state yourself:
import { useForm } from '@inertiajs/react'
import { useRef, useEffect } from 'react'
export default function ContactForm() {
const form = useForm({ email: '' })
const abortRef = useRef(null)
useEffect(() => () => abortRef.current?.abort(), [])
async function submit(e) {
e.preventDefault()
abortRef.current = new AbortController()
let response
try {
response = await window.captchaapi.solve({ signal: abortRef.current.signal })
} catch (err) {
return // err.code ('rate_limited', 'network_error', …), err.retryAfter
}
form.transform((data) => ({ ...data, captchaapi_response: response })).post('/contact')
}
return (
<form onSubmit={submit}>
<input value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
{form.errors.captchaapi_response && <span>{form.errors.captchaapi_response}</span>}
<button type="submit" disabled={form.processing}>Send</button>
</form>
)
}
Vue (@inertiajs/vue3):
import { useForm } from '@inertiajs/vue3'
const form = useForm({ email: '' })
async function submit() {
let response
try {
response = await window.captchaapi.solve()
} catch (err) {
return // err.code / err.retryAfter
}
form.transform((data) => ({ ...data, captchaapi_response: response })).post('/contact')
}
The server needs nothing Inertia-specific: validate captchaapi_response with the
transport-agnostic rule like any other field, and a failure surfaces in form.errors
automatically. If you render a plain server-side <form data-captcha> inside an
Inertia page instead, the widget re-discovers it on the inertia:navigate event
(Inertia 0.3.0+), the same way it handles Livewire's wire:navigate.
Retry and timeout
The widget's call to /challenge has a 3-second per-request timeout
and will retry once on a transient failure before giving up. A transient
failure is a network error (fetch throws, DNS fails, socket timeout) or a 5xx response from the API.
Client errors (4xx, including 429) are not retried - the server has given a
deliberate answer and retrying would be pointless or harmful.
Default: two total attempts with an 800 ms backoff between them. Worst-case total before surfacing a network error: about 6.8 seconds.
Override these via window.CAPTCHA_TIMINGS:
<script>
window.CAPTCHA_TIMINGS = {
fetchTimeoutMs: 3000, // per-request timeout
retryBackoffsMs: [800], // 1 retry; pass [] to disable retries entirely
};
</script>
Reach for this only if you have a concrete reason (very flaky networks you want to tolerate, or conversely, a stricter SLA requirement). The defaults are chosen to be invisible on healthy connections and robust enough to recover from typical transient blips.
Rate-limit cooldown
When /challenge returns 429 with a
retry_after value, the widget enters a cooldown:
- The status element shows a live countdown updated every second ("Please try again in 42 seconds" → "…41 seconds" → …).
- The submit button is disabled for the whole cooldown window. Enter-key
submissions and programmatic
form.submit()calls are also blocked. - When the countdown hits zero, the widget auto-fires a fresh challenge and re-enables the button. The visitor can submit again without any manual retry.
If the 429 response has no retry_after (e.g. a generic
limit from a CDN ahead of our API), the widget falls back to a static message
("Too many requests - please try again later") and leaves the submit button
enabled so the visitor can retry manually whenever they like. No countdown, no
auto-retry - the server didn't say when it would be safe, so we don't guess.
Multiple forms on one page
Add data-captcha to each form. Each gets its own challenge and response -
they don't share tokens.
Once the response reaches your server, verify it. See Backend examples.