A captcha that charges the client instead of guessing about it.
Two snippets to install, one call to verify, and the same response shape as the endpoint you are replacing. This page is the whole integration, including every error code and the four mistakes that turn a ten minute job into an afternoon.
What it is, and how it differs
The DEFLECTO captcha is a widget you put on a form and one call your server makes to check the result. If you have integrated reCAPTCHA or Turnstile, the shape is familiar on purpose: a public site key in the page, a secret on your server, a token in a hidden field, and a verification endpoint that answers success or a list of error codes.
What is underneath is not the same thing. A fingerprint captcha asks whether the client looks like a real browser, and answers from headers, JavaScript quirks, timing and a reputation score. That is a guess, and it is wrong in both directions: an automated browser that presents correctly passes for free, and a visitor with an unusual setup is punished for having one. This one asks a different question. It hands the client a signed challenge and requires a proof of work back, so instead of trying to recognise an attacker, it charges one. A single form submission is a rounding error of CPU. A hundred thousand of them is a hundred thousand times that, paid by whoever is sending them.
Be clear about the trade, because the marketing on the other side of this is misleading. A fingerprint-only captcha is instant because it does no work at all: it looks at the request and decides. Ours is fast for a different reason. The work is small, and it runs while the visitor is doing something else, reading the form, typing into it, or dragging a puzzle piece. By the time they press submit the token is already in the field. Nobody waits for it, and nobody gets a free pass either.
- Three endpoints, served on your own hostname because the edge node in front of you answers them:
/cap/v1/api.jsfor the loader,/cap/v1/bootstrapfor the browser, and/cap/v1/siteverifyfor your server. - No iframe. The widget is rendered inline and paints on the first frame after the script runs, which is most of why it is quicker to appear than the ones that nest a second document.
- No cookie, and no state to share between your servers. The challenge carries its own signed claims, so verification is a signature check plus a single-use record, not a lookup in a session store.
- A public key looks like
site_live_...and belongs in your HTML. A secret looks likesecret_live_...and belongs in your server’s environment. They are distinguishable on sight so that one in the wrong place is obvious in review.
The two snippets
The first goes in the page. Every element carrying data-deflecto-sitekey is rendered when the document is ready, so there is nothing to call.
<form action="/contact" method="post">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<div data-deflecto-sitekey="site_live_<your key>"
data-deflecto-action="contact"></div>
<button type="submit">Send</button>
</form>
<script src="/cap/v1/api.js" async></script>The widget writes its token into a hidden input named deflecto-captcha-response, which your form posts along with everything else. That input is appended to the nearest ancestor <form>, so keep the div inside the form. A div outside it renders perfectly and submits nothing, and the symptom on your server is missing-input-response.
If your form is rendered by a framework that owns the DOM, the same thing is a call, and it returns a widget id.
var id = deflecto.render(element, {
sitekey: "site_live_<your key>",
action: "contact",
callback: onToken,
"expired-callback": onExpired,
"error-callback": onError,
});
deflecto.reset(id); // discard the token and mint a fresh challenge
deflecto.getResponse(id); // the current token, or an empty stringThe second snippet is the one that matters. Your handler takes the posted token and asks the node about it, from your server, never from the page.
curl -sS https://example.com/cap/v1/siteverify \
-H 'content-type: application/json' \
--data '{
"secret": "secret_live_<your secret>",
"response": "<the deflecto-captcha-response field>",
"remoteip": "<the visitor address, optional>",
"action": "contact"
}'async function verifyCaptcha(token, ip) {
const res = await fetch("https://example.com/cap/v1/siteverify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
secret: process.env.DEFLECTO_CAPTCHA_SECRET,
response: token,
remoteip: ip,
action: "contact",
}),
});
// A token outcome is always 200. Anything else means the request itself was
// wrong, and it is not the visitor's fault: fail closed and alert yourself.
if (!res.ok) throw new Error("siteverify returned " + res.status);
const body = await res.json();
return body.success === true && body.action === "contact";
}A token that passes returns exactly this. The action is the one the token was minted for, the hostname is the one it was minted on, and challenge_ts is when it was minted, in RFC 3339.
{"success":true,"action":"contact","hostname":"example.com","challenge_ts":"2026-04-17T09:12:44Z"}A token that fails returns one code, in a list, because that is the shape existing error handling reads.
{"success":false,"error_codes":["already-verified"]}- Both of those are HTTP 200.A failed token is an outcome, not a transport error, so it does not fall into your client library’s error path. A 4xx from this endpoint means your request was malformed, which is a different problem with a different owner.
- The body is JSON. Form encoding is not accepted, and that is the only wire difference from the endpoint most people are migrating from. Unknown fields are ignored, so a client library adding one of its own is harmless.
actionis optional and worth sending. Send it and we compare it for you. Omit it and you are told which action the token carried, and comparing it is then yours to remember, which is a thing people forget.remoteipis optional unless the key asks for it. A key with strict address binding needs it, and treats an absent one as a mismatch rather than as a check to skip.- There are no CORS headers on this endpoint, deliberately. A browser must never be able to read its answer.
If you send remoteip, send an address you actually know. Reading X-Forwarded-Forand trusting it because the connection came from a proxy’s address range is not safe, for any provider, including the large ones.
The reason is concrete. Requests from a platform like Cloudflare Workers originate inside Cloudflare’s own ranges, so anybody with a free account is a client whose source address is on your trust list. They can then put any address they like in the header, spend somebody else’s reputation, walk around a ban, or get a real visitor blamed for their traffic. Trusting a forwarded address safely needs something the attacker cannot borrow: a client certificate, or a shared secret in a header you check.
The three modes, and what each costs the visitor
A key has one of three modes, and it is set on the key in the panel rather than in your markup. The page may carry a data-deflecto-modehint, and it only decides which affordance is drawn in the frame before the first reply lands, so the widget does not resize under the visitor’s eyes. The key’s mode wins the moment the reply arrives, because a page is attacker controlled and this is a security setting.
- silent
- Nothing to click. The widget mints a challenge on load, solves it while the visitor is reading the form, and writes the token. Bootstrap is 608 bytes, a first render costs 21,966 bytes including the loader, and a repeat render on a warm cache is the 608 bytes on their own. This is the default and it is the right answer for most forms.
- click
- One control the visitor presses, and the proof of work starts when they press it. Same wire cost as silent, same
dr1token. The click is evidence of a person stacked on top of the price, it is not a discount on the work. Choose it for a form where you want a deliberate gesture on the record, a contact form or a login. - puzzle
- A jigsaw piece to drag into its gap. Bootstrap is 26 to 27 KB because the background and the piece are PNGs sent as data URLs and a PNG barely compresses, so a first render is 47,614 bytes and every fresh challenge after that costs the 26 to 27 KB again. That belongs on a signup or a checkout, where one form load is worth 27 KB, and it does not belong on a site-wide gate that every page view pays for.
The loader is the same file in all three modes: 14,166 bytes as served, 5,330 with brotli, with a strong ETag so a repeat visit is answered 304 and costs nothing but the request. That is why the second render of a silent widget is 608 bytes.
The proof of work is identical in all three too. A key set to puzzle costs an attacker exactly what a key set to silent costs, and the interaction is evidence stacked on top of the price rather than a discount for looking friendlier. Anything else would make the gentlest option the cheapest one to attack, which is not a trap worth leaving in a dropdown.
So choose on what the visitor pays, not on how strong it feels. Silent everywhere by default. Click on the handful of forms where a deliberate gesture is worth recording. Puzzle only where a single form load justifies 27 KB, which is a signup or a checkout, and never as a gate on every page.
Somebody who cannot see the picture cannot place the piece, and no amount of keyboard support fixes that. The slider is focusable, arrow-driven and announced, which covers a visitor with no mouse, and it does nothing at all for a visitor with no sight. That is the honest cost of the mode, and it is why silent is the default and click exists in between.
For layout: the puzzle frame is 300 by 168 pixels and the piece is 60, so the widget is taller in puzzle mode than in the other two. Give the container room before you switch a key over.
Every error code, and what to do about each
Codes are stable strings that are never reworded, because your code compares them against literals. The first table is everything siteverify can answer, which is what your backend will see.
| Code | What it means, and what to do |
|---|---|
| missing-input-secret | No secret field arrived. Usually the request was form encoded: this endpoint reads JSON, and a form encoded body parses as one carrying no fields at all. That is the one place the wire format differs from the endpoint you are migrating from. |
| invalid-input-secret | The secret is malformed, belongs to a different key, was rotated and your configuration still carries the old value, or the request arrived with an Origin or Referer header, which means it came from a browser. Check the value against the panel, and if it was a browser that sent it, rotate the secret before you fix the code. |
| missing-input-response | No response field, so the form posted no token. The usual cause is placement: the widget writes its hidden input into the nearest ancestor <form>, so a div sitting outside the form renders correctly and submits nothing. |
| invalid-input-response | The value is not the shape we mint. Log its first few characters: a real token starts dr1. or dr2.. A value that starts with anything else was built by hand, or by a form library that serialised the field it never had. |
| bad-mac | The token does not authenticate under any signing key we hold, so it was altered after we minted it. A correct integration does not produce this. Look for a token that was URL decoded twice, trimmed of a trailing character, or stored in a column too short to hold it. |
| expired | The token is outside its own validity window. Tokens are short lived on purpose. Verify when the form arrives rather than from a queue you drain later, and let the widget mint a fresh one for a page that has been left open, which it does on its own and reports through data-deflecto-expired-callback. |
| already-verified | A token is single use and this one is spent. Two verifies for one submission is the common cause: a retry wrapper around your HTTP client, a double submitted form, or validation that calls siteverify once to check and once to act. Call it once and keep the answer. |
| action-mismatch | The token was minted for a different action than the one you pinned. Put the same string in the element’s data-deflecto-action and in the action you send. This is the check that stops a token from your public contact form being spent against your login. |
| hostname-mismatch | The token was minted for a hostname the key does not list. Add the hostname in the panel, including the www form if you serve it, because a *.example.com entry deliberately does not cover the apex. The binding is re-checked here and not only at mint time, so removing a hostname invalidates the tokens already out for it. |
| ip-mismatch | The key has strict address binding and the remoteipyou sent is not the address that minted the token. Send the visitor’s real address, or turn strict binding off: mobile networks move an address mid-session, and with strict binding on, omitting remoteip fails closed rather than skipping the check. |
| insufficient-work | The nonce does not pay for the challenge it is attached to. Our widget cannot produce this, so it means a client minted a challenge and answered it with less work than it was priced at. Treat it as an attack rather than as something to show a visitor, and do not offer a retry. |
| puzzle-failed | The work was paid and the piece was put in the wrong place. This is the one code worth turning into a sentence for the visitor, because it is the only one that means a person made an ordinary mistake. Ask them again. |
| sitekey-disabled | The key exists and is switched off in the panel. Every outstanding token for it stops verifying the moment it is disabled, which is what makes the switch worth having. Enable it, or point the page at a live key. |
The second table is bootstrap, which the browser calls. These codes never reach your backend: the widget shows the visitor a sentence and passes the code to data-deflecto-error-callback, so this is where to look when the widget itself will not come up.
| Code | What it means, and what to do |
|---|---|
| missing-sitekey | The element carries no data-deflecto-sitekey. Add it, or pass sitekey if you are calling deflecto.render yourself. |
| invalid-sitekey | The key is malformed, or it names no key on this node. Both causes share one code deliberately, because two codes would let anyone with a script find out which keys exist. |
| missing-origin | The browser sent no Origin, so there is no hostname to bind the token to. You are opening the page from file://, or rendering the widget inside a sandboxed iframe. |
| action-not-allowed | The key lists actions and this is not one of them, or the name is not a well-formed action. Add it to the key in the panel, or correct the attribute in the page. |
| rate-limited | The guard refused this request. The widget retries by itself, after the delay the reply names, and reports the code to your callback so it is visible rather than silent. There is nothing for your code to do. |
One rule covers everything not in the first table: if you did not get success: true, you do not have a verified visitor. That includes a request that timed out, a body you could not parse and a code released after you wrote your handler. Treat all of them the same way you treat a failure, which is to refuse the submission.
The four mistakes that cost an afternoon
These are in order of how expensive they are to find, not how common they are. All four look like the captcha being broken and all four are the integration.
The secret in client code
Siteverify refuses any request that carries an Origin or a Referer header and answers invalid-input-secret. That is deliberate and it is not a bug to work around. Either of those headers means a browser sent the request, and a secret a browser has sent is not a secret any more: it is in a cache, in a bundle, in the network tab and in somebody’s scraper. The refusal is the endpoint telling you before an attacker does.
We also log it loudly on our side, named with the site key, so an operator can reach you. If you see this code, move the call to your server and rotate the secret in the panel. The order does not matter, do both.
Spending a token twice
A token is single use. The second verify answers already-verified, and the usual culprit is not a double submitting visitor. It is a retry wrapper around your HTTP client treating a slow reply as a failure, or a handler that verifies once in a validation pass and once in the pass that sends the mail. Call siteverify once per submission and keep the answer for the rest of the request.
A token minted for a different action
The action is bound into the token. A token minted with data-deflecto-action="contact" and verified with action: "login" answers action-mismatch, and that is the binding doing its job: it is what stops a token harvested from your public contact form being spent against a form that matters. Grep for the string in both places when you add a second widget to a site, because copying the markup and forgetting the backend is exactly how this happens.
Never calling siteverify at all
The widget on its own is decoration. It makes a browser do work and it writes a token, and if nothing on your server ever checks that token then your form accepts anything, including a bare curl that never loaded the page. Every ingredient of the protection is in the call you skipped.
Refuse the submission when the field is absent, when the reply is not success: true, and when the call itself fails. A handler that processes the form when verification is unavailable has an off switch, and the switch is reachable by anybody who can make your request to us fail.
The test that proves this is one line: post your form with curl, with no captcha field at all, from outside your network. If your application accepts it, nothing else on this page is doing anything for you.
A rotated secret takes effect at once
An operator can mint a new secret for an existing key. That is the right thing to do when a secret has leaked or been lost, and it is cheap for you in one respect: the site key does not change, so your HTML needs no edit. Only your server configuration does.
It is not cheap in the other respect. There is no overlap window. A key holds one secret, rotating overwrites it, and siteverify checks the one it holds, so every call still carrying the old secret answers invalid-input-secret from that moment. On your side that presents as a form refusing every submission with nothing having changed in your code, which is why a rotation is coordinated with the deploy that carries the new value rather than done whenever somebody is in the panel.
If a secret is lost, ask for a new secret on the same key first, because that keeps the site key and confines the change to your environment. A whole new key is the fallback and a bigger job, since it means editing every page the old one appears in.