Handle CAPTCHA in Playwright, solve hCaptcha.

A Playwright script that hits a CAPTCHA has three things it can do: work out which one it is, stop triggering it so often, or get a valid token from outside the browser and hand it to the form. If the page runs hCaptcha, that last option is four steps: read the data-sitekey off the page, ask the NoneCap hCaptcha solver for a real token, inject it into the form's h-captcha-response field, then submit. It's the same four steps in Python and in Node, and the code is below.

First, identify which CAPTCHA you are facing

People describe all of these as “the captcha”, but they are five different products from four different vendors, with different response fields and different answers to “can I automate past this”. Playwright can tell them apart in a couple of selector checks. Each one leaves a distinctive container element, a distinctive hidden response field, and an iframe from its vendor’s domain.

What each CAPTCHA leaves in the page, and what NoneCap covers
CAPTCHAWhat Playwright can see in the DOMSolved by NoneCap
hCaptcha.h-captcha[data-sitekey], a textarea[name="h-captcha-response"] in the form, and an iframe served from hcaptcha.comYes: checkbox, invisible, and enterprise rqdata
reCAPTCHA v2.g-recaptcha[data-sitekey], a textarea[name="g-recaptcha-response"], and an iframe whose src contains recaptcha/api2/anchorNo, roadmap only
reCAPTCHA v3No widget at all. window.grecaptcha.execute is a function and a floating badge is usually pinned to a page cornerNo, roadmap only
Cloudflare Turnstile.cf-turnstile, an input[name="cf-turnstile-response"], and an iframe from challenges.cloudflare.comNo, roadmap only
FunCaptcha (Arkose)A container such as #arkose or #funcaptcha, an iframe from arkoselabs.com, and an fc-token valueNo, roadmap only

Two things trip people up in that table. reCAPTCHA v3 has no widget to find, so a DOM query returns nothing even though the page is protected; you have to ask the page whether grecaptcha.execute exists. And hCaptcha in drop-in mode reuses reCAPTCHA’s old g-recaptcha-response field name, so finding that field is not proof you are looking at reCAPTCHA. Check the container class and the iframe origin before you conclude anything.

Python · detect_captcha(page)
from playwright.sync_api import Page, sync_playwright

WIDGETS = [
    ("hcaptcha", ".h-captcha[data-sitekey], iframe[src*='hcaptcha.com']"),
    ("recaptcha_v2", ".g-recaptcha[data-sitekey], iframe[src*='recaptcha/api2/anchor']"),
    ("turnstile", ".cf-turnstile, iframe[src*='challenges.cloudflare.com']"),
    ("funcaptcha", "#arkose, #funcaptcha, iframe[src*='arkoselabs.com']"),
]

def detect_captcha(page: Page) -> str | None:
    for name, selector in WIDGETS:
        if page.locator(selector).count() > 0:
            return name
    # v3 renders no widget, so ask the page instead of the DOM.
    has_v3 = page.evaluate(
        "() => typeof window.grecaptcha?.execute === 'function'"
        " && !document.querySelector('.g-recaptcha[data-sitekey]')"
    )
    return "recaptcha_v3" if has_v3 else None

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://target.example/signup")
    page.wait_for_timeout(1500)   # widgets load in their own iframes, give them a beat
    print(detect_captcha(page))
    browser.close()

Give the page a moment before you run this. Every one of these widgets loads asynchronously into its own iframe, so a check that fires the instant goto resolves will report nothing on a page that very much has a captcha. Waiting for a specific selector is tidier than a fixed timeout when you already know what you are expecting.

If it is hCaptcha, read on. If it is reCAPTCHA, Turnstile or FunCaptcha, skip to what NoneCap does not solve.

Solve hCaptcha in Playwright (Python & Node)

Don't try to beat the challenge inside your automated browser. The form only checks that a valid token is in the response field when you submit; it has no way to know how the token was produced. So you solve it out of band. NoneCap solves it separately, off your machine, then returns a normal P1_ token. Your Playwright script never touches the challenge UI. It just supplies the token the page was already waiting for.

The four steps

Every integration, regardless of language, is the same shape:

  • 1. Read the data-sitekey and the page url.
  • 2. Solve by calling POST /v1/solves (block with ?wait=N, or submit asynchronously and poll the returned solve id).
  • 3. Inject the returned token into textarea[name="h-captcha-response"] via page.evaluate, and fire the widget callback if there is one.
  • 4. Submit the form the way your page does.

Python (Playwright)

Using the sync API and requests for the NoneCap call:

Python · Playwright
import os
import time
import requests
from playwright.sync_api import sync_playwright

