DEV Community

Cover image for Invisible reCAPTCHA v2: find the sitekey, solve it with invisible=1 and submit the token (Python and Node)
Bassem Shahin
Bassem Shahin

Posted on

Invisible reCAPTCHA v2: find the sitekey, solve it with invisible=1 and submit the token (Python and Node)

Invisible reCAPTCHA v2 is the version with no "I'm not a robot" box. The page shows only the small "protected by reCAPTCHA" badge, and the check runs when you click submit (or when the site calls grecaptcha.execute()). If it passes, reCAPTCHA puts a token in the form's g-recaptcha-response field and calls the site's callback. To automate a form like that you need three things: the sitekey (the 40-character key that starts with 6L), a solver request with invisible=1 next to the usual method=userrecaptcha, and the right place to put the token. On a plain HTML form that place is a POST field, so you often don't need a browser at all.

This post walks through all three with code you can run against Google's public invisible demo: a Python finder that reads the sitekey and the form from the HTML, a Playwright check for widgets that only exist after JavaScript runs, and a solve-and-submit script in Python and in Node.

How to tell an invisible widget from a checkbox or v3

An invisible v2 widget is declared in one of four ways. The first three are in the HTML; the last is in a script.

Markup Example
a div with data-size="invisible", run by grecaptcha.execute() <div class="g-recaptcha" data-sitekey="6Lc..." data-size="invisible" data-callback="onSubmit">
the g-recaptcha class on the submit button <button class="g-recaptcha" data-sitekey="6Lc..." data-callback="onSubmit">
a div bound to a button by id <div class="g-recaptcha" data-sitekey="6Lc..." data-bind="submit-btn" data-callback="onSuccess">
explicit rendering grecaptcha.render("box", { sitekey: "6Lc...", size: "invisible", callback: onToken })

Google's two public demos use the second and third forms, and neither one ever writes the word "invisible". That's why searching the page for invisible misses half the real widgets.

The thing most often mistaken for invisible v2 is reCAPTCHA v3. It also has no checkbox and shows the same badge. The difference is in the script tag and the call: v3 loads api.js?render=<sitekey> and asks for a token with grecaptcha.execute(sitekey, {action: "login"}). v3 needs version=v3 and an action, not invisible=1. If you're unsure which one you have, this comparison of v2, v3 and Enterprise has a detector for all of them. This post stays with invisible v2.

Step 1: find the sitekey and the form in the HTML

When the widget is in the HTML, you need no browser to find it. This finder uses only the standard library. For each widget it returns the sitekey, whether it's invisible, the callback name, and the form it protects: the URL it submits to, the method, and the fields a browser would send with it (disabled inputs and unchecked boxes left out, as a browser does).

"""find_invisible.py: find reCAPTCHA v2 widgets in a page's HTML and the form each one protects."""
import re
from html.parser import HTMLParser
from urllib.parse import urljoin

RENDER = re.compile(r"grecaptcha\.render\(\s*[^,]+,\s*\{([^}]*)\}", re.S)
OPT = r"""['"]?{0}['"]?\s*:\s*['"]([^'"]+)['"]"""


class WidgetFinder(HTMLParser):
    def __init__(self):
        super().__init__()
        self.forms, self.widgets, self.scripts = [], [], []
        self._form = None
        self._in_script = False

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "form":
            self._form = {"action": a.get("action") or "", "method": (a.get("method") or "get").lower(),
                          "id": a.get("id"), "fields": {}}
            self.forms.append(self._form)
        elif tag in ("input", "textarea", "select") and self._form is not None:
            if a.get("name") and "disabled" not in a and a.get("type") not in ("submit", "button", "image"):
                if a.get("type") in ("checkbox", "radio") and "checked" not in a:
                    return
                self._form["fields"][a["name"]] = a.get("value") or ""
        elif tag == "script":
            self._in_script = True
        if "data-sitekey" in a:
            invisible = (a.get("data-size") == "invisible"   # div + grecaptcha.execute()
                         or tag == "button"                    # <button class="g-recaptcha">
                         or "data-bind" in a)                  # div bound to a button id
            self.widgets.append({"sitekey": a["data-sitekey"], "invisible": invisible,
                                 "callback": a.get("data-callback"), "form": self._form})

    def handle_endtag(self, tag):
        if tag == "form":
            self._form = None
        elif tag == "script":
            self._in_script = False

    def handle_data(self, data):
        if self._in_script:
            self.scripts.append(data)


