Widget
captcha.js
is a zero-dependency script that handles the full PoW flow and shows a visible protection status inside your form.
You do not need to manage the Proof-of-Work flow yourself. Include captcha.js, add data-captcha to your form, and verify the result on your backend.
How the widget works
No checkbox, no puzzle image. The widget runs in the background and shows a small status panel so users know their form is protected.
-
1
Scan on load, trigger on interaction
The widget finds all
[data-captcha]forms on load. When you first interact with a form (click, tap, or keystroke), the PoW challenge is fetched in the background and the status panel shows Preparing protection…. -
2
Fetch and solve
The widget calls
/challengethen runs the PoW solver in a backgroundWorkerthread. The status panel shows Verifying form protection… during this phase. -
3
Ready
Once solved, the status panel shows Protection active. The form is ready to submit — no action needed from the user.
-
4
Append and submit
On submit, the widget appends two hidden fields —
captcha_tokenandcaptcha_solution— then re-submits the form.
Fields appended to your form
<input type="hidden" name="captcha_token" value="a8f3c1e2b7d94f6a...">
<input type="hidden" name="captcha_solution" value="83421">
Forward these two values to /verify from your server.
Backend verification is required.
The widget only handles the client-side PoW flow. You must call /verify server-side and reject the request if success is not true.
Skipping this step leaves your form unprotected.
See Backend Validation for examples in Laravel, Node.js, Python, and Go.
Visible inline status panel
The widget automatically renders a small status box inside your form. It honestly reflects what is happening in the background — no fake interaction required.
No interaction needed
Users never need to click. The panel is informational only.
Honest status
States reflect what the widget is actually doing — not a simulation.
Auto-injected
If no data-captcha-status container exists, the widget injects one automatically before the submit button.
States
idle
Preparing protection…
Challenge fetch started
solving
Verifying form protection…
PoW Worker running
ready
Protection active
Solution found, form ready
error
Verification unavailable
Missing site key or non-429 error from /challenge
rate_limited
Please try again in X seconds
/challenge returned 429
Placement
Add a data-captcha-status element anywhere inside your form to control where the panel appears. If omitted, the widget injects one automatically just before the submit button.
<form method="POST" action="/login" data-captcha>
<input type="email" name="email">
<input type="password" name="password">
<!-- optional: place and style however you like -->
<div data-captcha-status></div>
<button type="submit">Login</button>
</form>Styling
When you provide your own data-captcha-status container, the widget keeps styling changes minimal so layout, size, and positioning can be controlled with your own CSS. Apply any additional styling directly on the element.
The widget also sets a data-captcha-state attribute on the container as each state changes, so you can hook into states with plain CSS:
[data-captcha-status] { font-size: 13px; padding: 8px 12px; }
[data-captcha-status][data-captcha-state="idle"],
[data-captcha-status][data-captcha-state="solving"] { color: #6b7280; }
[data-captcha-status][data-captcha-state="ready"] { color: #059669; }
[data-captcha-status][data-captcha-state="error"] { color: #dc2626; }
[data-captcha-status][data-captcha-state="rate_limited"] { color: #d97706; }Or as inline styles directly on the element:
<div data-captcha-status style="width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px 12px; text-align: center;"></div>
If no container is provided, the auto-injected element gets a small default presentation. You can still target it with [data-captcha-status][data-captcha-auto] in CSS.
Custom messages & translations
Set window.CAPTCHA_MESSAGES before loading the script to override any state text.
You can override only the messages you need — any missing keys fall back to the built-in English defaults.
<script>
window.CAPTCHA_BASE_URL = "https://captchaapi.eu/api/v1";
window.CAPTCHA_SITE_KEY = "your-site-key";
window.CAPTCHA_MESSAGES = {
idle: "Schutz wird vorbereitet…",
solving: "Formularschutz wird überprüft…",
ready: "Schutz aktiv",
error: "Überprüfung nicht verfügbar",
rate_limited: "Bitte in {seconds} Sekunden erneut versuchen",
};
</script>
<script src="https://captchaapi.eu/captcha.js" defer></script>
Each message is defined as a string. The rate_limited message also supports a {seconds} placeholder, or a function for full control.
window.CAPTCHA_MESSAGES = {
rate_limited: "Please try again in {seconds} seconds",
};Or provide a function for full control (useful for localization). For example, in Czech:
window.CAPTCHA_MESSAGES = {
rate_limited: (seconds) => `Too many requests. Retry in ${seconds}s.`,
};window.CAPTCHA_MESSAGES = {
rate_limited: (seconds) => {
if (seconds === 1) return `Zkuste to znovu za ${seconds} sekundu`;
if (seconds >= 2 && seconds <= 4) return `Zkuste to znovu za ${seconds} sekundy`;
return `Zkuste to znovu za ${seconds} sekund`;
},
};This is especially helpful for languages that need different wording depending on the retry value.
idle
Preparing protection…
—
solving
Verifying form protection…
—
ready
Protection active
—
error
Verification unavailable
—
rate_limited
Please try again in {seconds} seconds
Supports {seconds} placeholder or a function
How Proof-of-Work works
PoW requires the client to do a small amount of computation before submitting. Bots struggle to do this at scale; real users never notice.
Challenge
The server issues a unique token containing a random nonce and the required difficulty.
Solve
The client iterates numbers until it finds one that, when hashed with the nonce, produces the required leading zeros.
Verify
The server re-hashes the token + solution. If the result has the required leading zeros, the challenge passes.
No tracking, no cookies.
The widget makes no fingerprinting calls. It requests a challenge using your site_key, then submits the resulting token and solution through your backend verification flow.
Difficulty
Difficulty controls how strict the Proof-of-Work requirement is. Higher levels require more attempts on average and can increase solve time significantly depending on device and browser.
Level 3 is the default and fits most use cases. Level 4 should be used carefully for high-value forms, especially if mobile and Safari performance matter.
Login form example
A complete login form with a styled data-captcha-status panel. The panel background and text colour are updated via a MutationObserver watching data-captcha-state.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<div class="mb-8">
<h1 class="text-xl font-semibold text-gray-900">Sign in</h1>
<p class="text-sm text-gray-500 mt-1">Welcome back. Enter your details below.</p>
</div>
<form method="POST" action="/login" data-captcha class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Email</label>
<input type="email" name="email" placeholder="you@example.com"
class="w-full px-3.5 py-2.5 rounded-lg border border-gray-300 text-sm">
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="block text-sm font-medium text-gray-700">Password</label>
<a href="#" class="text-xs text-gray-500">Forgot password?</a>
</div>
<input type="password" name="password" placeholder="••••••••"
class="w-full px-3.5 py-2.5 rounded-lg border border-gray-300 text-sm">
</div>
<!-- CAPTCHA status panel -->
<div data-captcha-status style="font-size:12px;font-family:inherit"
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200 text-gray-500 min-h-[36px]"></div>
<button type="submit"
class="w-full py-2.5 rounded-lg bg-gray-900 text-white text-sm font-semibold">
Sign in
</button>
</form>
<p class="mt-6 text-center text-xs text-gray-400">
Don't have an account? <a href="#" class="text-gray-600 font-medium">Sign up</a>
</p>
</div>
<p class="mt-5 text-center text-xs text-gray-400">
Protected by <a href="https://captchaapi.eu">captchaapi.eu</a> · No cookies · EU hosted
</p>
</div>
<script>
window.CAPTCHA_BASE_URL = "https://captchaapi.eu/api/v1";
window.CAPTCHA_SITE_KEY = "your-site-key";
</script>
<script src="https://captchaapi.eu/captcha.js" defer></script>
<!-- Swap Tailwind classes when the widget updates data-captcha-state -->
<script>
(function () {
const states = {
ready: ['bg-emerald-50', 'border-emerald-200', 'text-emerald-700'],
error: ['bg-red-50', 'border-red-200', 'text-red-600'],
rate_limited: ['bg-amber-50', 'border-amber-200', 'text-amber-700'],
};
const base = ['bg-gray-50', 'border-gray-200', 'text-gray-500'];
const all = [...base, ...Object.values(states).flat()];
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
if (m.attributeName !== 'data-captcha-state') return;
const el = m.target;
const state = el.getAttribute('data-captcha-state');
el.classList.remove(...all);
el.classList.add(...(states[state] ?? base));
});
});
document.addEventListener('DOMContentLoaded', function () {
const el = document.querySelector('[data-captcha-status]');
if (el) observer.observe(el, { attributes: true });
});
})();
</script>
</body>
</html>