5-minute integration
Five steps: create a project, drop in the script, mark your form, verify on the server, and make every attestation single-use. If you already have a project, skip to step 2.
On WordPress? Do step 1 to get your keys, then install the captchaapi.eu plugin and skip the rest. It protects the login, registration, lost-password, and comment forms plus Contact Form 7 - activate it, paste your site key and secret key, and choose which forms to protect. No script tag, no markup, no backend code. Download the latest release.
1. Create a project and grab your keys
- Sign in and go to Projects.
- Click New Project, give it a name, and optionally add the domain(s) you'll embed the widget on. Leaving Allowed Domains empty means any origin can use the site key.
- On the project detail page you'll now see two keys:
- Site Key — public. Goes into your HTML.
- Secret Key — private. Shown once here after creation; goes into your backend config (not your HTML). You can re-reveal it later with password confirmation.
Copy the secret key somewhere durable (password manager, environment variable) before you navigate away. To reveal it again you'll need to enter your account password.
2. Include the script
Add one tag to your page, ideally just before </body>:
<script>window.CAPTCHA_SITE_KEY = 'YOUR_SITE_KEY';</script>
<script src="https://captchaapi.eu/captcha.js" defer></script>
The script auto-initialises every form that carries the data-captcha attribute. Nothing runs until the user interacts with that form.
3. Mark your form
Add data-captcha to the form, and optionally a [data-captcha-status] element to show the protection state to the user.
<form method="POST" action="/contact" data-captcha>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<span data-captcha-status></span>
<button type="submit">Send</button>
</form>
When the form submits, the widget inserts a hidden input named captcha_attestation.
It contains a signed proof that the PoW was solved. You don't need to add it yourself.
4. Verify the attestation on your server
On Laravel? Skip steps 4 and 5 and installcaptchaapi/laravel—composer require captchaapi/laravelgives you the validation rule (replay protection included), Blade component, and Livewire trait pre-wired. See the Laravel section in backend examples.
Put your secret key(s) in an environment variable — never in code or client-side HTML.
CAPTCHA_SECRET_KEYS is a comma-separated list so you can deploy a new key
alongside the current one during rotation:
CAPTCHA_SECRET_KEYS=your_secret_here
# During rotation, temporarily:
# CAPTCHA_SECRET_KEYS=your_current_secret,your_pending_secret
On the endpoint that receives the form, verify the attestation's HMAC locally. No HTTP call to captchaapi.eu is needed.
<?php
/**
* @param list<string> $secrets Accept any key in this list.
*/
function verifyCaptcha(string $attestation, array $secrets, string $expectedSiteKey): bool
{
if (! str_contains($attestation, '.')) return false;
[$payloadB64, $sigB64] = explode('.', $attestation, 2);
$actual = base64_decode(strtr($sigB64, '-_', '+/'), strict: true);
if ($actual === false) return false;
$matched = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $payloadB64, $secret, binary: true);
if (hash_equals($expected, $actual)) {
$matched = true;
break;
}
}
if (! $matched) return false;
$rawPayload = base64_decode(strtr($payloadB64, '-_', '+/'), strict: true);
if ($rawPayload === false) return false;
$payload = json_decode($rawPayload, true);
if (! is_array($payload)) return false;
if (($payload['exp'] ?? 0) < time()) return false;
if (($payload['sk'] ?? '') !== $expectedSiteKey) return false;
return true;
}
$secrets = array_filter(array_map('trim', explode(',', getenv('CAPTCHA_SECRET_KEYS') ?: '')));
$ok = verifyCaptcha(
$_POST['captcha_attestation'] ?? '',
$secrets,
'YOUR_SITE_KEY',
);
5. Add replay protection (required for production)
Make every attestation single-use to close the replay window. The HMAC + exp
check above proves the attestation is genuine and fresh — it doesn't prove it hasn't been
used before. Without this last step, a bot that solves one PoW can re-submit the same
attestation many times within its TTL. One atomic cache call closes that window:
// After verifyCaptcha() returns true, claim the jti as single-use.
// Assumes $redis is a connected client (e.g. new Redis(), or pulled from your DI container).
[$payloadB64] = explode('.', $_POST['captcha_attestation'] ?? '', 2);
$payload = json_decode(base64_decode(strtr($payloadB64, '-_', '+/')) ?: '', true) ?? [];
$jti = $payload['jti'] ?? '';
$ttl = max(1, ($payload['exp'] ?? 0) - time());
if ($jti === '' || ! $redis->set("captcha:jti:{$jti}", 1, ['NX', 'EX' => $ttl])) {
// Already used or malformed — reject this submission.
return false;
}
The TTL on the cache key matches the attestation's remaining lifetime — once the attestation expires, the jti record vanishes with it. Redis does the cleanup; you don't carry growing state.
Same atomic pattern in every language — Cache::add in Laravel (Redis driver),
SET NX in phpredis, nx=True in redis-py. See
backend examples for the full
set in seven languages. Local HMAC verification means there's no central state for us to
dedup against; the jti cache lives on your side, by design.
That's it. Submissions with a missing or invalid attestation, or with a replayed
jti, fail the check — reject them. See
Backend examples for ready-made snippets in
Laravel, plain PHP, Node.js, Python, Go, Ruby, and Java.
Tip: the payload'solfield istruewhen the project is past its monthly request quota. Verification still succeeds (visitors continue to be served on the same baseline difficulty curve as in-quota traffic) so your users aren't punished — it's a signal to upgrade.
Next: full API reference · widget configuration · backend examples.