When a reCAPTCHA v3 solver in Python returns a token and the site still says no, check the action first. I ran the script below against Google's own v3 demo page while writing this. With the page's real action, the demo's backend accepted the token. With action=login, the token still scored 0.9 and was rejected with action-mismatch.
So a v3 solve comes down to four values: the sitekey, the page URL, version=v3, and the exact action string the page passes to grecaptcha.execute(). Enterprise adds enterprise=1 and gives you back a User-Agent you have to reuse. Below: how to find each one, a solver you can run as-is, and why a valid token still gets turned away.
First, confirm it really is v3
Look at the script tag. api.js?render=SITEKEY means v3. enterprise.js?render=SITEKEY means v3 Enterprise, and the page's calls go through grecaptcha.enterprise.*. A visible checkbox, or a g-recaptcha div with data-size="invisible", means v2. That takes different parameters (invisible=1 for the invisible kind, no action). The full checklist is in reCAPTCHA v2 vs v3 vs Enterprise: how to tell which one you're fighting.
The sitekey is the render= value, with one trap: a page can load more than one. Google's v3 demo loads a second key through enterprise.js, next to the key its execute() call uses. Match the key to the execute() call that fires on your form, not to the first render= you find.
How to find the reCAPTCHA v3 action
The action is a free-form string the site chooses: login, submit, homepage, examples/v3scores. Google returns it to the site's backend next to the score, and a backend can reject any token whose action doesn't match what it expects. That's exactly what happened in my login run. Three ways to find it, quickest first.
1. Search the loaded scripts. In Chrome DevTools, Ctrl+Shift+F (Cmd+Option+F on a Mac) searches every script on the page. Search for execute(. On Google's demo it's in plain sight:
grecaptcha.execute('6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9', {action: 'examples/v3scores'})
2. Log it at runtime. Minified bundles can turn the action into a variable, so the search only finds {action: e}. Paste this into the console once the page has loaded, then submit the form:
// Logs the sitekey and action of every execute() call, v3 and Enterprise.
for (const api of [window.grecaptcha, window.grecaptcha?.enterprise]) {
if (!api || typeof api.execute !== "function") continue;
const original = api.execute;
api.execute = function (sitekey, options) {
console.log("sitekey:", sitekey, "| action:", options?.action);
return original.apply(this, arguments);
};
}
// on submit, prints e.g.: sitekey: 6Lc... | action: submit
3. Set a breakpoint. If the site calls execute() while the page loads, the console hook arrives too late. Open the file from step 1 in the Sources panel (press {} to pretty-print minified code), click the line number of the execute( call, reload, and read action in the Scope pane when it pauses.
Don't skip this when it gets fiddly. The API falls back to verify if action is empty, and verify is rarely what a site checks. Treat it as required.
The reCAPTCHA v3 solver in Python
This talks to a 2Captcha-compatible in.php/res.php API. API_BASE points at the one I run (details at the end); 2Captcha-style services use the same parameter names. It needs pip install requests and an API key in CAPTCHA_API_KEY.
# solve_v3.py: reCAPTCHA v3 / v3 Enterprise solver. Needs: pip install requests
import os
import time
import requests
API_BASE = "https://ocr.captchaai.com" # a 2Captcha-compatible 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 submit(sitekey, pageurl, action, enterprise=False):
if API_KEY in ("", "YOUR_API_KEY"):
raise SystemExit("Set the CAPTCHA_API_KEY environment variable first")
if not (sitekey and pageurl and action):
raise ValueError("sitekey, pageurl and action are all required for v3")
data = {"key": API_KEY, "method": "userrecaptcha", "version": "v3",
"googlekey": sitekey, "pageurl": pageurl, "action": action, "json": 1}
if enterprise:
data["enterprise"] = 1
for attempt in range(3):
r = requests.post(f"{API_BASE}/in.php", data=data, timeout=30).json()
if r["status"] == 1:
return r["request"] # the task id
if r["request"] not in RETRYABLE: # bad key, zero balance, bad sitekey...
raise RuntimeError(f"submit rejected: {r['request']}")
time.sleep(10 * (attempt + 1))
raise RuntimeError("submit kept failing with server errors")
def solve_v3(sitekey, pageurl, action, enterprise=False, timeout=120):
task_id = submit(sitekey, pageurl, action, enterprise)
time.sleep(15) # the docs advise a 15-20 s wait
deadline, delay, attempt = time.time() + timeout, 5, 0
while time.time() < deadline:
attempt += 1
try:
r = requests.get(f"{API_BASE}/res.php", timeout=30, params={
"key": API_KEY, "action": "get", "id": task_id, "json": 1}).json()
except requests.RequestException as exc: # network blip: back off, keep polling
print(f"poll {attempt}: {exc}")
delay = min(delay * 2, 30)
else:
if r["status"] == 1:
# Enterprise answers also carry the solver's User-Agent: keep it
return r.get("request") or r.get("result"), r.get("user_agent")
print(f"poll {attempt}: {r['request']}")
if r["request"] == "CAPCHA_NOT_READY":
delay = 5
elif r["request"] in RETRYABLE:
delay = min(delay * 2, 30)
else: # ERROR_CAPTCHA_UNSOLVABLE, ERROR_WRONG_CAPTCHA_ID, ...
raise RuntimeError(f"solve failed: {r['request']}")
time.sleep(delay)
raise TimeoutError(f"task {task_id} not solved within {timeout}s")
A few details that matter:
-
Enterprise is the same call plus
enterprise=True. Its answer comes back asresultanduser_agentinstead ofrequest(I checked the raw response on 2026-09-28), which is why the return line reads both. -
You can't request a score.
min_scoreis not a parameter. The site's backend sets the threshold, and the token clears it or doesn't. -
recaptcha.net pages. If the script loads from
www.recaptcha.netrather thanwww.google.com, add"domain": "recaptcha.net"todata. -
Polling.
CAPCHA_NOT_READYis normal: wait 5 seconds and ask again. Server errors back off up to 30 seconds, and anything else stops the loop, because retrying a bad sitekey only wastes time.
Send the token the way the page does
With v3 the token doesn't ride along in the form automatically, the way v2's g-recaptcha-response field does. The page's own JavaScript puts it wherever it wants: a form field, a JSON key, a query string. So submit the form once by hand with the Network tab open and copy that request exactly. Then be quick about it. Google's docs say a token expires two minutes after it's issued and can be verified only once, so solve right before the request and never reuse a token.
Append this to solve_v3.py. It makes the same request the demo page's JavaScript makes with its token:
if __name__ == "__main__":
# Google's public v3 demo: a safe target for trying this out
PAGE = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
ACTION = "examples/v3scores"
token, user_agent = solve_v3(
sitekey="6LdKlZEpAAAAAAOQjzC2v_d36tWxCl6dWsozdSy9", pageurl=PAGE, action=ACTION)
print("token:", token[:24] + "...", "| user agent:", user_agent)
session = requests.Session()
if user_agent: # Enterprise tokens are tied to the solver's User-Agent
session.headers["User-Agent"] = user_agent
# the same request the demo page's own JavaScript makes with its token
check = session.get("https://recaptcha-demo.appspot.com/recaptcha-v3-verify.php",
params={"action": ACTION, "token": token}, timeout=30)
print(check.json())
My run on 2026-09-28 (about 17 seconds end to end):
token: 0cAFcWeA7U2MiF6_97mK6g5l... | user agent: None
{'success': True, 'hostname': 'recaptcha-demo.appspot.com', 'challenge_ts': '2026-09-28T11:43:26Z', 'apk_package_name': None, 'score': 0.9, 'action': 'examples/v3scores', 'error-codes': []}
And the run where I solved with action="login" and submitted to the same page:
{'success': False, 'hostname': 'recaptcha-demo.appspot.com', 'challenge_ts': '2026-09-28T11:41:38Z', 'apk_package_name': None, 'score': 0.9, 'action': 'login', 'error-codes': ['action-mismatch']}
Same score, rejected, and action-mismatch is the only error code. The action alone sank it. (That's a demo page, so treat 0.9 as one observation, not a promise. Your target's own threshold decides.)
Why a valid token still gets rejected
- Action mismatch. As above. Copy the string exactly, including case and slashes.
-
Expired or reused. Google reports this as
timeout-or-duplicate. One solve per request, submitted inside two minutes. -
User-Agent mismatch on Enterprise. Send the
user_agentthat came back with the token. -
Wrong type. v3 parameters won't help against a v2 invisible widget. When
in.phpcan tell the key belongs to another reCAPTCHA type, it answersERROR_WRONG_KEY_TYPE. - The site's threshold. If the action, timing and User-Agent are right and it still fails, the backend wanted a higher score, or an Enterprise site is applying its own rules on hostname, IP or session. That's a reputation problem, covered in why your reCAPTCHA v3 score is low.
And the errors the API itself returns:
| Code | Meaning | Fix |
|---|---|---|
ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST
|
bad API key | copy it again from the dashboard |
ERROR_ZERO_BALANCE |
not enough balance or threads | top up, or lower concurrency |
ERROR_PAGEURL, ERROR_WRONG_SITEKEY
|
page URL missing, sitekey malformed | re-extract both from the page |
CAPCHA_NOT_READY |
still solving | poll again in 5 seconds |
ERROR_CAPTCHA_UNSOLVABLE |
the solve failed | check type and parameters, resubmit |
FAQ
Can I ask for a 0.9 score? No. There is no min_score parameter. The site decides what score it accepts.
Is v3 Enterprise a different method? No. It's the same method=userrecaptcha and version=v3, plus enterprise=1. Read result and user_agent from the answer.
Do I need a proxy? No, leave it out. Our proxy guide lists reCAPTCHA v3 as not supported with proxies, so send v3 and v3 Enterprise tasks without proxy/proxytype.
I'm Bassem, founder of CaptchaAI, the ocr.captchaai.com endpoint in the script. It solves reCAPTCHA v3 and v3 Enterprise through the 2Captcha-compatible API, so an existing 2Captcha client moves over by changing the base URL. Enterprise sites can still reject a valid token on their own rules, so test against your real target before you build on it. A free thread is enough for that: one thread for 30 days, no card. The full parameter list is in the v3 Enterprise docs.
Top comments (0)