Pro Captcha

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.

  1. You create a site in the dashboard and get a public sitekey (PK_…) and a private secret (SK_…). The sitekey goes in your page, the secret stays on your server.
  2. The widget solves a small proof of work in the background and gets a token. It is single use and valid for 120 seconds.
  3. Your form sends the token to your backend. Your backend sends it, with the secret, to /v1/siteverify and reads the verdict.

Quickstart

Five minutes, start to finish.

  1. 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 with sitekey-mismatch.
  2. Copy the sitekey into your page and the secret into your server environment as PROCAPTCHA_SECRET.
  3. Add the script and the widget element to your form (see below). Submit the token with the form.
  4. 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().

OptionValuesWhat it does
sitekeyPK_…Required. Your public site key. The page hostname must be registered for the site.
actionstringNames the form, for example signup. The token carries it so the server can check it.
productscore (default), turn, puzzlescore 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.
puzzleauto, alwaysalways asks for the puzzle on the first round.
themeauto, light, darkauto follows the visitor's color scheme.
callbackglobal function nameData-attribute form of onSuccess. Called with the token.
onSuccess / onError / onExpire / onChallengefunctionsrender() 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>.

CodeMeaning
puzzle-closedThe visitor closed the puzzle dialog. A visible checkbox returns to idle.
puzzle-failedSix rounds ended without a solved puzzle.
puzzle-load-failedThe puzzle images could not be decoded.
invalid-responseThe server answered without a token and without asking for a puzzle.
product-unavailableThe site runs turn but the widget was rendered as score.

Reference

JavaScript API.

CallReturnsWhat it does
render(el, opts)widget idRenders 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?)stringThe current token, or an empty string if there is none or it is older than about 110 seconds.
reset(id?)voidClears the token and runs again. Call it after a failed submit, since tokens are single use.
remove(id)voidDestroys 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"
  }'
  • secret and response are required. Send JSON or a form-encoded body.
  • sitekey is optional. When present it must match the site that owns the secret.
  • remoteip is optional. A mismatch with the address that solved the challenge caps the score.
  • Idempotency-Key is optional. A retry with the same key returns the first answer instead of failing as a duplicate.
  • The endpoint answers 200 for a rejected token too. Read success, 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"
}
FieldMeaning
successtrue when the token is valid, unused and the verdict is not a fail.
score0 to 1. Higher is more likely a person.
verdictpass, fail, challenge or scored
actionThe action the widget was rendered with.
hostnameThe page hostname the token was issued on.
reasonsSignals that shaped the score. For logs and debugging, not for branching.
modelive, test or shadow, depending on the site mode.
error-codesEmpty 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

CodeMeaning
missing-input-secretNo secret in the request.
invalid-input-secretThe secret is unknown or has been rotated out.
missing-input-responseNo token in the request.
invalid-input-responseThe token is malformed, expired or was minted for another site.
sitekey-mismatchThe sitekey does not belong to this secret.
timeout-or-duplicateThe token was already used. Tokens are single use and are not re-scored.
quota-exceededThe workspace is over its plan quota.
verdict-failThe token was valid but the visitor was judged to be a bot.
action-mismatchSDK only: the token's action is not the one you expected.
hostname-mismatchSDK only: the token's hostname is not the one you expected.
score-below-thresholdSDK 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 mode field 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.

SymptomLikely cause
Widget shows an error, console says sitekey-mismatchThe page hostname is not registered on the site, or the sitekey belongs to another site.
Server answers invalid-input-secretWrong secret, or the old one after a rotation window ended. Check PROCAPTCHA_SECRET.
First submit works, the second fails with timeout-or-duplicateThe token was reused. Call procaptcha.reset() after every submit that reaches the server.
invalid-input-response on a fresh tokenThe 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-thresholdOn puzzle sites a solved puzzle can score between 0.2 and 0.5. Rely on success and verdict, or lower minScore.
Widget never appearsThe script is blocked by a CSP or an ad blocker, or the element has no class procaptcha and no render() call.
Error rate_limitedToo 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 expectedAction to the same value as data-action, so a token from the login form cannot be replayed on signup.
  • Set expectedHostname to 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 puzzle sites, rely on success and verdict, or lower minScore. 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.