logo
captchaAPI

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 and /verify below are shorthand for /api/v1/captcha/challenge and /api/v1/captcha/verify. The full base URL the widget calls is documented under window.CAPTCHA_BASE_URL below.

Global configuration

Set these window variables before the script loads:

VariableTypeDefaultPurpose
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 and /verify. 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, verify round-trip, attestation TTL, 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:attested 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 = 'sk_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:

AttributePlacementWhat 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:attested 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:

  • captcha_attestation — a signed proof your backend verifies locally with the project's 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.

StateDefault colorWhenDefault English message
waiting#6b7280 greyPage 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 greyChallenge request in flight.Preparing protection…
solving#6b7280 greyWorker is hashing or verify call is in flight.Verifying form protection…
ready#059669 emeraldAttestation received; safe to submit.Protection active
error#dc2626 redNetwork, 5xx, worker failure, or rejected verify.Verification unavailable
rate_limited#d97706 amberChallenge or verify 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 idlesolvingready 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-ATde).

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 request. You can switch individual forms or the entire site to eager preload — see Preload mode.
  • Verify runs in the background too. After the worker finds a nonce, the widget immediately calls /verify and caches the returned attestation. By the time the user clicks submit, everything's ready — there's no extra round-trip on submit.
  • Submit is intercepted. The widget calls e.preventDefault(), injects the hidden attestation input, then calls form.submit(). Your own submit listeners added after the widget initialises will fire normally — but listeners that re-submit via fetch need 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.
  • Attestation freshness is validated server-side. The widget sends whatever attestation it has; your backend verification rejects stale ones via the signed exp field in the payload. 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 /challenge or /verify after retries (see below), the form is not submitted and an error state is shown. No attestation = no submission, by design.
  • No cookies, no storage. State lives in JavaScript variables for the page's lifetime only.
  • Livewire wire:navigate works. The widget listens for the livewire:navigated event in addition to DOMContentLoaded. Forms inserted by SPA navigation get discovered and initialized automatically; forms that survive across navigations (e.g. inside a @persist block) 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 request 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 attestation is always ready. The trade-off is one challenge request 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 request 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 idlesolvingready 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 /challenge fires immediately after each submit so the next click is instant. Costs one extra request per submit, plus one trailing request 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 attestation is ready by the time they click submit again. If the submit succeeded and they never touch the form again, no extra request is sent.

For a typical Livewire single-submit flow this means one /challenge per successful submission, not two. The dashboard's monthly request 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 six 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] verified in 51ms (network RTT)
[captchaapi] attestation valid for 300s (server-set per project config)
[captchaapi] total end-to-end 198ms

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 to captchaapi.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).
  • verified — round-trip to /verify. Same network factor as challenge issued.
  • attestation valid for — server-set lifetime baked into the signed payload's exp field. Matches the project's TTL setting in the dashboard (1 / 2 / 5 / 10 minutes); see Choosing an attestation TTL.
  • total end-to-end — the full pipeline from first issue to attestation 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 acquiring the attestation — 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 captcha_attestation hidden input, but instead of calling form.submit() it dispatches a captchaapi:attested 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 attestations 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:attested', function (e) {
        // e.detail.attestation is the signed token, also already in the
        // captcha_attestation 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
    {
        // captcha_attestation is validated 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 attestation input, and dispatches to your action when the attestation 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, secret-key rotation flow, and test-mode bypass are documented in the package README.

Retry and timeout

Every call to /challenge and /verify 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 per endpoint with an 800 ms backoff between them. Worst-case total before surfacing a network error: about 6.8 seconds per endpoint.

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 or /verify 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 attestation — they don't share tokens.


Once the attestation reaches your server, verify it. See Backend examples.