Skip to content

Add a captcha to any form

Qlyne Verify stops bots on your forms without a puzzle and without the connector. The browser solves a short proof-of-work (about a second on a phone), and the form carries a token your server checks with Qlyne before accepting it. It works on any host: Vercel, Netlify, your own server.

Your site ID is in the site menu of the dashboard, and so are these two lines, already filled in (Bots, “Captcha for any form”):

<script src="https://wait.qlyne.com/src/verify.js" async></script>
<form method="post" action="/contact">
<!-- your fields -->
<div class="qlyne-verify" data-site="your_site_id" data-core-url="https://api.qlyne.com"></div>
<button>Send</button>
</form>

The box shows “Checking your browser…” and then “Browser checked”. The token goes into a hidden field named qlyne-token, inside the form.

Options on the div:

AttributeWhat it does
data-themelight or dark; by default it follows the visitor’s system
data-fieldanother name for the hidden field
data-callbackname of a global function that gets the token

The page also gets a qlyne:verified event with the token in event.detail.token.

Under Bots, “Challenge”, “Click to confirm” can make the box ask for a click on “I’m a person”: always, or only when the visitor looks like a bot (the widget sends the same counts as the bot score, never keys or text). The token comes after the click.

Add your site’s address to Site settings, “Allowed origins”, so only your pages can ask for challenges. Empty allows any site.

Before accepting the form, send the token to Qlyne with the account API key (Plan and API key). Keep the key on the server: anyone who reads it from the browser can check tokens in your name.

Terminal window
curl -X POST "https://api.qlyne.com/verify/check?tenant_id=your_site_id" \
-H "Authorization: Bearer $QLYNE_API_KEY" -H "Content-Type: application/json" \
-d '{"token": "value of qlyne-token", "remoteip": "visitor IP"}'

Answers:

AnswerMeaning
{"success": true, "solved_at": 1791300000, "origin": "https://shop.example"}Accept the form
{"success": false, "error": "invalid_or_expired"}Missing, wrong, already used or older than 5 minutes
{"success": false, "error": "ip_mismatch"}Solved from another IP than the one in remoteip
HTTP 401Wrong API key

remoteip is optional. Send it when your server knows the visitor’s real IP; behind a proxy, use the address the proxy forwards.

app/api/contact/route.js
export async function POST(request) {
const form = await request.formData();
const res = await fetch(`${process.env.QLYNE_CORE_URL}/verify/check?tenant_id=${process.env.QLYNE_SITE}`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.QLYNE_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ token: form.get('qlyne-token') }),
});
const check = await res.json();
if (!check.success) return Response.json({ error: 'Please try again.' }, { status: 400 });
// ...handle the form
return Response.json({ ok: true });
}
$check = Http::withToken(env('QLYNE_API_KEY'))
->post(env('QLYNE_CORE_URL').'/verify/check?tenant_id='.env('QLYNE_SITE'), [
'token' => $request->input('qlyne-token'),
'remoteip' => $request->ip(),
])->json();
abort_unless($check['success'] ?? false, 422, 'Please try again.');
import os, requests
check = requests.post(
f"{os.environ['QLYNE_CORE_URL']}/verify/check",
params={"tenant_id": os.environ["QLYNE_SITE"]},
headers={"Authorization": f"Bearer {os.environ['QLYNE_API_KEY']}"},
json={"token": form["qlyne-token"]},
timeout=5,
).json()
if not check.get("success"):
raise ValueError("captcha")

A token works once. If your page sends the form with fetch and stays open, ask for a new token after each send:

QlyneVerify.reset(document.querySelector('.qlyne-verify'));

The widget also renews the token by itself before it expires, so a form left open for a while still works.

If your site sends a Content-Security-Policy header, allow the script and the calls to Qlyne:

script-src 'self' https://wait.qlyne.com; connect-src 'self' https://api.qlyne.com

The widget needs nothing else: its styles don’t count as inline styles, and if the policy doesn’t allow a worker from blob:, it solves the challenge on the page itself. Adding worker-src blob: lets it solve in the background, which keeps a slow phone responsive.

Under Bots in the dashboard: captchas solved, automated browsers refused (the widget notices browsers driven by Selenium, Puppeteer or Playwright) and tokens your server rejected. No personal data is kept: the token holds the time, the origin and the visitor’s IP (the /64 for IPv6) for 5 minutes, only to compare with remoteip.