Add OTCaptcha to a form
You will add one script to your page and one check to your server. Everything else, from choosing a check to raising the work during an attack, happens on our side.
1. Quickstart
Each site gets a site key, which is public and goes in the page, and a secret, which stays on your server. Load the script, then request a pass when the visitor submits the form and send it along with the other fields.
<script src="[SCRIPT URL]" defer></script>
const pass = await otcaptcha.check("[SITE KEY]");
await submitForm({ ...fields, pass });Load the script when the form opens rather than when it is submitted. The check then has a few seconds of page behaviour to look at, and the proof-of-work is usually finished before the visitor presses the button.
2. What the visitor sees
The call returns a pass either way. What happens in between depends on the risk of the attempt and on the attack level of your site at that moment.
| Risk | The visitor sees | Your code sees |
|---|---|---|
| Normal | Nothing. The work runs in background threads. | The promise resolves with a pass. |
| Suspicious | A small box: press and hold until it fills. Keyboard: hold the space bar or Enter. | The same promise, a few seconds later. |
| Looks automated | A slide puzzle, with a switch to the hold for anyone who cannot drag. | The same promise, or a rejection if the visitor closes the box. |
The box is a dialog on top of your page: Tab stays inside it, Escape closes it, and focus returns to your form afterwards. It uses the language of your page, one of six, and your brand settings (section 6). Nothing in your code changes between the three cases.
3. Check the pass on your server
Send the pass to the verify endpoint before you act on the form. Never put your secret in the browser. A pass works once and expires five minutes after it was issued, so verify it as soon as it arrives and do not store it for later.
POST [VERIFY URL]
Content-Type: application/json
{ "secret": "[SECRET]", "pass": "[PASS]" }
200 OK
{ "valid": true, "reason": "ok" }Treat a missing pass the same as an invalid one. A page that shows the check but never verifies on the server is unprotected, and we flag such sites in your reports.
4. Result codes
| ok | The pass is valid. |
| expired | The pass is older than five minutes. |
| used | The pass has already been checked. |
| invalid | The pass or the secret is wrong. |
Only ok lets the form through. The other three are all a refusal; the reason is for your logs, not for the visitor.
5. Outage policy: fail open or fail closed
Decide on your server what happens when the verify endpoint does not answer. The default we recommend is to fail open: set a short timeout, let the visitor continue, and log the event. Your sign-ups then never depend on us being up.
Failing open is safe against someone blocking our domain in their own browser: if our service is up and the browser sends no real pass, your verify call still refuses it. What it does not cover is an attacker who can take our service down for everyone. For that case, switch the same site to fail closed for the duration of the attack, and switch back afterwards. Keep the switch in configuration, not in code, so it takes seconds either way.
// pseudocode on your server
try {
result = verify(pass, timeout: 2s)
} catch (timeout or connection error) {
log("verify unavailable")
return FAIL_OPEN ? allow() : reject()
}
return result.valid ? allow() : reject()6. Brand look
Four settings per site. They are applied the moment a box opens, and they need no change in your page.
| Setting | Values | Note |
|---|---|---|
| accent | A hex colour | Refused when it would be hard to read on the box |
| radius | 0 to 24 | Corner radius in pixels |
| mode | auto, light or dark | auto follows the visitor’s system setting |
| trust | on or off | Shows or hides the "Protected by OTCaptcha" line |
An accent that would be hard to read on the box is refused when it is set, not silently shown. The trust line is plain text, not a link, so it can never take a visitor out of your form.
7. Choose how to load the script
| Option | Updates | Best for |
|---|---|---|
| Hosted by us | Automatic: every site runs the current loader | Most sites |
| Self-hosted | When you copy a new file | Strict content security policies and change control |
The file is about 12 KB gzipped and under 11 KB with brotli. Loaded from us it is served precompressed with a strong ETag and a long revalidation window, so a returning visitor pays a 304, not a download. There is one current loader, 1.2.0; loaded from us, every site runs it. The loader reports its version with every check, so a self-hosted copy that falls behind is visible in your reports.
Content security policy: the script needs no inline script and starts its background workers from its own file. Hosted by you, it needs no change to script-src; loaded from us, allow our origin for scripts and connections.
8. Reports and the challenge funnel
Every check is recorded without personal data: time, site, which check ran, the outcome, the scores and the checks behind them, and the loader version. Network and device identifiers are salted hashes that change daily. Detailed records are kept for 90 days; daily totals without identifiers are kept longer.
A visitor who gives up on a hold or a puzzle never reaches your server. From loader 1.2.0 the box sends one small beacon when it ends, so the funnel can be reported:
| pass | The visitor finished the check. |
| closed | They closed the box. |
| left | They left the page with the box open. |
| alt | They switched from the puzzle to the hold. |
The beacon carries the counts of holds or drags started, released early, finished and refused. It carries no identifier, your code never waits for it, and a lost beacon is simply missing, so treat the funnel as a floor.
9. Testing
Dedicated test keys that always pass, always fail or always show a given check are planned and not available yet. Until then, test against a real site key: the demo page shows each check as the visitor sees it, and a check on your own page can be forced by submitting repeatedly from one address, which raises its risk score.
What to check before going live: the pass reaches your server on the normal path, a missing or reused pass is refused, and the form behaves as you intend when the verify endpoint times out.