Documentation
Install the widget. Verify the token.
Everything you need to protect a form: one script tag on the page, one request from your server. Examples in nine server languages and five client setups.
Overview
How the pieces fit.
- You create a site in the dashboard and get a public
sitekey(PK_…) and a privatesecret(SK_…). The sitekey goes in your page, the secret stays on your server. - The widget solves a small proof of work in the background and gets a token. It is single use and valid for 120 seconds.
- Your form sends the token to your backend. Your backend sends it, with the secret, to
/v1/siteverifyand reads the verdict.
Quickstart
Five minutes, start to finish.
- Create an account, then add a site in the dashboard. Register every hostname the widget will run on (
shop.example.com, or*.example.com). A page on an unregistered host is refused withsitekey-mismatch. - Copy the
sitekeyinto your page and thesecretinto your server environment asPROCAPTCHA_SECRET. - Add the script and the widget element to your form (see below). Submit the token with the form.
- Verify the token on your server before you do anything with the request.
1 · Client
Add the widget to your page.
Load the script
Load it once per page. It is a single file with no dependencies, and the proof of work runs in a Web Worker so it never blocks your page.
<script src="https://cdnp.procaptcha.qzz.io/v1/api.js" async defer></script>Put the widget in your form
Any element with the class procaptcha is rendered automatically. The widget does not add a field to your form by itself: pass the token along in data-callback (or onSuccess) as below, or read it with procaptcha.getResponse() on submit. The hidden field is named procaptcha because the server SDKs look for that name.
No build stepAuto-renders every .procaptcha element
<script src="https://cdnp.procaptcha.qzz.io/v1/api.js" async defer></script>
<form method="post" action="/signup">
<input name="email" type="email" required>
<input type="hidden" name="procaptcha" id="procaptcha-token">
<div class="procaptcha"
data-sitekey="PK_your_sitekey"
data-action="signup"
data-callback="onCaptcha"></div>
<button type="submit">Sign up</button>
</form>
<script>
// Called with the token once the widget has verified the visitor.
function onCaptcha(token) {
document.getElementById("procaptcha-token").value = token;
}
</script>Reference
Widget options.
Each option works as a data- attribute on the element and as a key in procaptcha.render().
| Option | Values | What it does |
|---|---|---|
sitekey | PK_… | Required. Your public site key. The page hostname must be registered for the site. |
action | string | Names the form, for example signup. The token carries it so the server can check it. |
product | score (default), turn, puzzle | score is invisible. turn shows one checkbox. puzzle shows the checkbox and may add a slot puzzle for unsure visitors (Pro and Enterprise). The server resolves it against your site and plan. |
puzzle | auto, always | always asks for the puzzle on the first round. |
theme | auto, light, dark | auto follows the visitor's color scheme. |
callback | global function name | Data-attribute form of onSuccess. Called with the token. |
onSuccess / onError / onExpire / onChallenge | functions | render() callbacks. onChallenge fires each time the puzzle dialog opens. |
Widget error codes
onError receives one of these. Anything else is the API's own error string (for example rate_limited or sitekey-mismatch) or http_<status>.
| Code | Meaning |
|---|---|
puzzle-closed | The visitor closed the puzzle dialog. A visible checkbox returns to idle. |
puzzle-failed | Six rounds ended without a solved puzzle. |
puzzle-load-failed | The puzzle images could not be decoded. |
invalid-response | The server answered without a token and without asking for a puzzle. |
product-unavailable | The site runs turn but the widget was rendered as score. |
Reference
JavaScript API.
| Call | Returns | What it does |
|---|---|---|
render(el, opts) | widget id | Renders into a selector or element. Rendering twice onto one node replaces the first widget. |
execute(sitekey, { action }) | Promise<string> | Runs a verification and resolves with a token. With no widget on the page it runs invisibly. |
getResponse(id?) | string | The current token, or an empty string if there is none or it is older than about 110 seconds. |
reset(id?) | void | Clears the token and runs again. Call it after a failed submit, since tokens are single use. |
remove(id) | void | Destroys the widget. |
Invisible, on demand
For logins and API-style forms, skip the markup and ask for a token when the visitor submits.
// Invisible, no widget on the page: ask for a token when the visitor submits.
const token = await window.procaptcha.execute("PK_your_sitekey", { action: "login" });
await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Procaptcha-Token": token },
body: JSON.stringify({ email, password }),
});2 · Server
Verify the token on your backend.
Never trust a token that the browser sends without checking it. Make one request per token from your server, with your secret. The secret must never reach the browser or a public repository.
curl -X POST https://api.procaptcha.qzz.io/v1/siteverify \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7b1c0f3e" \
-d '{
"secret": "SK_your_secret",
"response": "TOKEN_FROM_THE_FORM",
"sitekey": "PK_your_sitekey",
"remoteip": "203.0.113.7"
}'secretandresponseare required. Send JSON or a form-encoded body.sitekeyis optional. When present it must match the site that owns the secret.remoteipis optional. A mismatch with the address that solved the challenge caps the score.Idempotency-Keyis optional. A retry with the same key returns the first answer instead of failing as a duplicate.- The endpoint answers
200for a rejected token too. Readsuccess, never the HTTP status alone.
Server examples
Pick your language.
Node.js, Go, Python and PHP have an official SDK that applies expectedAction, expectedHostname and minScore (default 0.5) for you, and retries once on network errors and 5xx. Every other language calls the HTTP endpoint above, which is a single JSON POST. Each example reads the token from the field procaptcha and treats a thrown error as a reject.
Official SDKnpm i @procaptcha/server
import express from "express";
import { middleware, verify } from "@procaptcha/server";
const app = express();
app.use(express.urlencoded({ extended: false }));
// Option 1: call verify() yourself and apply your own policy.
app.post("/signup", async (req, res) => {
try {
const r = await verify({
secret: process.env.PROCAPTCHA_SECRET!,
response: req.body.procaptcha,
expectedAction: "signup",
minScore: 0.5,
});
if (!r.success) return res.status(403).json({ codes: r["error-codes"] });
res.json({ ok: true, score: r.score });
} catch {
// Network failure or non-2xx answer: treat it as a reject.
res.status(502).json({ error: "captcha could not be verified" });
}
});
// Option 2: middleware, the result lands on res.locals.procaptcha.
app.post("/contact", middleware({ secret: process.env.PROCAPTCHA_SECRET! }), (req, res) => {
res.json({ ok: true });
});A network failure, a timeout or a non-2xx answer throws instead of returning a result. Treat both paths as a reject: if (err || !res.success).
Reference
Response and errors.
{
"success": true,
"score": 0.91,
"action": "signup",
"challenge_ts": "2026-10-30T13:39:10Z",
"hostname": "shop.example.com",
"provider": "procaptcha",
"reasons": ["ip_velocity=1.79", "pointer_entropy=0.8"],
"error-codes": [],
"verdict": "pass",
"mode": "live"
}| Field | Meaning |
|---|---|
success | true when the token is valid, unused and the verdict is not a fail. |
score | 0 to 1. Higher is more likely a person. |
verdict | pass, fail, challenge or scored |
action | The action the widget was rendered with. |
hostname | The page hostname the token was issued on. |
reasons | Signals that shaped the score. For logs and debugging, not for branching. |
mode | live, test or shadow, depending on the site mode. |
error-codes | Empty on success. See below. |
A rejected token
The status is still 200. The body has success: false and at least one code (abridged here).
{
"success": false,
"error-codes": ["timeout-or-duplicate"]
}Error codes
| Code | Meaning |
|---|---|
missing-input-secret | No secret in the request. |
invalid-input-secret | The secret is unknown or has been rotated out. |
missing-input-response | No token in the request. |
invalid-input-response | The token is malformed, expired or was minted for another site. |
sitekey-mismatch | The sitekey does not belong to this secret. |
timeout-or-duplicate | The token was already used. Tokens are single use and are not re-scored. |
quota-exceeded | The workspace is over its plan quota. |
verdict-fail | The token was valid but the visitor was judged to be a bot. |
action-mismatch | SDK only: the token's action is not the one you expected. |
hostname-mismatch | SDK only: the token's hostname is not the one you expected. |
score-below-threshold | SDK only: the score is under your minScore. |
Hardening
Content Security Policy.
If your site sends a CSP header, allow the script host, the API calls that come from the same host, and the proof-of-work worker, which runs from a blob: URL. The puzzle images are decoded in the page.
Content-Security-Policy:
script-src 'self' https://cdnp.procaptcha.qzz.io;
connect-src 'self' https://cdnp.procaptcha.qzz.io;
worker-src blob:;
img-src 'self' data: blob:;If you serve the script from your own host, point the widget at your API with data-api on the script tag.
Testing
Try it without blocking anyone.
- Create a site in test or shadow mode in the dashboard. Verdicts are still computed and logged, but visitors are not blocked. Switch the site to live when the numbers look right. The
modefield of the response tells you which one answered. - Open the site's verifications list in the dashboard to see each token, its score and its
reasons. - Verify the same token twice to see
timeout-or-duplicate. A replay is rejected, never re-scored.
Troubleshooting
When it does not work.
| Symptom | Likely cause |
|---|---|
| Widget shows an error, console says sitekey-mismatch | The page hostname is not registered on the site, or the sitekey belongs to another site. |
| Server answers invalid-input-secret | Wrong secret, or the old one after a rotation window ended. Check PROCAPTCHA_SECRET. |
| First submit works, the second fails with timeout-or-duplicate | The token was reused. Call procaptcha.reset() after every submit that reaches the server. |
| invalid-input-response on a fresh token | The token took longer than 120 seconds to reach the server, or the form field is empty. Log the field you read. |
| Valid puzzle solves are rejected with score-below-threshold | On puzzle sites a solved puzzle can score between 0.2 and 0.5. Rely on success and verdict, or lower minScore. |
| Widget never appears | The script is blocked by a CSP or an ad blocker, or the element has no class procaptcha and no render() call. |
| Error rate_limited | Too many challenges from one client in a short time. Back off and retry; verification calls are rate limited too. |
Checklist
Before you ship.
- Always verify on the server, on every protected request. A token that was only checked in the browser proves nothing.
- Set
expectedActionto the same value asdata-action, so a token from the login form cannot be replayed on signup. - Set
expectedHostnameto the exact host you serve the widget on, or leave it unset to use your site's domain allowlist. The SDK check is an exact match, while the allowlist accepts*.example.com. - Verify within 120 seconds of the token being issued, and only once. After a rejected submit, call
procaptcha.reset()to get a fresh token. - On
puzzlesites, rely onsuccessandverdict, or lowerminScore. A solved puzzle can carry a score between 0.2 and 0.5, which the default SDK threshold would reject. - Rotate secrets from the dashboard. The old secret keeps working for 24 hours, so you can deploy the new one without downtime.
- Keep the secret in an environment variable or a secret manager, never in client code or version control.
Ready to stop the bots?
Start on the free tier. Create a site, copy the sitekey, ship.