API = "https://api.nonecap.com/v1/solves"
HEADERS = {"Authorization": f"Bearer {os.environ['NONECAP_KEY']}"}

def solve_hcaptcha(sitekey: str, url: str) -> str:
    r = requests.post(
        API,
        headers=HEADERS,
        params={"wait": 90},                 # block up to 90s for the token
        json={"type": "hcaptcha", "sitekey": sitekey, "url": url},
        timeout=120,
    )
    r.raise_for_status()
    data = r.json()
    # ?wait is a long poll with a deadline: the solve can still be in flight
    # when it returns, so keep polling until the status is terminal.
    while data["status"] in ("pending", "solving"):
        time.sleep(2)
        data = requests.get(f"{API}/{data['id']}", headers=HEADERS, timeout=30).json()
    if data["status"] != "solved":
        raise RuntimeError(f"solve {data['status']}: {data.get('error')}")
    return data["token"]                     # a real P1_… token

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://target.example/login")

    # 1. Read the sitekey straight off the rendered widget.
    sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")

    # 2. Mint a real token out-of-band with NoneCap.
    token = solve_hcaptcha(sitekey, page.url)

    # 3. Inject the hCaptcha token into h-captcha-response. hCaptcha's drop-in
    #    mode reuses reCAPTCHA's old g-recaptcha-response field name, so we set
    #    it too if present, it's still the hCaptcha token, not a reCAPTCHA solve.
    page.evaluate(
        """(token) => {
            for (const name of ['h-captcha-response', 'g-recaptcha-response']) {
                const el = document.querySelector(`textarea[name="${name}"]`);
                if (el) el.value = token;
            }
            // Invisible widgets submit from data-callback, fire it if present.
            const cb = document.querySelector('[data-callback]')?.getAttribute('data-callback');
            if (cb && typeof window[cb] === 'function') window[cb](token);
        }""",
        token,
    )

    # 4. Submit the form however your page does it.
    page.click("button[type=submit]")
    page.wait_for_load_state("networkidle")
    browser.close()

The ?wait=90 parameter is a long poll with a deadline, not a guarantee. If the solve is still running when the deadline passes you get a 202 back with a null token and a status of pending or solving, which is why the helper above keeps polling GET /v1/solves/{id} until the status is terminal. Skipping that loop is the most common way this integration breaks under load: it works in testing and then returns None on the busy run.

Node (Playwright)

The same flow in JavaScript with fetch and the Playwright Node client:

Node · Playwright
import { chromium } from "playwright";

const API = "https://api.nonecap.com/v1/solves";
const HEADERS = { Authorization: `Bearer ${process.env.NONECAP_KEY}` };

async function solveWithNoneCap(sitekey, url) {
  const res = await fetch(`${API}?wait=90`, {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ type: "hcaptcha", sitekey, url }),
  });
  let data = await res.json();
  while (data.status === "pending" || data.status === "solving") {
    await new Promise((r) => setTimeout(r, 2000));
    data = await (await fetch(`${API}/${data.id}`, { headers: HEADERS })).json();
  }
  if (data.status !== "solved") {
    throw new Error(`solve ${data.status}: ${JSON.stringify(data.error)}`);
  }
  return data.token; // a real P1_… token
}

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://target.example/login");

// 1. Read the sitekey straight off the rendered widget.
const sitekey = await page
  .locator("[data-sitekey]")
  .first()
  .getAttribute("data-sitekey");

// 2. Mint a real token out-of-band with NoneCap.
const token = await solveWithNoneCap(sitekey, page.url());

// 3. Inject the hCaptcha token into h-captcha-response. hCaptcha's drop-in
//    mode reuses reCAPTCHA's old g-recaptcha-response field name, so we set it
//    too if present, it's still the hCaptcha token, not a reCAPTCHA solve.
await page.evaluate((token) => {
  for (const name of ["h-captcha-response", "g-recaptcha-response"]) {
    const el = document.querySelector(`textarea[name="${name}"]`);
    if (el) el.value = token;
  }
  const cb = document
    .querySelector("[data-callback]")
    ?.getAttribute("data-callback");
  if (cb && typeof window[cb] === "function") window[cb](token);
}, token);

// 4. Submit the form however your page does it.
await page.click("button[type=submit]");
await page.waitForLoadState("networkidle");
await browser.close();

hCaptcha checkbox vs invisible vs enterprise

The request you send NoneCap depends on which kind of hCaptcha the page renders. The injection step is identical; what changes is the type you send and, for invisible widgets, whether you need to fire the callback yourself.

