Your solver returned a valid reCAPTCHA v2 token. You put it into the hidden g-recaptcha-response textarea, and nothing happens. The submit button stays disabled, or the form posts and the server replies "please complete the captcha". The usual cause is a reCAPTCHA v2 callback: the site doesn't watch the textarea, it waits for reCAPTCHA to call one of its own JavaScript functions with the token. This post shows how to find the callback function (it hides in one of three places) and how to trigger it from Playwright or Selenium after you inject the token.
Why filling g-recaptcha-response does nothing
When a real user passes the widget, reCAPTCHA does two things:
- It writes the token into the
g-recaptcha-responsetextarea. Each widget on the page has its own. - If the site registered a callback, it calls that function with the token.
Injecting the token only does step 1. On a plain form that posts the textarea, that's enough. On a callback page, step 2 is where the site's logic lives. Typical callbacks:
- submit the form:
function onSubmit(token) { document.getElementById("demo-form").submit(); }, the pattern from Google's invisible reCAPTCHA docs - enable a submit button that was disabled until now
- send the token to an API with
fetch() - store the token in React or Vue component state, so the form posts from state and never reads the textarea
Nothing fires those for you. Invisible reCAPTCHA v2 nearly always works this way: there is no checkbox, so the callback is how the site learns the check has finished.
Two limits apply whatever you do. A token is valid for two minutes and can be verified only once. Solve right before you trigger, and never reuse a token.
How to find the reCAPTCHA callback function
Open DevTools on the page with the widget. There are three places to look, from quickest to most reliable.
1. The data-callback attribute. Declarative widgets name the function in the HTML:
<div class="g-recaptcha" data-sitekey="6Lc..." data-callback="onCaptchaDone"></div>
<button class="g-recaptcha" data-sitekey="6Lc..." data-callback="onSubmit">Log in</button>
The value is the name of a global function, so the call is window.onCaptchaDone(token). Search the Elements panel for data-callback.
2. The callback option of grecaptcha.render(). Sites that load api.js?onload=...&render=explicit create the widget in code:
grecaptcha.render("captcha-box", {
sitekey: "6Lc...",
callback: (token) => submitLogin(token),
});
Search all scripts (Ctrl+Shift+F in the Sources panel) for grecaptcha.render. If the callback is an anonymous function inside a bundled module, you can't call it by name. The third method handles that.
3. The ___grecaptcha_cfg.clients object. reCAPTCHA keeps every rendered widget's settings in this global. Each client holds a parameters object with sitekey, size and callback, usually two levels down, under minified names that change between reCAPTCHA releases (for example ___grecaptcha_cfg.clients[0].aa.l.callback). So don't hard-code the path. Walk the object and stop at anything that has a sitekey. The callback you find there is either the function itself or the global name from data-callback, so this works for all three setups, including React wrappers.
Save this as find-callbacks.js:
() => {
const cfg = window.___grecaptcha_cfg;
const found = [];
if (!cfg || !cfg.clients) return found;
const seen = new Set();
const walk = (obj, keys, depth) => {
if (!obj || typeof obj !== "object" || depth > 4 || seen.has(obj)) return;
if (obj instanceof Node || obj === window) return; // skip DOM nodes
seen.add(obj);
if (typeof obj.sitekey === "string") {
const cb = obj.callback;
found.push({
keys, // path from clients to the params object
sitekey: obj.sitekey,
// "invisible" means send invisible=1; button-bound widgets store no size
size: obj.size || (obj.bind ? "invisible" : null),
callbackType: typeof cb, // "function", "string" or "undefined"
callbackName: typeof cb === "string" ? cb : (cb && cb.name) || null,
});
return;
}
for (const [k, v] of Object.entries(obj)) walk(v, [...keys, k], depth + 1);
};
for (const [id, client] of Object.entries(cfg.clients)) walk(client, [id], 0);
return found;
}
You can paste it into the console as (...)() to check a page by hand. A callbackType of "undefined" means that widget has no callback: fill the textarea and submit the form yourself.
reCAPTCHA v3 (api.js?render=KEY) registers a client here too, with an id of 100000 or higher, size: "invisible" and no callback, so it can pass for an invisible v2 widget. If a widget turns up that you didn't expect, check which reCAPTCHA variant you're dealing with first, because v3 needs a different request.
Get the token from the solver
A callback page uses the same solver request as any reCAPTCHA v2 widget: method=userrecaptcha, the sitekey as googlekey, and the pageurl where the widget loaded. Add invisible=1 for invisible widgets. It is required, and a token solved without it can be rejected. This is the 2Captcha-compatible in.php/res.php API, so if you're coming from 2Captcha the parameters are identical (the migration post has the diff).
Prerequisites: Python 3.9+, pip install requests, plus playwright or selenium. Save as solver.py next to the two JS files:
import time
from pathlib import Path
import requests
API_KEY = "YOUR_API_KEY"
FIND_JS = Path("find-callbacks.js").read_text()
TRIGGER_JS = Path("trigger-callback.js").read_text()
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}
def solve_recaptcha_v2(sitekey, pageurl, invisible=False, timeout=120):
if not API_KEY or API_KEY == "YOUR_API_KEY":
raise SystemExit("Set API_KEY to your key first")
params = {"key": API_KEY, "method": "userrecaptcha",
"googlekey": sitekey, "pageurl": pageurl, "json": 1}
if invisible:
params["invisible"] = 1
r = requests.get("https://ocr.captchaai.com/in.php", params=params, timeout=30).json()
if r["status"] != 1: # e.g. ERROR_WRONG_SITEKEY, ERROR_ZERO_BALANCE
raise RuntimeError(f"in.php: {r['request']}")
task_id = r["request"]
time.sleep(15)
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get("https://ocr.captchaai.com/res.php", timeout=30, params={
"key": API_KEY, "action": "get", "id": task_id, "json": 1}).json()
if r["status"] == 1:
return r["request"]
if r["request"] in TRANSIENT:
time.sleep(10)
continue
if r["request"] != "CAPCHA_NOT_READY": # e.g. ERROR_CAPTCHA_UNSOLVABLE
raise RuntimeError(f"res.php: {r['request']}")
time.sleep(5)
raise TimeoutError(f"task {task_id} not solved in {timeout}s")
def pick_widget(widgets):
if not widgets:
raise RuntimeError("no reCAPTCHA widget found on this page")
return next((w for w in widgets if w["callbackType"] != "undefined"), widgets[0])
Trigger the callback after injecting the token
The trigger does both steps a real solve does, in the same order. Filling the textarea covers callbacks that call form.submit(), since that post carries the textarea. Save as trigger-callback.js:
([token, keys]) => {
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
.forEach((t) => { t.value = token; });
// look the callback up again: functions can't travel back to Python
const params = keys.reduce((o, k) => (o ? o[k] : undefined), window.___grecaptcha_cfg.clients);
let cb = params && params.callback;
if (typeof cb === "string") cb = cb.split(".").reduce((o, k) => (o ? o[k] : undefined), window);
if (typeof cb !== "function") return "no-callback";
setTimeout(() => cb(token), 0); // async, so evaluate() returns before any navigation
return "called";
}
Playwright:
from playwright.sync_api import sync_playwright
from solver import FIND_JS, TRIGGER_JS, pick_widget, solve_recaptcha_v2
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/login")
page.wait_for_function("() => Object.keys(window.___grecaptcha_cfg?.clients || {}).length > 0")
w = pick_widget(page.evaluate(FIND_JS))
print("widget:", w)
token = solve_recaptcha_v2(w["sitekey"], page.url, invisible=w["size"] == "invisible")
outcome = page.evaluate(TRIGGER_JS, [token, w["keys"]])
print("trigger:", outcome)
if outcome == "no-callback":
page.click("form [type=submit]")
page.wait_for_url("**/dashboard", timeout=15000) # your success signal
browser.close()
Selenium uses the same two JS files:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from solver import FIND_JS, TRIGGER_JS, pick_widget, solve_recaptcha_v2
driver = webdriver.Chrome()
driver.get("https://example.com/login")
WebDriverWait(driver, 30).until(lambda d: d.execute_script(
"return Object.keys(window.___grecaptcha_cfg?.clients || {}).length > 0"))
w = pick_widget(driver.execute_script(f"return ({FIND_JS})();"))
token = solve_recaptcha_v2(w["sitekey"], driver.current_url, invisible=w["size"] == "invisible")
outcome = driver.execute_script(f"return ({TRIGGER_JS})(arguments[0]);", [token, w["keys"]])
print("trigger:", outcome)
if outcome == "no-callback":
driver.find_element(By.CSS_SELECTOR, "form [type=submit]").click()
Expected output on a callback page:
widget: {'keys': ['0', 'aa', 'l'], 'sitekey': '6Lc...', 'size': 'invisible', 'callbackType': 'function', 'callbackName': 'onSubmit'}
trigger: called
The minified keys will differ from aa and l, which is the point of walking the object.
Troubleshooting
-
The form posts twice, or the server says the token was already used. The callback submitted the form and you clicked submit as well. Only click when the trigger returns
no-callback. -
A widget bound to a button reports
size: null. reCAPTCHA stores nosizefor ag-recaptchaclass on a button or fordata-bind, so the finder treats a setbindas invisible. If a widget you know is invisible still showsnull, passinvisible=Trueyourself. -
The submit handler checks
grecaptcha.getResponse(). That call reads the widget'sg-recaptcha-responsetextarea, so the fill in the trigger already covers it. There's no need to override it. -
The callback ran but the server rejects the token. Check that
pageurlis the page that rendered the widget (not the form's action URL), that you solved the right widget's sitekey, that invisible widgets gotinvisible=1, and that fewer than two minutes passed. -
ERROR_CAPTCHA_UNSOLVABLEfrom res.php. Resubmit once. If it repeats, re-check the sitekey and the variant.
Cloudflare Turnstile has the same trap, since many sites put a data-callback on the cf-turnstile div. The Playwright walkthrough for Turnstile covers that token flow.
FAQ
What is a reCAPTCHA v2 callback? A JavaScript function the site registers, through data-callback or grecaptcha.render(), that reCAPTCHA calls with the token when the check passes. Callback sites submit the form or call their API from inside it.
Do callback pages need a different solver method? No. The token is an ordinary reCAPTCHA v2 token from method=userrecaptcha. Only what you do with it changes. Invisible widgets still need invisible=1.
How do I find the callback when the site's JavaScript is minified? Don't look for a name. Walk ___grecaptcha_cfg.clients to the object that has a sitekey and use its callback property, which holds the function even when it has no usable name.
Disclosure: I work on CaptchaAI, whose endpoint the examples use. It solves reCAPTCHA v2, including invisible and callback widgets, and the reCAPTCHA v2 Callback guide lists the parameters. To run the code above against your own page, claim a free thread: 1 thread for 30 days, no card.
— Bassem
Top comments (0)