Developer documentation
Add a privacy-first captcha in minutes
Drop in a widget, verify one token on your server, done. No tracking, no image puzzles, EU-hosted proof-of-work. Works with any language or framework.
Quickstart
Two steps: embed the widget in your form, then verify the token on your server when the form is submitted. You need a sitekey (public) and a secret (server-only), both from your InfoPeak dashboard.
1. Add the widget to your form
<form action="/signup" method="POST">
<input type="email" name="email" required>
<!-- InfoPeak Captcha widget -->
<div class="infopeak-captcha" data-sitekey="YOUR_SITEKEY"></div>
<button type="submit">Sign up</button>
</form>
<!-- Load once, near the end of <body> -->
<script src="https://captcha.infopeak.io/infopeak-captcha.js" defer></script>
The widget solves automatically in the background the moment the visitor starts interacting with your form -
there is no checkbox to click. When it finishes it adds a hidden field
infopeak-captcha-response to the surrounding <form>,
submitted with your form like any other field.
2. Verify the token on your server
<?php
$token = $_POST['infopeak-captcha-response'] ?? '';
$res = file_get_contents('https://captcha.infopeak.io/api/v1/pow/siteverify', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode([
'sitekey' => 'YOUR_SITEKEY',
'secret' => 'YOUR_SECRET',
'token' => $token,
]),
],
]));
$ok = json_decode($res, true)['valid'] ?? false;
if (!$ok) {
http_response_code(400);
exit('Captcha verification failed');
}
// ... proceed with signupThat is the whole integration. Everything below is reference detail.
How it works
InfoPeak Captcha uses proof-of-work instead of tracking or image puzzles. The visitor's browser solves a small computational challenge in the background - invisible, no clicking traffic lights. Bots pay a CPU cost that makes mass abuse expensive; humans wait a fraction of a second.
-
1
Challenge. The widget requests a challenge for your sitekey (
/pow/config). -
2
Solve. The browser solves the proof-of-work in a Web Worker - no user interaction.
-
3
Token. The solved work is exchanged for a single-use token (
/pow/verify). -
4
Verify. Your server confirms the token with your secret (
/pow/siteverify).
Widget reference
Any element with class infopeak-captcha is turned into a widget on page load.
It solves automatically when the visitor first interacts with the surrounding form - no click required. If there
is no surrounding <form>, it solves on load.
| Attribute | Required | Description |
|---|---|---|
data-sitekey |
Yes | Your public sitekey. The widget shows an error if missing. |
data-lang |
No | Two-letter language code (e.g. da, de). Defaults to the page <html lang>, then the browser language. 16 languages supported. |
data-api |
No | Override the API origin. Defaults to where the script was loaded from (https://captcha.infopeak.io). You rarely need this. |
data-response-field-name |
No | Name of the hidden token field. Defaults to infopeak-captcha-response. Set it to g-recaptcha-response (or h-captcha-response, cf-turnstile-response) to keep the backend code you already have. See Migrating. |
Reading the token
On success the widget injects a hidden input into the closest <form>
(or into the widget element if there is no form):
<input type="hidden" name="infopeak-captcha-response" value="THE_TOKEN">
A solved widget also carries the CSS class is-done, which you can use to
enable a submit button. Tokens are single-use and short-lived - verify them server-side promptly.
Resetting the widget
Tokens are single-use. If a submission is rejected and the visitor stays on the page - a wrong password, a failed field validation, anything you handle without a page reload - the token in the form has already been spent and the next attempt will fail on the captcha. Ask the widget for a fresh one:
// All widgets on the page.
window.infopeakCaptcha.reset();
// Or just one, by element or CSS selector.
window.infopeakCaptcha.reset(document.querySelector('.infopeak-captcha'));
window.infopeakCaptcha.reset('#signup-captcha');
The widget returns to its idle state, clears the hidden field and immediately solves again, so a new token is
in place within a second or two. It returns the number of widgets it reset, which is
0 if the selector matched nothing.
Languages
The widget picks its language in this order: the data-lang attribute, then the page's
<html lang>, then the browser's language. Anything unrecognised falls back to English,
and nb is treated as no.
<div class="infopeak-captcha" data-sitekey="YOUR_SITEKEY" data-lang="da"></div>Supported codes
cs Czech |
da Danish |
de German |
en English |
es Spanish |
et Estonian |
fi Finnish |
fr French |
it Italian |
nl Dutch |
no Norwegian |
pl Polish |
pt Portuguese |
ro Romanian |
sv Swedish |
uk Ukrainian |
Accessibility
The usual CAPTCHA accessibility problems come from the challenge itself: distorted text, image grids, audio fallbacks and time limits. InfoPeak Captcha has none of them, so the barrier never gets built in the first place.
-
Nothing to solve. Verification runs by itself in the background. Most visitors never interact with the widget at all.
-
Real button semantics. The widget renders an actual
<button>with anaria-label, so it stays reachable and operable by keyboard and screen reader. -
State is announced. On success the button is marked
aria-checked="true"and the visible label changes, so the result is conveyed as text rather than colour alone. -
Translated status text. The idle, verifying, verified and error labels ship in all 16 supported languages.
-
No time pressure. There is no countdown and no penalty for taking your time.
<form> it protects. That way the verification starts as
soon as the visitor engages with the form, and assistive technology reads it in the right place in the flow.
Server-side verification
Always verify the token on your server before trusting a submission. Send your sitekey,
secret and the submitted token to
/api/v1/pow/siteverify. A valid: true response means the token
was genuine and had not been used before. The same code that protects infopeak.io's own signup is shown here for PHP.
function infopeak_captcha_verify(string $token): bool {
$ch = curl_init('https://captcha.infopeak.io/api/v1/pow/siteverify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'sitekey' => 'YOUR_SITEKEY',
'secret' => 'YOUR_SECRET',
'token' => $token,
]),
CURLOPT_TIMEOUT => 8,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $resp !== false && $code === 200
&& (json_decode($resp, true)['valid'] ?? false) === true;
}async function infopeakCaptchaVerify(token) {
const res = await fetch('https://captcha.infopeak.io/api/v1/pow/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sitekey: 'YOUR_SITEKEY',
secret: 'YOUR_SECRET',
token,
}),
});
if (!res.ok) return false;
const data = await res.json();
return data.valid === true;
}import requests
def infopeak_captcha_verify(token: str) -> bool:
r = requests.post(
"https://captcha.infopeak.io/api/v1/pow/siteverify",
json={
"sitekey": "YOUR_SITEKEY",
"secret": "YOUR_SECRET",
"token": token,
},
timeout=8,
)
return r.status_code == 200 and r.json().get("valid") is Truefunc InfopeakCaptchaVerify(token string) bool {
body, _ := json.Marshal(map[string]string{
"sitekey": "YOUR_SITEKEY",
"secret": "YOUR_SECRET",
"token": token,
})
resp, err := http.Post(
"https://captcha.infopeak.io/api/v1/pow/siteverify",
"application/json", bytes.NewReader(body),
)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var out struct{ Valid bool `json:"valid"` }
json.NewDecoder(resp.Body).Decode(&out)
return out.Valid
}require 'net/http'
require 'json'
def infopeak_captcha_verify(token)
uri = URI('https://captcha.infopeak.io/api/v1/pow/siteverify')
res = Net::HTTP.post(uri, {
sitekey: 'YOUR_SITEKEY',
secret: 'YOUR_SECRET',
token: token
}.to_json, 'Content-Type' => 'application/json')
res.code == '200' && JSON.parse(res.body)['valid'] == true
endAPI reference
Base URL https://captcha.infopeak.io. All endpoints accept and return JSON.
/pow/config and /pow/verify are called
by the widget in the browser. In normal use you only call
/pow/siteverify from your server. The first two are documented for completeness
and custom integrations.
Confirm a token server-side. Your secret authenticates the call. This is the only endpoint you normally implement.
Request
- sitekey - string - your public sitekey
- secret - string - your server-side secret
- token - string - the value from the infopeak-captcha-response field
Response
- valid - boolean - true if the token was genuine and unused
- over_limit - boolean - true if the account is over its monthly quota; protection is then paused and valid is always true (see Quotas)
curl -X POST https://captcha.infopeak.io/api/v1/pow/siteverify \
-H "Content-Type: application/json" \
-d '{"sitekey":"YOUR_SITEKEY","secret":"YOUR_SECRET","token":"THE_TOKEN"}'
# => {"valid":true,"over_limit":false}The same verification in the format reCAPTCHA, hCaptcha and Turnstile use, so existing backend code works by changing only the URL. Parameters are read from a form-encoded body, a JSON body or the query string. Always answers HTTP 200; check success.
Request
- secret - string - your server-side secret
- response - string - the token the widget put in the form
- sitekey - string, optional - if sent, the token must belong to this sitekey
- remoteip - string, optional - accepted and ignored
Response
- success - boolean - true if the token was genuine and unused
- challenge_ts - string - when the challenge was solved (ISO 8601, UTC)
- hostname - string - the site the challenge was solved on
- error-codes - array - on failure: missing-input-secret, invalid-input-secret, missing-input-response, invalid-input-response, timeout-or-duplicate or bad-request. With success: over-limit when the quota is used up (see Quotas)
curl -X POST https://captcha.infopeak.io/siteverify \
-d secret=YOUR_SECRET \
-d response=THE_TOKEN
# => {"success":true,"challenge_ts":"2026-09-25T09:41:44Z","hostname":"example.com"}Called by the widget. Issues a proof-of-work challenge for a sitekey.
Request
- sitekey - string - your public sitekey
Response
- challenge - string - the challenge to solve
- salt - string - salt for the work function
- difficulty - number - target work factor
Called by the widget. Exchanges solved work for a single-use token.
Request
- sitekey - string - your public sitekey
- challenge - string - the challenge from /pow/config
- nonce - number - the solution found by the browser
Response
- token - string - single-use verification token
Quotas & pricing
Your account has a monthly verification quota based on its plan, shared by all its sitekeys. When you go
over, protection pauses until the 1st of next month or until you upgrade, whichever comes first. Your
form keeps working, but nothing is checked: /pow/siteverify returns
valid: true with over_limit: true for any token,
and /siteverify returns success: true with the
error code over-limit. The widget shows that protection is paused.
This is the same fail-open behaviour as reCAPTCHA, so real visitors are never turned away.
You don't have to watch the numbers yourself. We email you when your account has used 80 % of its monthly quota and again at 100 %, once each per month. The count resets on the 1st (UTC).
| Plan | Verifications / month |
|---|---|
| Free | 10,000 |
| Pro | 100,000 |
| Business | 1,000,000 |
| Enterprise | Unlimited |
Testing
Use these public test credentials to build and test your integration before creating your own key. They work on any domain, never count against a quota, and run the real proof-of-work flow. Never use them in production.
| Test sitekey | ipk_test_sitekey |
| Test secret | ipk_test_secret |
Domain binding
Your sitekey is public - it ships in your HTML, and that is fine. Your secret is not and must only ever live on your server. Never expose the secret in client-side code.
To stop others from embedding your sitekey on their own pages, bind it to one or more domains in your dashboard. A bound key only issues challenges when the request comes from an allowed domain; unbound keys work everywhere. Bind your keys before going to production.
Content Security Policy
If your site sends a Content-Security-Policy header, allow these three directives.
This is the most common reason a correct integration still shows nothing.
script-src https://captcha.infopeak.io
connect-src https://captcha.infopeak.io
worker-src blob:-
script-srcLoads the widget script itself. -
connect-srcThe widget fetches the challenge API, the worker source and the WebAssembly engine from this origin. -
worker-src blob:The proof-of-work runs in a Web Worker created from a same-origin blob, because browsers block workers loaded straight from another domain.
Strict style-src
The loader injects its own inline <style> so the two-line embed works without a
separate stylesheet. If your policy forbids inline styles, load the stylesheet yourself and give it the id
ipk-captcha-css - the loader sees it and skips the injection.
<link rel="stylesheet" id="ipk-captcha-css"
href="https://captcha.infopeak.io/infopeak-captcha.css">
Then add https://captcha.infopeak.io to your style-src as well.
Migrating
Coming from reCAPTCHA v2, hCaptcha or Turnstile, you change three things: the script, the widget element and
the verify URL. Your form handling and your server-side verification code stay as they are, because
/siteverify takes the same parameters and answers in the same format.
<!-- Before -->
<div class="g-recaptcha" data-sitekey="..."></div>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<!-- After -->
<div class="infopeak-captcha" data-sitekey="YOUR_SITEKEY"
data-response-field-name="g-recaptcha-response"></div>
<script src="https://captcha.infopeak.io/infopeak-captcha.js" defer></script>
On your server, point the verify call at https://captcha.infopeak.io/siteverify
and use your InfoPeak secret. That is the whole backend change.
From reCAPTCHA
| reCAPTCHA | InfoPeak Captcha |
|---|---|
<div class="g-recaptcha" data-sitekey> |
<div class="infopeak-captcha" data-sitekey> |
https://www.google.com/recaptcha/api.js |
https://captcha.infopeak.io/infopeak-captcha.js |
POST field g-recaptcha-response |
Unchanged with data-response-field-name="g-recaptcha-response" |
https://www.google.com/recaptcha/api/siteverify |
https://captcha.infopeak.io/siteverify |
secret + response, success back |
Unchanged |
grecaptcha.reset() |
infopeakCaptcha.reset() |
grecaptcha.getResponse() |
Read the hidden field's value |
From hCaptcha
| hCaptcha | InfoPeak Captcha |
|---|---|
<div class="h-captcha" data-sitekey> |
<div class="infopeak-captcha" data-sitekey> |
https://js.hcaptcha.com/1/api.js |
https://captcha.infopeak.io/infopeak-captcha.js |
POST field h-captcha-response |
Unchanged with data-response-field-name="h-captcha-response" |
https://api.hcaptcha.com/siteverify |
https://captcha.infopeak.io/siteverify |
secret + response (+ sitekey), success back |
Unchanged |
hcaptcha.reset() |
infopeakCaptcha.reset() |
From Turnstile
| Turnstile | InfoPeak Captcha |
|---|---|
<div class="cf-turnstile" data-sitekey> |
<div class="infopeak-captcha" data-sitekey> |
https://challenges.cloudflare.com/turnstile/v0/api.js |
https://captcha.infopeak.io/infopeak-captcha.js |
POST field cf-turnstile-response |
Unchanged with data-response-field-name="cf-turnstile-response" |
https://challenges.cloudflare.com/turnstile/v0/siteverify |
https://captcha.infopeak.io/siteverify |
secret + response (form or JSON), success back |
Unchanged |
turnstile.reset() |
infopeakCaptcha.reset() |
score and action checks and keep the
success check.
Errors
Errors return a non-2xx status and a JSON body {"error": "..."}.
| Status | Meaning | Fix |
|---|---|---|
400 |
Challenge not found or invalid work | The token is stale, malformed, or already used. Call infopeakCaptcha.reset() to issue a fresh one before the next attempt. |
401 |
Wrong secret | The secret does not match the sitekey. Check your server configuration. |
403 |
Domain not allowed | The request origin is not in the sitekey's allowed domains. Add it in your dashboard. |
404 |
Unknown sitekey | The sitekey does not exist. Check for typos. |
Ready to ship it?
Create a free sitekey and protect your first form in under five minutes.
Get your free key