hCaptcha sitekey types and the matching NoneCap request
Sitekey typeHow you spot itWhat to send NoneCap
CheckboxA visible "I am human" widget renders; a textarea[name="h-captcha-response"] appears in the formtype: "hcaptcha" + sitekey + url
InvisibleNo widget; hcaptcha.execute() runs on an action and a data-callback handles the tokentype: "hcaptcha" + sitekey + url, then fire the callback
Enterprise (rqdata)The page passes a fresh rqdata blob into hcaptcha.render() / execute()type: "hcaptcha_enterprise" + sitekey + url + rqdata

Checkbox sitekeys render a visible widget and a h-captcha-response textarea; set the value and submit. Invisible sitekeys have no widget and submit from a data-callback, so after injecting you call window[callbackName](token), which the examples above already do.

Enterprise rqdata

Enterprise hCaptcha is where in-browser tricks fall down hardest: each challenge carries an IP-bound rqdata blob, so a token has to be minted in a real session that matches it. Capture the rqdata the page passes into hcaptcha.render() / execute() and forward it with the enterprise type:

Enterprise · rqdata
# Enterprise sitekeys also need the per-challenge rqdata blob.
# Read it from the call the page makes (hcaptcha.render / execute),
# then forward it with the enterprise type:
requests.post(
    API,
    headers=HEADERS,
    params={"wait": 90},
    json={
        "type":    "hcaptcha_enterprise",
        "sitekey": sitekey,
        "url":     page.url,
        "rqdata":  rqdata,   # the fresh, IP-bound challenge blob
    },
)

Async instead of blocking

The examples block with ?wait=90, which is the simplest path for a single solve. For high-throughput crawls you can omit ?wait and poll GET /v1/solves/{id} yourself. Concurrency is capped per account (5 on the free trial, up to 50 on paid packs and 200 on the Enterprise plan), so a fleet of Playwright workers can run in parallel up to that cap. The full object and every language sample are in the API reference.

Trigger fewer challenges

Challenge frequency is not fixed. hCaptcha, reCAPTCHA, and Turnstile all score a request before deciding whether to interrupt it, and the same script can go from a challenge on every run to a challenge on one run in twenty depending on how it connects and how it behaves. The inputs that move the number, roughly in order of weight:

  • IP reputation dominates everything else. Traffic from AWS, Hetzner, DigitalOcean, and every other hosting range is challenged almost by default, because almost nobody browses from a datacenter. Running the same script from a residential connection, or through a residential proxy that is not heavily resold, is the single biggest change available to you.
  • A consistent session matters next. The scoring looks for agreement between the parts of your setup: user agent, Accept-Language, timezone, screen size, and the platform they all claim. A Linux container announcing a macOS user agent in a US timezone with a German locale is a mismatch that a real browser does not produce. In Playwright, set these together on the context rather than one at a time.
  • Persistent state helps. A fresh profile with no cookies and no history looks colder than a lived-in one. Use launch_persistent_context, or save and reload storage_state, so a run that already cleared a challenge does not start from zero on the next page.
  • Velocity counts against you. Risk compounds inside a session. Ten signups a minute from one address scores worse than one, and hammering the same form after a failure makes the next attempt harder rather than easier. Back off on failure instead of retrying immediately.
  • Headful beats headless. Chromium’s headless mode still differs from headful in ways detectors have learned to read. Running headful under a virtual display costs you some resources and removes a signal.

Why stealth plugins aren't enough

playwright-stealth and its cousins patch navigator.webdriver, the plugin and language arrays, and various property mismatches that give a headless browser away. They do reduce how often crude checks fire. What they do not do is produce a token: nothing you patch in the page makes the widget hand you a valid response string, and none of it touches your IP reputation.

Modern hCaptcha scores the whole session: the TLS and browser fingerprint, the pointer and timing behaviour, the reputation of the IP. It only hands out a token when that score looks human. Enterprise sitekeys go further and bind every challenge to a fresh rqdata blob tied to your request. A patched automation browser is exactly the profile these systems are built to deny.

No configuration reliably prevents challenges on a site you do not control. You do not control the heaviest input, your address’s reputation, and you do not control the site’s own settings, which can force a challenge on every visitor regardless of score. Better infrastructure lowers the rate. Nothing pins it to zero, so a pipeline that assumes zero breaks the first time a threshold changes.

The details behind each of those signals, and what to change first, are in why am I getting hCaptcha challenges.

reCAPTCHA, Turnstile, and FunCaptcha: what NoneCap does not solve

The injection pattern generalises: every one of these products drops its result into a hidden field that the form reads on submit, so mechanically the shape is the same, only the field name changes (g-recaptcha-response, cf-turnstile-response, the Arkose fc-token). What does not generalise is the hard part, which is getting a token the vendor will accept.

