Solve hCaptcha in Puppeteer (Node.js).
Don't try to beat the challenge inside your automated browser. Read the
data-sitekey off the page with page.$eval, ask NoneCap for a
real token, inject it into the form's h-captcha-response field with
page.evaluate, then submit. Four steps, all in plain Node.
Why vanilla stealth usually isn't enough
A headless Puppeteer session with puppeteer-extra-plugin-stealth gets you
past the crudest automation checks, but it doesn't get you a valid hCaptcha token.
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.
So instead of solving the challenge inside the browser you're automating, you
solve it out of band. NoneCap solves it separately, off your machine,
then returns a normal P1_ token. Your Puppeteer script never touches the
challenge UI. It just supplies the token the page was already waiting for.
The four steps
The flow is the same shape on every page that gates with hCaptcha:
- 1. Read the
data-sitekeyand the pageurl. - 2. Solve by calling
POST /v1/solves(block with?wait=N, or go async with awebhook_url). - 3. Inject the returned token into
textarea[name="h-captcha-response"]viapage.evaluate, and fire the widget callback if there is one. - 4. Submit the form the way your page does.
Full Node example
Using the official puppeteer package and Node's built-in fetch
for the NoneCap call. ?wait=90 blocks the request until the solve reaches
a terminal state, so the token comes back inline with no polling loop:
import puppeteer from "puppeteer";
const NONECAP_KEY = process.env.NONECAP_KEY;
async function solveWithNoneCap(sitekey, url) {
const res = await fetch("https://api.nonecap.com/v1/solves?wait=90", {
method: "POST",
headers: {
Authorization: `Bearer ${NONECAP_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ type: "hcaptcha", sitekey, url }),
});
const data = await res.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 puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://target.example/login", { waitUntil: "networkidle2" });
// 1. Read the sitekey straight off the rendered widget.
const sitekey = await page.$eval("[data-sitekey]", (el) =>
el.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;
}
// 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.
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle2" }),
page.click("button[type=submit]"),
]);
await browser.close(); Reading the sitekey when it isn't a DOM attribute
The example assumes the widget renders a [data-sitekey] element, which is
the common case. Some pages instead pass the key straight to hcaptcha.render()
and never set the attribute. When page.$eval can't find it, fall back to the
sitekey query param on the hcaptcha.com/1/api.js script tag:
// Some pages never put data-sitekey in the DOM and pass it straight to
// hcaptcha.render(). Read it from the api.js script tag's query string,
// or sniff the value the page hands to render():
const sitekey = await page.$eval("[data-sitekey]", (el) =>
el.getAttribute("data-sitekey")
).catch(async () => {
const src = await page.$eval(
"script[src*='hcaptcha.com/1/api.js']",
(el) => el.getAttribute("src")
);
return new URL(src).searchParams.get("sitekey");
}); Don't hard-code the sitekey: sites rotate them, and enterprise deployments bind the challenge to the page, so always read it from the live DOM.
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.
| Sitekey type | How you spot it | What to send NoneCap |
|---|---|---|
| Checkbox | A visible "I am human" widget renders; a textarea[name="h-captcha-response"] appears in the form | type: "hcaptcha" + sitekey + url |
| Invisible | No widget; hcaptcha.execute() runs on an action and a data-callback handles the token | type: "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 example above already does.
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 sitekeys also need the per-challenge rqdata blob. Capture it
// from the call the page makes (hcaptcha.render / execute), then forward it
// with the enterprise type:
const res = await fetch("https://api.nonecap.com/v1/solves?wait=90", {
method: "POST",
headers: {
Authorization: `Bearer ${NONECAP_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "hcaptcha_enterprise",
sitekey,
url: page.url(),
rqdata, // the fresh, IP-bound challenge blob
}),
}); A note on g-recaptcha-response
The example also sets textarea[name="g-recaptcha-response"] when it exists.
That is hCaptcha's drop-in compatibility field: hCaptcha reuses reCAPTCHA's old field
name so sites can swap providers without touching their backend. The value you write is
still the hCaptcha P1_ token, not a reCAPTCHA solve. NoneCap
does not solve reCAPTCHA.
Async instead of blocking
The example blocks with ?wait=90, which is the simplest path for a single
solve. For high-throughput crawls you can omit ?wait and pass a
webhook_url to receive a solve.completed callback, or poll
GET /v1/solves/{id} yourself. Concurrency is capped per account (5 on
the free trial, 25 on Starter, 50 on Builder, 100 on Scale), so a fleet of Puppeteer workers can run
in parallel up to that cap. The full object and every language sample are in the
API reference.
When this isn't the right approach
NoneCap solves hCaptcha only: regular, invisible, and enterprise
rqdata. If the page is actually protected by reCAPTCHA, Cloudflare Turnstile, or FunCaptcha, this technique doesn't apply; those are on the roadmap, not live. And if your target has no captcha at all, you don't need a solver. Drive the form directly in Puppeteer and skip the token step entirely.
Driving Playwright or Selenium instead of Puppeteer? The flow is identical. Read the
data-sitekey, mint the token, set h-captcha-response in the
DOM, and fire the callback. See
hCaptcha solving in Playwright for the Python and
Node variants, hCaptcha solving in Selenium for the
WebDriver version, or hCaptcha for web scraping
for the no-browser HTTP-client version of the same pipeline.
Last updated June 2026.
Frequently asked
Why not just solve hCaptcha inside Puppeteer with a stealth plugin?
rqdata blob. A patched headless Chrome still looks like a patched headless Chrome. NoneCap solves it separately, off your machine, and hands back a finished token, so your Puppeteer run only has to inject and submit.How do I read the sitekey in Puppeteer?
page.$eval("[data-sitekey]", el => el.getAttribute("data-sitekey")). If the page passes the key straight to hcaptcha.render() and never sets the attribute, read it from the sitekey query param on the hcaptcha.com/1/api.js script tag instead. Send that value plus page.url() to POST /v1/solves.The widget is invisible and injecting the token does nothing. What now?
data-callback function. After setting textarea[name="h-captcha-response"] in page.evaluate, 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.