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.
1. Put the check in the form
Section titled “1. Put the check in the form”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:
| Attribute | What it does |
|---|---|
data-theme | light or dark; by default it follows the visitor’s system |
data-field | another name for the hidden field |
data-callback | name 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.
2. Check the token on your server
Section titled “2. Check the token on your server”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.
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:
| Answer | Meaning |
|---|---|
{"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 401 | Wrong 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.
Node.js (Next.js route handler)
Section titled “Node.js (Next.js route handler)”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 });}PHP (Laravel)
Section titled “PHP (Laravel)”$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.');Python
Section titled “Python”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")Forms sent with JavaScript
Section titled “Forms sent with JavaScript”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.
Sites with a Content Security Policy
Section titled “Sites with a Content Security Policy”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.comThe 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.
What gets counted
Section titled “What gets counted”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.