Short answer: look at which script the page loads and how it asks for a token. api.js with a visible checkbox is reCAPTCHA v2. api.js with no checkbox, bound to a button or to grecaptcha.execute(), is v2 invisible. api.js?render=<sitekey> plus grecaptcha.execute(sitekey, {action}) is v3. enterprise.js, with calls under grecaptcha.enterprise.*, is reCAPTCHA Enterprise. Enterprise is not a fourth kind of challenge. It is Google Cloud's version of the same checkbox, invisible and score keys, and the site checks its tokens through a different backend API.
Getting this right matters because a token made for the wrong variant often comes back from the solver fine, submits fine, and is then rejected by the site's backend with no useful error. Below you'll find the signals side by side, a small Python detector, a Playwright check for what loads at runtime, and the solver parameters for each variant. I ran all of it against Google's own demo pages on 2026-09-29, and the outputs shown are from those runs.
reCAPTCHA v2 vs v3 vs Enterprise at a glance
| Script | What you see | How the token is made | What the site's backend gets | |
|---|---|---|---|---|
| v2 checkbox | recaptcha/api.js |
"I'm not a robot" box | user ticks it (image grid if risky) |
success, hostname
|
| v2 invisible | recaptcha/api.js |
no box, only the badge | a bound button or grecaptcha.execute()
|
success, hostname
|
| v3 | recaptcha/api.js?render=<sitekey> |
the badge only | grecaptcha.execute(sitekey, {action}) |
success, score, action
|
| Enterprise | recaptcha/enterprise.js |
any of the three above |
grecaptcha.enterprise.execute() or .render()
|
tokenProperties, riskAnalysis (score and reasons) |
Two things won't help you tell them apart. The sitekey won't: every variant uses a 40-character key that starts with 6L. The "protected by reCAPTCHA" badge won't either, because v2 invisible and v3 both show it. Some sites load the scripts from www.recaptcha.net instead of www.google.com, and the same rules apply.
reCAPTCHA Enterprise vs v3: what is actually different
This is the pair people mix up most. In the browser, an Enterprise score key behaves exactly like v3: there's no widget, just a badge and an execute() call with an action. The differences are these.
- Where the key lives. Enterprise is a Google Cloud product. Its keys belong to a Cloud project, and they come in the same flavours as the classic ones: score, checkbox and invisible.
-
The client script. The page loads
enterprise.jsinstead ofapi.js, and every call goes throughgrecaptcha.enterprise, for examplegrecaptcha.enterprise.execute(sitekey, {action: 'LOGIN'}). Its requests normally go to/recaptcha/enterprise/...rather than/recaptcha/api2/.... -
How the backend checks the token. Classic v2 and v3 post the token and a secret to
https://www.google.com/recaptcha/api/siteverify. Enterprise creates an assessment through the Cloud API instead. The request looks roughly like this (authentication left out):
POST https://recaptchaenterprise.googleapis.com/v1/projects/PROJECT_ID/assessments
{"event": {"token": "TOKEN", "siteKey": "6Lc...", "expectedAction": "LOGIN",
"userAgent": "Mozilla/5.0 ...", "userIpAddress": "203.0.113.7"}}
The answer carries more than a score:
{"tokenProperties": {"valid": true, "action": "LOGIN", "hostname": "example.com"},
"riskAnalysis": {"score": 0.3, "reasons": ["AUTOMATION"]}}
-
More signals.
reasonscan includeAUTOMATION,UNEXPECTED_ENVIRONMENT,TOO_MUCH_TRAFFICorLOW_CONFIDENCE_SCORE. An invalid token also says why intokenProperties.invalidReason, with values such asEXPIRED,DUPEorMALFORMED. Google documents 11 score levels for Enterprise, but by default a site sees only four: 0.1, 0.3, 0.7 and 0.9.
From the automation side, the browser mechanics are the same as v3 (or v2, for checkbox keys). What changes is how much the backend can compare. The site can pass Google the user agent and IP of the request that carried the token. A token produced in one browser and then submitted with a different user agent can therefore fail. That's why an Enterprise solve comes back with a user_agent you're expected to reuse. It helps to think of Enterprise as v3 with a backend that sees more, not as a harder version of v3.
v2 checkbox vs v2 invisible vs v3: the markup
v2 checkbox is a div with a sitekey, rendered by api.js:
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-sitekey="6Lc..."></div>
Some sites use explicit rendering instead: api.js?onload=onloadCallback&render=explicit plus grecaptcha.render(el, {sitekey: '6Lc...'}). That is still v2. Only render= followed by a sitekey means v3.
v2 invisible has three common markups. Google's own two demos use the two that never say "invisible":
<div class="g-recaptcha" data-sitekey="6Lc..." data-size="invisible" data-callback="onSubmit"></div>
<button class="g-recaptcha" data-sitekey="6Lc..." data-callback="onSubmit">Submit</button>
<div class="g-recaptcha" data-sitekey="6Lc..." data-bind="submit-btn" data-callback="onSuccess"></div>
The programmatic version is grecaptcha.render(el, {sitekey: '6Lc...', size: 'invisible'}).
v3 has no element at all, just the keyed include and an execute() call:
<script src="https://www.google.com/recaptcha/api.js?render=6Lc..."></script>
<script>
grecaptcha.ready(() =>
grecaptcha.execute('6Lc...', {action: 'submit'}).then(sendTokenToBackend));
</script>
At runtime, every key in use gets an anchor iframe. The iframe URL carries the sitekey as k= and a size=. A checkbox gets normal or compact. A v2 invisible widget gets invisible, and so does v3. So size=invisible alone doesn't separate v2 invisible from v3. The render=<sitekey> include and an execute() call with an action do.
Detect the reCAPTCHA version from the HTML (Python)
This script reads the page and its same-origin scripts. It reports each sitekey with its type, whether it's Enterprise, and the action or callback if it can find them. It's regex-based, so treat it as a triage tool rather than a parser.
# detect_recaptcha.py: which reCAPTCHA does a page use? Needs: pip install requests
import html
import re
import sys
from urllib.parse import parse_qs, urljoin, urlparse
import requests
KEY = r"6L[\w-]{38}"
SCRIPT = re.compile(r"""<script[^>]+src=["']([^"']*/recaptcha/(?:api|enterprise)\.js[^"']*)["']""", re.I)
TAG = re.compile(r"<(\w+)\s([^>]*data-sitekey[^>]*)>", re.I)
ATTR = re.compile(r"""([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
RENDER = re.compile(r"grecaptcha(\.enterprise)?\.render\([^,]+,\s*\{([^}]*)\}")
EXECUTE = re.compile(rf"""grecaptcha(\.enterprise)?\.execute\(\s*['"]({KEY})['"]\s*,\s*\{{\s*action\s*:\s*['"]([^'"]+)['"]""")
def detect(url):
ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/140.0 Safari/537.36"}
page = requests.get(url, headers=ua, timeout=30).text
code = page
for src in re.findall(r"""<script[^>]+src=["']([^"']+\.js[^"']*)["']""", page)[:20]:
src = urljoin(url, html.unescape(src)) # actions often live in bundles
if urlparse(src).netloc == urlparse(url).netloc:
code += requests.get(src, headers=ua, timeout=30).text
keys, widget_script = {}, None
for src in SCRIPT.findall(page):
src = urljoin(url, html.unescape(src))
enterprise = "/enterprise.js" in src
render = parse_qs(urlparse(src).query).get("render", [""])[0]
if re.fullmatch(KEY, render): # render=<sitekey>: score mode
keys[render] = {"type": "v3", "enterprise": enterprise, "action": None}
else: # no key: this script renders widgets
widget_script = "enterprise" if enterprise else "standard"
for tag, attrs in TAG.findall(page): # implicit widgets
a = {m[0].lower(): m[1] or m[2] for m in ATTR.findall(attrs)}
invisible = a.get("data-size") == "invisible" or "data-bind" in a or tag.lower() == "button"
keys[a["data-sitekey"]] = {"type": "v2-invisible" if invisible else "v2-checkbox",
"enterprise": widget_script == "enterprise",
"action": a.get("data-action"),
"callback": a.get("data-callback")}
for ent, body in RENDER.findall(code): # explicit grecaptcha.render(...)
key = re.search(KEY, body)
if key:
invisible = re.search(r"""size['"]?\s*:\s*['"]invisible""", body)
keys[key.group()] = {"type": "v2-invisible" if invisible else "v2-checkbox",
"enterprise": bool(ent) or widget_script == "enterprise",
"action": None}
for ent, key, action in EXECUTE.findall(code): # execute(key, {action})
info = keys.setdefault(key, {"type": "v3", "enterprise": bool(ent)})
info.update(type="v3", action=action, enterprise=info["enterprise"] or bool(ent))
return [{"sitekey": key, **info} for key, info in keys.items()]
if __name__ == "__main__":
for found in detect(sys.argv[1]):
print(found)
Here it is against four of Google's demo pages:
$ python detect_recaptcha.py https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php
{'sitekey': '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', 'type': 'v2-invisible', 'enterprise': False, 'action': None, 'callback': 'onSubmit'}
$ python detect_recaptcha.py https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php
{'sitekey': '6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9', 'type': 'v3', 'enterprise': False, 'action': 'examples/v3scores'}
$ python detect_recaptcha.py https://www.google.com/recaptcha/api2/demo
{'sitekey': '6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-', 'type': 'v2-checkbox', 'enterprise': False, 'action': 'action', 'callback': 'onSuccess'}
$ python detect_recaptcha.py https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox-explicit.php
{'sitekey': '6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9', 'type': 'v2-checkbox', 'enterprise': False, 'action': None}
On a page with enterprise.js?render=KEY and grecaptcha.enterprise.execute(KEY, {action: 'LOGIN'}) in a bundle, it reports 'type': 'v3', 'enterprise': True, 'action': 'LOGIN'.
It has limits. A minified bundle often passes the action as a variable ({action: e}), which a regex can't read. Scripts added by a tag manager aren't in the HTML at all. The runtime check below covers the second gap, and a DevTools breakpoint on the execute( line covers the first.
Confirm what loads at runtime (Playwright)
This one opens the page in a real browser and records every reCAPTCHA script and anchor iframe it requests.
# sniff_recaptcha.py: what does the page load at runtime?
# Needs: pip install playwright && playwright install chromium
import sys
from urllib.parse import parse_qs, urlparse
from playwright.sync_api import sync_playwright
def sniff(url):
scripts, anchors = set(), []
def on_request(req):
u = urlparse(req.url)
if "/recaptcha/" not in u.path:
return
name = u.path.rsplit("/", 1)[-1]
if name in ("api.js", "enterprise.js"):
scripts.add(req.url)
elif name == "anchor": # one anchor iframe per key in use
q = parse_qs(u.query)
anchors.append({"sitekey": q.get("k", [""])[0],
"enterprise": "/enterprise/" in u.path,
"size": q.get("size", [""])[0], # normal, compact or invisible
"sa": q.get("sa", [""])[0]}) # the widget's action, if set
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.on("request", on_request)
page.goto(url, wait_until="networkidle")
namespace = page.evaluate("""() => ({
grecaptcha: typeof window.grecaptcha !== 'undefined',
enterprise: typeof window.grecaptcha?.enterprise !== 'undefined'})""")
browser.close()
return scripts, anchors, namespace
if __name__ == "__main__":
scripts, anchors, namespace = sniff(sys.argv[1])
for src in sorted(scripts):
print("script:", src)
for anchor in anchors:
print("anchor:", anchor)
print("namespace:", namespace)
Here's the output on Google's v3 demo:
$ python sniff_recaptcha.py https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php
script: https://www.google.com/recaptcha/api.js?render=6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9
script: https://www.google.com/recaptcha/enterprise.js?render=6Le80pApAAAAANg24CMbhL_U2PASCW_JUnq5jPys
anchor: {'sitekey': '6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9', 'enterprise': False, 'size': 'invisible', 'sa': ''}
anchor: {'sitekey': '6Le80pApAAAAANg24CMbhL_U2PASCW_JUnq5jPys', 'enterprise': True, 'size': 'invisible', 'sa': ''}
namespace: {'grecaptcha': True, 'enterprise': True}
This output shows the trap. Every page on recaptcha-demo.appspot.com loads a second key through enterprise.js at runtime, and that key isn't in the HTML. The v3 form itself uses 6LdKlZEp... through api.js. If you had gone by "the page loads enterprise.js", you would have solved the wrong key in the wrong mode.
The anchor path doesn't settle it either. The checkbox and invisible demos load both scripts too, and in most of my runs on them the form's own classic widget was served from /recaptcha/enterprise/anchor, on the invisible demo as well. Yet the demo's backend accepted a standard solve for the invisible demo's key, with no enterprise=1 (it's the Python run further down).
So here's the rule that holds up. Start from the sitekey the form actually uses. Then follow that key: which script include carries it, and whether grecaptcha or grecaptcha.enterprise calls execute() or render() with it.
Solver parameters for each reCAPTCHA variant
These are the parameter names of the 2Captcha-style in.php/res.php API, which several solving services share. If you're moving an existing client from one provider to another, the switch is mostly a base-URL change.
| Variant | Add to method=userrecaptcha, googlekey, pageurl
|
Token in the res.php answer |
|---|---|---|
| v2 checkbox | nothing | request |
| v2 invisible |
invisible=1 (required) |
request |
| v2 Enterprise |
enterprise=1, plus action= set to the anchor's sa= value if it has one |
result, plus user_agent
|
| v3 |
version=v3 and action= set to the execute() action (both required) |
request |
| v3 Enterprise |
version=v3, action=... and enterprise=1
|
result, plus user_agent
|
There's no parameter for the score you want, and no min_score. The site's backend sets the threshold. What you control is the action, the timing and the user agent.
One client covers all five. It takes an entry from detect_recaptcha.py:
# solve_recaptcha.py: one client for all five variants. Needs: pip install requests
import os
import re
import time
import requests
API = "https://ocr.captchaai.com" # any 2Captcha-style in.php/res.php API
API_KEY = os.environ.get("CAPTCHA_API_KEY", "YOUR_API_KEY")
RETRYABLE = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}
def build_params(info, pageurl):
"""info is one entry from detect_recaptcha.py: type, enterprise, action."""
p = {"key": API_KEY, "method": "userrecaptcha", "googlekey": info["sitekey"],
"pageurl": pageurl, "json": 1}
if info["type"] == "v2-invisible":
p["invisible"] = 1 # required for invisible widgets
if info["type"] == "v3":
if not info.get("action"):
raise ValueError("v3 needs the action passed to execute(); find it first")
p.update(version="v3", action=info["action"])
if info["enterprise"]:
p["enterprise"] = 1
if info["type"] != "v3" and info.get("action"):
p["action"] = info["action"] # v2 Enterprise: the sa= value
return p
def solve(params, timeout=180):
if params["key"] in ("", "YOUR_API_KEY"):
raise SystemExit("set CAPTCHA_API_KEY first")
for attempt in range(3): # submit, retrying server errors
r = requests.post(f"{API}/in.php", data=params, timeout=30).json()
if r["status"] == 1:
break
if r["request"] not in RETRYABLE: # bad key, bad sitekey, no balance
raise RuntimeError(f"submit failed: {r['request']}")
time.sleep(10 * (attempt + 1))
else:
raise RuntimeError("submit failed after 3 tries")
task_id, deadline = r["request"], time.time() + timeout
time.sleep(15) # nothing is ready before ~15 s
while time.time() < deadline:
try:
r = requests.get(f"{API}/res.php", timeout=30, params={
"key": params["key"], "action": "get", "id": task_id, "json": 1}).json()
except requests.RequestException as exc: # network blip: keep polling
print("poll error:", exc)
else:
if r["status"] == 1:
# standard answers put the token in "request"; Enterprise answers use
# "result" and add the solver's "user_agent", which you must reuse
return r.get("result") or r["request"], r.get("user_agent")
if r["request"] != "CAPCHA_NOT_READY": # ERROR_CAPTCHA_UNSOLVABLE, ...
raise RuntimeError(f"solve failed: {r['request']}")
time.sleep(5)
raise TimeoutError(f"task {task_id} not solved in {timeout}s")
if __name__ == "__main__":
# Google's public invisible v2 demo, as detect_recaptcha.py reported it
PAGE = "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php"
info = {"sitekey": "6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-",
"type": "v2-invisible", "enterprise": False, "action": None}
token, user_agent = solve(build_params(info, PAGE))
print("token:", token[:20] + "...", "| user_agent:", user_agent)
# submit it the way the page's form does: POST with g-recaptcha-response
headers = {"User-Agent": user_agent} if user_agent else {}
reply = requests.post(PAGE, headers=headers, timeout=30,
data={"ex-a": "foo", "ex-b": "bar", "g-recaptcha-response": token})
print("demo backend:", re.findall(r"'(success|hostname)' => '?([\w.-]+)", reply.text))
$ CAPTCHA_API_KEY=... python solve_recaptcha.py
token: 0cAFcWeA4d2qPybEZsxn... | user_agent: None
demo backend: [('success', 'true'), ('hostname', 'recaptcha-demo.appspot.com')]
The same client in Node 18+ (built-in fetch, no packages), pointed at the v3 demo this time:
// solve_recaptcha.mjs: the same client for Node 18+ (built-in fetch, no packages)
const API = "https://ocr.captchaai.com";
const API_KEY = process.env.CAPTCHA_API_KEY ?? "YOUR_API_KEY";
const RETRYABLE = new Set(["ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getJson = async (url, init = {}) =>
(await fetch(url, { ...init, signal: AbortSignal.timeout(30_000) })).json();
function buildParams(info, pageurl) {
const p = { key: API_KEY, method: "userrecaptcha", googlekey: info.sitekey, pageurl, json: "1" };
if (info.type === "v2-invisible") p.invisible = "1";
if (info.type === "v3") {
if (!info.action) throw new Error("v3 needs the action passed to execute(); find it first");
Object.assign(p, { version: "v3", action: info.action });
}
if (info.enterprise) {
p.enterprise = "1";
if (info.type !== "v3" && info.action) p.action = info.action; // v2 Enterprise: sa= value
}
return p;
}
async function solve(params, timeoutMs = 180_000) {
if (!params.key || params.key === "YOUR_API_KEY") throw new Error("set CAPTCHA_API_KEY first");
let r;
for (let attempt = 0; ; attempt++) {
r = await getJson(`${API}/in.php`, { method: "POST", body: new URLSearchParams(params) });
if (r.status === 1) break;
if (!RETRYABLE.has(r.request) || attempt === 2) throw new Error(`submit failed: ${r.request}`);
await sleep(10_000 * (attempt + 1));
}
const id = r.request;
const deadline = Date.now() + timeoutMs;
await sleep(15_000);
while (Date.now() < deadline) {
const q = new URLSearchParams({ key: params.key, action: "get", id, json: "1" });
try {
r = await getJson(`${API}/res.php?${q}`);
} catch (err) {
console.log("poll error:", err.message); // network blip: keep polling
await sleep(5_000);
continue;
}
// Enterprise answers carry the token in "result" plus the solver's "user_agent"
if (r.status === 1) return { token: r.result ?? r.request, userAgent: r.user_agent ?? null };
if (r.request !== "CAPCHA_NOT_READY") throw new Error(`solve failed: ${r.request}`);
await sleep(5_000);
}
throw new Error(`task ${id} not solved in ${timeoutMs / 1000}s`);
}
// Google's public v3 demo, as detect_recaptcha.py reported it
const PAGE = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";
const info = { sitekey: "6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9", type: "v3",
enterprise: false, action: "examples/v3scores" };
const { token, userAgent } = await solve(buildParams(info, PAGE));
console.log("token:", token.slice(0, 20) + "...", "| userAgent:", userAgent);
// the same check the demo page's own JavaScript runs with its token
const q = new URLSearchParams({ action: info.action, token });
console.log(await getJson(`https://recaptcha-demo.appspot.com/recaptcha-v3-verify.php?${q}`));
$ CAPTCHA_API_KEY=... node solve_recaptcha.mjs
token: 0cAFcWeA6F-w3KX697Jj... | userAgent: null
{
success: true,
hostname: 'recaptcha-demo.appspot.com',
challenge_ts: '2026-09-29T07:53:00Z',
apk_package_name: null,
score: 0.9,
action: 'examples/v3scores',
'error-codes': []
}
That's one run on a demo page, so don't read the 0.9 as a promise for your own target.
Where the token goes depends on the variant:
-
v2: it goes into the
g-recaptcha-responsetextarea. If the widget has adata-callback, call that function with the token as well, because on callback-driven forms filling the textarea alone does nothing. -
v3 and Enterprise: it goes into whatever field the page's own request uses. Watch the Network tab. It might be
g-recaptcha-responsein a form post, or a JSON field in an XHR. -
Enterprise: also send the request with the
user_agentfrom the answer as yourUser-Agentheader.
Why a correctly solved token still gets rejected
-
Expired or reused. A token is valid for two minutes and can be verified only once. Classic siteverify reports this as
timeout-or-duplicate, and Enterprise reportsEXPIREDorDUPE. Solve once per request and submit right away. -
Action mismatch. The backend compares the token's
actionwith the one it expects. Google's PHP library, which the demos use, reports this asaction-mismatch. Copy the string exactly, including case and slashes. -
Wrong variant. This covers v3 parameters sent for a v2 invisible key, a missing
invisible=1, and a missingenterprise=1. When the API can tell that the key belongs to another type,in.phpanswersERROR_WRONG_KEY_TYPE. Often nothing tells you. -
User agent mismatch on Enterprise. Reuse the
user_agentthat came back with the token. -
Wrong page URL. Send the URL of the page where the widget loads. If the widget sits inside an iframe, send the iframe's URL. The
hostnamein the check has to be the site's. - Score below the site's threshold. If everything above is right and it still fails, you have a reputation problem (IP, browser, behaviour). That's covered in why your reCAPTCHA v3 score is low.
- Enterprise site rules. An Enterprise site can reject a valid token on its own rules, based on reasons, IP or session. Test against your real target, not only a demo.
FAQ
Is reCAPTCHA Enterprise the same as v3?
No. Enterprise is the Google Cloud product. Its score keys work like v3 in the browser, but they load enterprise.js, call grecaptcha.enterprise.execute(), and are checked through the assessment API, which returns reasons and token details along with the score. Enterprise also has checkbox and invisible keys that behave like v2.
How do I know if a site uses reCAPTCHA Enterprise?
Find the sitekey the form uses. Then check whether enterprise.js loads that key, or whether it's passed to grecaptcha.enterprise.*. An enterprise.js somewhere on the page isn't enough on its own, because it can belong to another key.
Does render=explicit mean v3?
No. render=explicit is v2's explicit rendering mode. v3 is render= followed by the sitekey.
Can I tell the version from the sitekey?
No. All variants use 40-character keys that start with 6L.
Can I ask the solver for a 0.9 score?
No. There's no min_score parameter. The site decides which score it accepts.
I'm Bassem, and I run CaptchaAI. ocr.captchaai.com in the scripts above is our endpoint, and it solves all five variants in the table through the 2Captcha-compatible API. To try the detector's output against your own target, a free thread is enough: one thread for 30 days, no card. The v3 Enterprise guide lists every parameter.
Top comments (0)