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-sitekey and the page url.
  • 2. Solve by calling POST /v1/solves (block with ?wait=N, or go async with a webhook_url).
  • 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.

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:

Node · Puppeteer
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:

Sitekey fallback
// 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.

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 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 · rqdata
// 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?
puppeteer-extra-plugin-stealth patches the obvious automation tells, but modern hCaptcha scores the whole session: TLS and browser fingerprint, input cadence, behavioural signals, and on enterprise sitekeys each challenge is bound to a fresh 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?
When the widget is in the DOM, use 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?
Invisible widgets do not submit on a value change; they submit from the 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.
How much does each solve cost?
At least one credit per solve. Billing is one credit per hCaptcha challenge round (hCaptcha decides how many: often one, sometimes two or three), charged only on success, and failed solves are auto-refunded. Credits run $0.40 to $0.50 per 1,000 depending on your pack, you get 100 free credits on signup, and there is no subscription. See pricing.

Start solving hCaptcha in minutes.

100 free credits on signup. Pay per solve, credits never expire, failed solves auto-refunded.