NoneCap solves hCaptcha only. Regular checkbox, invisible, and enterprise rqdata sitekeys. reCAPTCHA, Turnstile, and FunCaptcha are on the roadmap and are not live, so pointing one of those workloads at the API will not work today. We would rather say so here than have you find out from a failed run.

What you do instead depends on which of them you are looking at. Turnstile is the mildest of the three: it is an interstitial far more often than a puzzle, and a residential address plus a real headful browser and persisted cookies is frequently enough for it to pass on its own. Try triggering fewer challenges before you try anything else. reCAPTCHA v3 never interrupts anyone; it returns a score to the site’s backend, so there is nothing to solve and nothing to inject. If you are being blocked by v3, you are being blocked by your score, and the only lever is the one in that same section. reCAPTCHA v2 and FunCaptcha do need a token you cannot mint locally, so a service that covers those types is the answer, and today that is not us.

And if your target has no captcha at all, you don't need a solver. Drive the form directly in Playwright and skip the token step entirely.

Testing without burning real challenges

One more thing worth setting up before any of this reaches CI. If your own application uses hCaptcha and you are writing Playwright tests against it, do not point those tests at your production sitekey. Every run then depends on a live third-party service, adds latency, and either costs you solves or trains a rate limiter on your build server’s address.

hCaptcha publishes an official test sitekey, 10000000-ffff-ffff-ffff-000000000001, which always passes and always emits the same dummy token. Load it by environment so production keeps your real key, and your suite exercises the whole form flow with no challenge and no charge. The keys, the matching test secret, and the not-using-dummy-passcode error you get from mixing test and real values are in hCaptcha test sitekeys.

Reserve real solves for what they are for: automating a site you do not own, where the challenge is a step in the flow like a redirect or a login wall.

Driving Puppeteer or Selenium instead of Playwright? The flow is identical. Read the data-sitekey, mint the token, set h-captcha-response in the DOM, and fire the callback. For the wider pipeline, see hCaptcha for web scraping.

Last updated October 2026.

Frequently asked

Can Playwright solve CAPTCHA by itself?
No. Playwright drives a browser; it has no way to answer a challenge or mint the token the form needs. Clicking the checkbox with page.click does not help either, because the widget scores the whole session rather than the click. Your two real options are to stop triggering the challenge and, when it appears anyway, to fetch a valid token from somewhere else and inject it.
How do I detect which CAPTCHA a page uses in Playwright?
Count matching selectors: .h-captcha[data-sitekey] for hCaptcha, .g-recaptcha[data-sitekey] for reCAPTCHA v2, .cf-turnstile for Turnstile, and an arkoselabs.com iframe for FunCaptcha. reCAPTCHA v3 renders nothing, so test for it in JavaScript with typeof window.grecaptcha?.execute === "function". There is a ready-made detect_captcha(page) helper above.
Does playwright-stealth bypass hCaptcha or reCAPTCHA?
It hides automation markers such as navigator.webdriver and patches the header and property mismatches that give a headless browser away. That is worth having, and it clears the crudest checks. It does not produce a valid token, and it does not change your IP reputation, which is the heaviest input to the score. Treat stealth as one input to the challenge rate, never as a solve.
Where do I get the sitekey and url to send?
The sitekey is on the rendered widget. Read it in Playwright with page.locator("[data-sitekey]").get_attribute("data-sitekey") (or scrape it from the hcaptcha.render() call). The url is just the page the challenge appears on, i.e. page.url. Send both to POST /v1/solves.
The widget is invisible and injecting the token does nothing. What now?
Invisible widgets do not submit on a value change; they submit from the data-callback function. After setting textarea[name="h-captcha-response"], look up the callback name from the data-callback attribute and call window[name](token), exactly as the example does. If the form posts through your own handler instead, just click submit after injecting.
Is solving a CAPTCHA legal, or against the site’s terms?
It depends on the site’s terms and on your jurisdiction, and neither of those is something a solver can answer for you. Read the target’s terms of service, and get advice if the answer matters commercially. Plenty of legitimate work meets a challenge (your own QA suite, an account you own, a site that has asked you to integrate) and plenty of automation is prohibited by the site it points at. NoneCap’s own terms are at /terms/.
How much does each solve cost?
At least one credit per solve. Billing starts at one credit per hCaptcha challenge round (often one, sometimes two or three), charged only on success, so failed solves are never charged. Credits run $0.20-$0.50 per 1,000 depending on your pack, you get 1,300 free credits on signup, and no subscription is required. See pricing.

Start solving hCaptcha in minutes.

1,300 free credits on signup. Pay per solve, credits never expire, failed solves never charged.