def find_widgets(html, page_url):
    p = WidgetFinder()
    p.feed(html)
    widgets = p.widgets
    # explicit rendering: grecaptcha.render(el, {sitekey: "...", size: "invisible"})
    for body in RENDER.findall("\n".join(p.scripts)):
        key = re.search(OPT.format("sitekey"), body)
        if key:
            size = re.search(OPT.format("size"), body)
            widgets.append({"sitekey": key.group(1), "callback": None, "form": None,
                            "invisible": bool(size and size.group(1) == "invisible")})
    for w in widgets:
        form = w.pop("form") or (p.forms[0] if len(p.forms) == 1 else None)
        w["submit_url"] = urljoin(page_url, form["action"]) if form else None
        w["method"] = form["method"] if form else None
        w["fields"] = dict(form["fields"]) if form else {}
    return widgets
Enter fullscreen mode Exit fullscreen mode

On the two demos and a checkbox page for comparison:

import requests
from find_invisible import find_widgets

for url in ["https://www.google.com/recaptcha/api2/demo?invisible=true",
            "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php",
            "https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php"]:
    html = requests.get(url, timeout=30).text
    print(find_widgets(html, url))
Enter fullscreen mode Exit fullscreen mode
[{'sitekey': '6LfP0CITAAAAAHq9FOgCo7v_fb0-pmmH9VW3ziFs', 'invisible': True, 'callback': 'onSuccess', 'submit_url': 'https://www.google.com/recaptcha/api2/demo?invisible=true', 'method': 'post', 'fields': {}}]
[{'sitekey': '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', 'invisible': True, 'callback': 'onSubmit', 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php', 'method': 'post', 'fields': {'ex-a': 'foo', 'ex-b': 'bar'}}]
[{'sitekey': '6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9', 'invisible': False, 'callback': None, 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php', 'method': 'post', 'fields': {'ex-a': 'foo', 'ex-b': 'bar'}}]
Enter fullscreen mode Exit fullscreen mode

The Google demo's fields is empty because every input on that form is disabled, so a browser sends only the token. A widget created by grecaptcha.render() has no element to attach it to a form, so the finder falls back to the page's only form when there is just one. With several forms, check by hand.

When the HTML has no sitekey: read the anchor iframe

Single-page apps and bundled sites often create the widget from JavaScript that a regex won't find. Let the page render, then read the widget's anchor iframe. Every rendered key gets one, and its URL carries the sitekey as k= and the widget type as size=:

// anchors.mjs: list every reCAPTCHA widget a page actually rendered. npm i playwright
import { chromium } from "playwright";

const url = process.argv[2];
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "load" });
await page.waitForSelector('iframe[src*="/anchor"]', { state: "attached", timeout: 15_000 });
await page.waitForTimeout(2_000); // give late widgets a moment

for (const frame of page.frames()) {
  const u = new URL(frame.url());
  if (!u.pathname.endsWith("/anchor")) continue;
  console.log({
    sitekey: u.searchParams.get("k"),
    size: u.searchParams.get("size"), // "invisible" for v2 invisible AND for v3
    enterprise: u.pathname.includes("/enterprise/"),
  });
}
await browser.close();
Enter fullscreen mode Exit fullscreen mode
$ node anchors.mjs "https://www.google.com/recaptcha/api2/demo?invisible=true"
{
  sitekey: '6LfP0CITAAAAAHq9FOgCo7v_fb0-pmmH9VW3ziFs',
  size: 'invisible',
  enterprise: false
}

$ node anchors.mjs https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php
{
  sitekey: '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-',
  size: 'invisible',
  enterprise: true
}
{
  sitekey: '6Le80pApAAAAANg24CMbhL_U2PASCW_JUnq5jPys',
  size: 'invisible',
  enterprise: true
}
Enter fullscreen mode Exit fullscreen mode

The second run shows two traps. First, that demo loads a second key, an Enterprise score key that isn't part of the form, and it reports size: 'invisible' too, because v3 and score keys always do. Pick the key the form's widget uses (the one in the HTML, or the one tied to the submit request), not the first anchor you see.

Second, the form's own key came back with enterprise: true, yet the page loads it through the classic api.js. In an earlier run of the same script the same day, the same key was served from /recaptcha/api2/anchor and reported false. A page that also loads enterprise.js can do that. So use the anchor path as a hint only: decide Enterprise by whether the key is used through enterprise.js and grecaptcha.enterprise, which the comparison post covers. For this demo the answer is a standard invisible v2 key.

Step 2: solve it with invisible=1

The solve is a normal reCAPTCHA v2 task on the 2Captcha-style in.php/res.php API:

Parameter Value
method userrecaptcha
googlekey the sitekey from step 1
pageurl the full URL of the page that shows the widget, not the form's action URL
invisible 1. The docs mark it as required for invisible widgets: without it the solve can fail
json 1, so the answers are JSON

You get a task id back, wait about 15 seconds, then poll res.php every 5 seconds until the token arrives. Server errors are retried with a backoff capped at 30 seconds. Account and input errors (ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE, ERROR_PAGEURL) stop at once, because retrying won't fix them.

Step 3: post the token with the form

This script ties the steps together. It loads the page, takes the first invisible widget, solves it and posts the form's fields plus g-recaptcha-response to the form's action, which is what the browser would have done after the callback fired. Prerequisites: Python 3.9+ and pip install requests, with find_invisible.py in the same folder.

# solve_invisible.py: solve an invisible reCAPTCHA v2 and post the form, no browser.
# Needs: pip install requests   (find_invisible.py from above sits next to it)
import os
import re
import sys
import time

import requests

from find_invisible import find_widgets

API = "https://ocr.captchaai.com"  # 2Captcha-style in.php / res.php
API_KEY = os.environ.get("CAPTCHAAI_KEY", "YOUR_API_KEY")
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36")
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


def call(path, **kw):
    """One API call; network errors and non-JSON replies come back as a transient error."""
    try:
        return requests.request(timeout=30, url=f"{API}/{path}", **kw).json()
    except (requests.RequestException, ValueError) as exc:
        return {"status": 0, "request": "ERROR_SERVER_ERROR", "detail": str(exc)}


def solve_invisible(sitekey, pageurl, timeout=180):
    if API_KEY in ("", "YOUR_API_KEY"):
        raise SystemExit("Set CAPTCHAAI_KEY to your API key first")
    if not re.fullmatch(r"6L[\w-]{38}", sitekey):
        raise SystemExit(f"{sitekey!r} doesn't look like a reCAPTCHA sitekey")
    params = {"key": API_KEY, "method": "userrecaptcha", "googlekey": sitekey,
              "pageurl": pageurl, "invisible": 1, "json": 1}
    for attempt in range(3):
        r = call("in.php", method="POST", data=params)
        if r["status"] == 1:
            break
        if r["request"] not in TRANSIENT:  # ERROR_WRONG_USER_KEY, ERROR_ZERO_BALANCE,
            raise RuntimeError(f"in.php: {r['request']}")  # ERROR_PAGEURL, ...
        time.sleep(5 * 2 ** attempt)
    else:
        raise RuntimeError("in.php: server errors on 3 tries")

    task_id, started, wait = r["request"], time.time(), 5
    time.sleep(15)  # nothing is ready before ~15 s
    while time.time() - started < timeout:
        r = call("res.php", method="GET", params={
            "key": API_KEY, "action": "get", "id": task_id, "json": 1})
        if r["status"] == 1:
            return r["request"]
        if r["request"] in TRANSIENT:
            wait = min(wait * 2, 30)  # back off on server trouble
        elif r["request"] == "CAPCHA_NOT_READY":
            wait = 5
        else:  # ERROR_CAPTCHA_UNSOLVABLE, ERROR_WRONG_CAPTCHA_ID, ...
            raise RuntimeError(f"res.php: {r['request']}")
        time.sleep(wait)
    raise TimeoutError(f"task {task_id} not solved in {timeout}s")


if __name__ == "__main__":
    page = sys.argv[1] if len(sys.argv) > 1 else \
        "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php"
    if not page.startswith(("http://", "https://")):
        sys.exit("Pass the full URL of the page that shows the widget")
    s = requests.Session()
    s.headers["User-Agent"] = UA
    widgets = [w for w in find_widgets(s.get(page, timeout=30).text, page) if w["invisible"]]
    if not widgets:
        sys.exit("No invisible widget in the HTML: it is a checkbox, or a script renders it (see below).")
    w = widgets[0]
    print("widget:", {k: w[k] for k in ("sitekey", "callback", "submit_url")})

    token = sys.argv[2] if len(sys.argv) > 2 else solve_invisible(w["sitekey"], page)
    print("token:", token[:20] + "...")
    # what the browser would have posted: the form's fields plus the token
    data = {**w["fields"], "g-recaptcha-response": token}
    if w["method"] == "post":
        reply = s.post(w["submit_url"], data=data, timeout=30)
    else:  # a GET form sends its fields in the query string
        reply = s.get(w["submit_url"], params=data, timeout=30)
    print("demo backend:", re.findall(r"'(success|hostname)' => '?([\w.-]+)", reply.text),
          re.findall(r"\d+ => '([a-z-]+)'", reply.text))
Enter fullscreen mode Exit fullscreen mode

The demo backend prints the result of its own siteverify call, so the last line shows Google's verdict. With a solved token:

$ CAPTCHAAI_KEY=... python solve_invisible.py
widget: {'sitekey': '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', 'callback': 'onSubmit', 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php'}
token: 0cAFcWeA4ZrE7MhWVePD...
demo backend: [('success', 'true'), ('hostname', 'recaptcha-demo.appspot.com')] []
Enter fullscreen mode Exit fullscreen mode

You can test the form half without spending a solve: an optional second argument is used as the token. A fake token shows what a rejection looks like:

$ python solve_invisible.py https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php 03AFcWeA-not-a-real-token
widget: {'sitekey': '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-', 'callback': 'onSubmit', 'submit_url': 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php'}
token: 03AFcWeA-not-a-real-...
demo backend: [('success', 'false'), ('hostname', 'NULL')] ['invalid-input-response', 'hostname-mismatch']
Enter fullscreen mode Exit fullscreen mode

On your own target there's no siteverify printout. Use the site's own success signal instead: a redirect, a session cookie, or the absence of its "please complete the captcha" message.

The same flow in Node.js

Node 18+ has fetch built in, so this needs no packages. The finder is a regex version of the Python one: good for triage on server-rendered pages, and the anchor check above covers the rest.

// solve-invisible.mjs: the same flow in Node 18+ (built-in fetch, no packages)
const API = "https://ocr.captchaai.com";
const API_KEY = process.env.CAPTCHAAI_KEY ?? "YOUR_API_KEY";
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
  "(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
const TRANSIENT = new Set(["ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const attr = (tag, name) => tag.match(new RegExp(`\\s${name}=["']([^"']*)["']`, "i"))?.[1];

async function call(path, init = {}) {
  try {
    const res = await fetch(`${API}/${path}`, { ...init, signal: AbortSignal.timeout(30_000) });
    return await res.json();
  } catch (err) { // network error or non-JSON reply: treat as transient
    return { status: 0, request: "ERROR_SERVER_ERROR", detail: err.message };
  }
}

// Regex triage, like find_invisible.py: invisible widgets declared in the HTML
function findInvisible(html, pageUrl) {
  const out = [];
  for (const m of html.matchAll(/<(\w+)\b[^>]*\bdata-sitekey=[^>]*>/gi)) {
    const tag = m[0];
    const invisible = attr(tag, "data-size") === "invisible" ||
      m[1].toLowerCase() === "button" || /\bdata-bind=/i.test(tag);
    if (!invisible) continue;
    const start = html.lastIndexOf("<form", m.index);
    const formTag = start >= 0 ? html.slice(start, html.indexOf(">", start) + 1) : "";
    const formBody = start >= 0 ? html.slice(start, html.indexOf("</form>", start)) : "";
    const fields = {};
    for (const [input] of formBody.matchAll(/<input\b[^>]*>/gi)) {
      const name = attr(input, "name");
      if (name && !/\bdisabled\b/i.test(input) && !/type=["'](submit|button)/i.test(input))
        fields[name] = attr(input, "value") ?? "";
    }
    out.push({
      sitekey: attr(tag, "data-sitekey"),
      callback: attr(tag, "data-callback") ?? null,
      submitUrl: new URL(attr(formTag, "action") || pageUrl, pageUrl).href,
      method: (attr(formTag, "method") || "get").toUpperCase(),
      fields,
    });
  }
  return out;
}

async function solveInvisible(sitekey, pageurl, timeoutMs = 180_000) {
  if (!API_KEY || API_KEY === "YOUR_API_KEY") throw new Error("Set CAPTCHAAI_KEY first");
  if (!/^6L[\w-]{38}$/.test(sitekey)) throw new Error(`${sitekey} isn't a reCAPTCHA sitekey`);
  const params = new URLSearchParams({ key: API_KEY, method: "userrecaptcha",
    googlekey: sitekey, pageurl, invisible: "1", json: "1" });
  let r;
  for (let attempt = 0; ; attempt++) {
    r = await call("in.php", { method: "POST", body: params });
    if (r.status === 1) break;
    if (!TRANSIENT.has(r.request) || attempt === 2) throw new Error(`in.php: ${r.request}`);
    await sleep(5_000 * 2 ** attempt);
  }
  const id = r.request, started = Date.now();
  let wait = 5_000;
  await sleep(15_000);
  while (Date.now() - started < timeoutMs) {
    r = await call(`res.php?${new URLSearchParams({ key: API_KEY, action: "get", id, json: "1" })}`);
    if (r.status === 1) return r.request;
    if (TRANSIENT.has(r.request)) wait = Math.min(wait * 2, 30_000);
    else if (r.request === "CAPCHA_NOT_READY") wait = 5_000;
    else throw new Error(`res.php: ${r.request}`); // ERROR_CAPTCHA_UNSOLVABLE, ...
    await sleep(wait);
  }
  throw new Error(`task ${id} not solved in ${timeoutMs / 1000}s`);
}

const page = process.argv[2] ?? "https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php";
if (!/^https?:\/\//.test(page)) throw new Error("Pass the full URL of the page that shows the widget");
const html = await (await fetch(page, { headers: { "User-Agent": UA } })).text();
const [w] = findInvisible(html, page);
if (!w) throw new Error("No invisible widget in the HTML: a checkbox, or rendered by a script");
console.log("widget:", { sitekey: w.sitekey, callback: w.callback, submitUrl: w.submitUrl });

const token = process.argv[3] ?? await solveInvisible(w.sitekey, page);
const data = new URLSearchParams({ ...w.fields, "g-recaptcha-response": token });
const reply = w.method === "POST"
  ? await fetch(w.submitUrl, { method: "POST", headers: { "User-Agent": UA }, body: data })
  : await fetch(`${w.submitUrl.split("?")[0]}?${data}`, { headers: { "User-Agent": UA } });
const body = await reply.text();
console.log("demo backend:", [...body.matchAll(/'(success|hostname)' => '?([\w.-]+)/g)].map((m) => [m[1], m[2]]),
  [...body.matchAll(/\d+ => '([a-z-]+)'/g)].map((m) => m[1]));
Enter fullscreen mode Exit fullscreen mode
$ node solve-invisible.mjs https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php 03AFcWeA-not-a-real-token
widget: {
  sitekey: '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-',
  callback: 'onSubmit',
  submitUrl: 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php'
}
demo backend: [ [ 'success', 'false' ], [ 'hostname', 'NULL' ] ] [ 'invalid-input-response', 'hostname-mismatch' ]
Enter fullscreen mode Exit fullscreen mode

With CAPTCHAAI_KEY set and no second argument, it solves and posts a real token like the Python script:

$ CAPTCHAAI_KEY=... node solve-invisible.mjs
widget: {
  sitekey: '6LcmDCcUAAAAAL5QmnMvDFnfPTP4iCUYRk2MwC0-',
  callback: 'onSubmit',
  submitUrl: 'https://recaptcha-demo.appspot.com/recaptcha-v2-invisible.php'
}
demo backend: [ [ 'success', 'true' ], [ 'hostname', 'recaptcha-demo.appspot.com' ] ] []
Enter fullscreen mode Exit fullscreen mode

When the token doesn't go in a form field

Posting g-recaptcha-response works when the callback only submits the form, as on both demos. Three other setups are common:

  • The callback does the work. It enables a button, stores the token in React state or calls an API. In a browser you have to call the callback yourself after you fill the textarea. This post on reCAPTCHA v2 callbacks shows how to find it, even when the bundle is minified, and trigger it from Playwright or Selenium.
  • The token travels in an XHR. Open DevTools, submit once by hand and look at the request in the Network tab. The token may sit in a JSON body under a name like captcha or recaptchaToken. Send it in that request, with the same headers and cookies.
  • The site runs execute() on page load. It fetches a token as soon as the page opens and sends it later. Tokens are valid for two minutes and can be verified only once, so solve right before you send the request rather than when you load the page.

Troubleshooting

  • invalid-input-response with a real token. Most often the token expired or was already used. Next, check that pageurl is the page that shows the widget and that you solved the key the form uses, not the extra key the second anchor run found.
  • The solve works but the site still says "verify you are human". The token went to the wrong place. Compare your request with the browser's request in the Network tab: field name, endpoint and cookies.
  • ERROR_PAGEURL or ERROR_WRONG_SITEKEY from in.php. The page URL is missing or not a full URL, or the sitekey is wrong. Copy both from the page again.
  • ERROR_CAPTCHA_UNSOLVABLE from res.php. Submit once more. If it repeats, check the variant: a v3 key sent as invisible v2 won't produce a token the site accepts.
  • The finder returns nothing on a page that clearly has the badge. Either the widget is rendered by a script (use the anchor check) or it's v3 (api.js?render=...).

FAQ

What is invisible reCAPTCHA v2?
A reCAPTCHA v2 widget with no checkbox. It runs when the user submits the form or the site calls grecaptcha.execute(). Most visitors never see a challenge, and a suspicious one gets the image puzzle. Like the checkbox, it hands the site a token in g-recaptcha-response to verify server-side.

What's the difference between invisible reCAPTCHA v2 and v3?
Invisible v2 returns a pass/fail token and can still show an image challenge. v3 never shows a challenge. It returns a token the site's backend scores from 0.0 to 1.0, tied to an action name. They need different solver parameters: invisible=1 for v2, version=v3 plus the action for v3.

Is invisible=1 really needed?
Yes. The API docs mark it as required for invisible widgets and say a solve without it can fail. Send invisible=1 whenever the widget is invisible, and leave it out for checkbox widgets.

What does "this site key is not enabled for the invisible captcha" mean?
Google shows that error when a key registered for the checkbox type is rendered with size: "invisible". The site chooses the key type when it registers the key, so treat that key as a checkbox key and leave out invisible=1.


Disclosure: I work on CaptchaAI, whose endpoint the examples use. It solves reCAPTCHA v2 Invisible with the parameters above, and the reCAPTCHA v2 Invisible guide lists them. To run the scripts against your own page, claim a free thread: 1 thread for 30 days, no card.

— Bassem

Top comments (0)