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

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. 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:

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: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.

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 for a nonce.Verifying form protection…
ready#059669 emeraldSolution found; safe to submit.Protection active
error#dc2626 redNetwork, 5xx, or worker failure.Verification unavailable
rate_limited#d97706 amberChallenge 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 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 hidden captchaapi_response 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.
  • 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 /challenge after 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: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 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 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 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 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).
  • 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.