Guide

Handling reCAPTCHA in Playwright and Puppeteer

Playwright and Puppeteer can drive every part of a page except the reCAPTCHA widget. The flow around it is the same in both tools: find the sitekey, POST it to a solver, put the returned token where the page's own code reads it, then submit through the page's own handler. This page shows the real KagedCap request and response shapes, working code for both browsers, and the cases where a solver is the wrong tool.

First, check whether you need a solver at all

If you control the site under test, fix it on your side. It is cheaper, deterministic, and it does not cost you a network round trip per run. Google publishes a test key pair that always verifies — sitekey 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI, secret 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe — so pointing staging at it stops the assessment being a gate. It is not a no-op: the widget still renders, it still shows the "for testing purposes only" notice, and the pair is the v2 checkbox variant, so it still wants a click before a token exists. Staging only — the pair verifies for anybody, so it is worthless on anything reachable in production.

Two alternatives work as well and are less visible: have your backend skip the assessment when a request carries a shared secret header only CI knows, or build staging without the widget mounted at all.

A solver earns its place in the other case — you are authorized to automate a property whose bot defences you do not own. A vendor portal your team is permitted to script. A partner flow behind a login. An accessibility or uptime check on a site that granted you access. The challenge cannot tell that work apart from abuse, so the run dies at the widget.

If you do not have that authorization, stop here. Nothing on this page converts unauthorized automation into authorized automation, and every solve is logged to the account that made it.

One scope limit up front: KagedCap covers reCAPTCHA v2 invisible, v3, and v3 Enterprise. v2 checkbox — the kind that can pop an image grid — is not supported.

How to find the sitekey on a page

If it is your property, take the key from the reCAPTCHA admin console and hardcode it. Scraping it out of the DOM on every run is a moving part you do not need.

Otherwise the key is in one of three places, and the URL Google is loaded from tells you whether it is an Enterprise key:

function findSitekey() {
  const scripts = [...document.querySelectorAll('script[src*="recaptcha"]')];
  const frames = [...document.querySelectorAll('iframe[src*="/recaptcha/"]')];

  // Decide enterprise from the loader URL, never from the presence of the
  // grecaptcha.enterprise namespace — that can exist on a standard-key page.
  const enterprise =
    scripts.some((s) => s.src.includes('/enterprise.js')) ||
    frames.some((f) => f.src.includes('/recaptcha/enterprise/'));

  // 1. explicit widget markup (v2, and v3 rendered explicitly)
  const el = document.querySelector('[data-sitekey]');
  if (el) return { sitekey: el.getAttribute('data-sitekey'), enterprise };

  // 2. the loader script: api.js?render=<sitekey> / enterprise.js?render=<sitekey>
  for (const s of scripts) {
    const k = new URL(s.src, location.href).searchParams.get('render');
    if (k && k !== 'explicit') return { sitekey: k, enterprise };
  }

  // 3. the widget iframe: .../anchor?...&k=<sitekey>
  for (const f of frames) {
    const k = new URL(f.src, location.href).searchParams.get('k');
    if (k) return { sitekey: k, enterprise };
  }
  return { sitekey: null, enterprise };
}

Enterprise pages load /recaptcha/enterprise.js and their iframes sit under /recaptcha/enterprise/; standard pages use api.js and /recaptcha/api2/. That distinction picks the task name — ReCaptchaV3EnterpriseTask rather than ReCaptchaV3Task — and an Enterprise sitekey sent on the plain task will not mint a usable token, so it is worth getting from a signal that cannot lie to you.

v3 also has an action, which is a string in the page's own grecaptcha.execute(sitekey, { action }) call. The hook below records every one the page asks for, if you cannot read it from source.

The solve call: POST /solve

One authenticated POST returns a token. Authenticate with x-api-key. Only a 200 carrying a token is billed — every failure releases the hold and costs nothing.

POST https://api.kagedcap.io/solve
x-api-key: <your key>
content-type: application/json

{
  "task": "ReCaptchaV3Task",
  "url": "https://example.com/login",
  "sitekey": "6Lc…",
  "action": "login",
  "userAgent": "<the exact UA your browser context sends>",
  "proxy": "http://user:pass@proxy.example.com:8080"
}
{
  "success": true,
  "task": "ReCaptchaV3Task",
  "token": "03AFcWeA…",
  "score": <0.0-1.0 — whatever Google returned for this token>,
  "verification": null
}

There is no typical score to design around, which is why the field above is a placeholder rather than a number: read what comes back, do not assume it. verification is populated only if you send your own secretKey — that is the whole mechanism, there is no second flag to set. The secret is passed through to the verification call and is never copied into any log line.

The task names:

  • ReCaptchaV3Task / ReCaptchaV3TaskProxyLess
  • ReCaptchaV3EnterpriseTask / ReCaptchaV3EnterpriseTaskProxyLess
  • ReCaptchaV2Task / ReCaptchaV2TaskProxyLess — invisible only

Field rules that matter in a browser flow:

  • url is the page the captcha appears on. Use page.url() so a redirect cannot desync it.
  • action is optional on every reCAPTCHA task. For v3 it binds the token to your site's action, and omitting it performs a genuine no-action solve. Send the action your page uses when you know it: an Enterprise assessment reports tokenProperties.action back to the site, and sites are told to check it matches. v2 ignores it and returns score: null.
  • userAgent should be the exact UA your browser context presents. Read it off the context rather than hardcoding a string that will rot — both examples below do. The token embeds it, so a mismatch between the solve and the replay weakens the result. Omit it and a default is used.
  • Tokens are short-lived — Google documents a two-minute validity window for the response token. Solve immediately before you submit, not at the start of a long setup phase.

ProxyLess or proxied — which task to send

A reCAPTCHA token is minted by a client talking to Google from some IP address. On a proxied task that client uses the proxy you supply; on a ProxyLess task it uses ours.

The rules are enforced, and they are reCAPTCHA-specific — the other task families carry their own proxy rules, so do not generalise these:

  • Every reCAPTCHA task except the ProxyLess variants requires proxy.
  • ProxyLess tasks must not carry one.
  • Break either rule and the answer is 400 validation_error with an issues array. The entry reads path: "proxy" plus either a proxy is required for non-ProxyLess tasks or ProxyLess tasks must not include a proxy. There is no proxy_required or proxy_not_allowed error code to branch on — nothing emits them — so read issues.
  • Format is scheme://user:pass@host:port or host:port:user:pass.

Mint from the same egress you replay from. reCAPTCHA Enterprise assessments accept a userIpAddress on the event, so if the site passes the requester's IP into createAssessment and your token was minted from a different address, the assessment it gets back does not describe the request you are making — it describes a different client at a different address. That is an inconsistent assessment, and inconsistent is not a state you want your own automation to be in. If your Playwright context runs through a proxy, send that same proxy to /solve.

ProxyLess is the right pick when there is no proxy in the picture at all — your own staging, or a flow that does not tie the token to your egress. One address, nothing to reconcile.

The errors you will actually hit:

  • 502 solve_failed — a real attempt ran and failed. Not billed. Do not blindly retry the same request; fix the input first, usually the proxy.
  • 502 proxy_unreachable — the solve ran and your proxy did not answer.
  • 400 proxy_invalid — the proxy was malformed or disallowed and no solve was attempted.
  • 503 proxyless_disabled — the shared egress pool is off; resend on the non-ProxyLess task with your own proxy.
  • 429 key_frozen — a burst of failed solves froze the key, which almost always means dead proxies. Wait out the cool-off named in the message. Retrying during it does not extend the freeze — the check runs before dispatch, so a refused request never reaches the failure counter — but it does not shorten it either. Fix the proxies first.
  • 429 concurrency_limit_exceeded, 503 no_capacity, 503 solver_unavailable, 504 solve_timeout — retry with backoff.
  • 403 host_not_allowed — the key's allowlist does not include the page host.

On POST /solve, an Idempotency-Key header is an in-flight guard and nothing more: it holds for 120 seconds, a duplicate arriving while the first is still running gets 409 idempotency_conflict, and a completed result is not replayed — so the same key sent after the window buys a second solve. If retry-safe submission matters, use the async POST /v2/solve instead, where the key is durable across shards and resubmitting it returns the original job rather than starting another. Do not reach for GET /v1/solves/{id} as a recovery path; that is a dashboard session route and will not accept an API key.

Playwright: detect, solve, inject, submit

For v3 there is no DOM field to fill — the token goes straight from grecaptcha.execute() into the page's own JavaScript. So the injection point is execute() itself. The hook below wraps it using the public grecaptcha surface, records every (sitekey, action) the page asks for, and returns your token once you set one.

The two things it has to survive are late assignment and two-step assignment. Pages routinely set window.grecaptcha to a stub before api.js fills in execute, and on Enterprise pages grecaptcha.enterprise is normally attached after the top-level object exists. Patching once, at assignment time, wraps nothing on either. So trap the property and retry the wrap on every read.

function hookGrecaptcha() {
  window.__kc = { token: null, calls: [] };

  const wrap = (target) => {
    if (!target || typeof target.execute !== 'function' || target.__kcWrapped) return;
    const real = target.execute.bind(target);
    target.execute = (sitekey, opts = {}) => {
      window.__kc.calls.push({ sitekey, action: opts.action ?? null });
      return window.__kc.token ? Promise.resolve(window.__kc.token) : real(sitekey, opts);
    };
    target.__kcWrapped = true;   // only once execute was really wrapped
  };

  const trap = (obj, prop, onTouch) => {
    let v = obj[prop];
    Object.defineProperty(obj, prop, {
      configurable: true,
      get() { onTouch(v); return v; },
      set(next) { v = next; onTouch(v); },
    });
  };

  trap(window, 'grecaptcha', (g) => {
    if (!g) return;
    wrap(g);
    if (!g.__kcEnterpriseTrapped) {
      g.__kcEnterpriseTrapped = true;
      trap(g, 'enterprise', (e) => wrap(e));   // attached later on enterprise pages
    }
  });
}

async function solve(body) {
  const res = await fetch('https://api.kagedcap.io/solve', {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-api-key': process.env.KAGEDCAP_API_KEY },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok || !data.success) {
    // 400 validation_error carries `issues`; the rest carry error + message.
    throw new Error(`${res.status} ${data.error}: ${data.message ?? ''} ${JSON.stringify(data.issues ?? [])}`);
  }
  return data;  // { success, task, token, score, verification }
}
import { chromium } from 'playwright';

const PROXY_URL = 'http://user:pass@proxy.example.com:8080';  // same egress for both

const browser = await chromium.launch({
  proxy: { server: 'http://proxy.example.com:8080', username: 'user', password: 'pass' },
});
const page = await browser.newPage();
await page.addInitScript(hookGrecaptcha);

// A property you own or are authorized to automate.
await page.goto('https://staging.example.com/login');

// Read the UA off the context instead of hardcoding one — the token embeds it.
const UA = await page.evaluate(() => navigator.userAgent);

const { token } = await solve({
  task: 'ReCaptchaV3Task',   // ReCaptchaV3EnterpriseTask for an enterprise key
  url: page.url(),
  sitekey: '6Lc…',
  action: 'login',           // optional — send it if your page uses one
  userAgent: UA,
  proxy: PROXY_URL,
});

await page.evaluate((t) => { window.__kc.token = t; }, token);

await page.fill('#email', process.env.TEST_EMAIL);
await page.fill('#password', process.env.TEST_PASSWORD);
await page.click('button[type="submit"]');   // execute() now resolves with your token
await page.waitForURL('**/account');

One ordering caveat. If the page calls execute() on load rather than on submit, that first call happens before you have a token and falls through to the real implementation. Since you already know your own sitekey and action, solve before goto() and seed window.__kc.token from the init script instead. Read window.__kc.calls once during development to learn what the page actually asks for, then hardcode it.

This is also the Puppeteer v3 path. page.evaluateOnNewDocument(hookGrecaptcha) is Puppeteer's equivalent of addInitScript, and everything after it — the solve call, seeding the token through page.evaluate — is identical.

Puppeteer: injecting a v2 invisible token

v2 is simpler — the token has a home in the DOM. api.js renders a hidden textarea#g-recaptcha-response that the form posts. Pages read it in one of three ways, so set all three and let the real one win.

import puppeteer from 'puppeteer';
// solve() from the section above — same ESM module, one helper for both examples.

const browser = await puppeteer.launch({
  args: ['--proxy-server=http://proxy.example.com:8080'],
});
const page = await browser.newPage();
await page.authenticate({ username: 'user', password: 'pass' });
await page.goto('https://staging.example.com/contact', { waitUntil: 'networkidle2' });

const UA = await browser.userAgent();
const sitekey = await page.$eval('[data-sitekey]', (el) => el.getAttribute('data-sitekey'));

const { token } = await solve({
  task: 'ReCaptchaV2Task',   // invisible v2: no action, score comes back null
  url: page.url(),
  sitekey,
  userAgent: UA,
  proxy: 'http://user:pass@proxy.example.com:8080',
});

await page.evaluate((t) => {
  // 1. the form field
  for (const el of document.querySelectorAll('textarea[id^="g-recaptcha-response"]')) {
    el.value = t;
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }
  // 2. what many pages read instead of the field
  if (window.grecaptcha) window.grecaptcha.getResponse = () => t;
  // 3. invisible widgets usually hand the token to data-callback
  const cb = document.querySelector('[data-callback]')?.dataset.callback;
  if (cb && typeof window[cb] === 'function') window[cb](t);
}, token);

await page.click('#submit');
await page.waitForSelector('.thank-you');

If the textarea query comes back empty, the invisible widget has not rendered yet — the getResponse stub and the callback still carry the token. Multiple widgets on one page get id suffixes (g-recaptcha-response-1), which is why the selector is a prefix match.

The same block runs in Playwright with page.$eval and page.evaluate unchanged; only the launch and proxy-auth lines differ between the two tools. Both examples are written against documented public APIs and standard widget markup, but page-level behaviour varies — whether the site calls execute() at load or at submit, and whether it reads the textarea, getResponse() or a callback. Run them against a staging page you control before you trust the selectors.

Common questions

How do I inject a reCAPTCHA v3 token in Playwright?

v3 has no DOM field. The token goes from grecaptcha.execute() directly into the page's JavaScript, so the injection point is execute() itself. Use page.addInitScript to define a configurable getter and setter for window.grecaptcha that wraps execute as soon as a real one exists, and define the same trap on the enterprise property, because enterprise pages attach grecaptcha.enterprise after the top-level object. Retry the wrap on every read rather than patching once, since pages commonly assign a stub before api.js fills in execute. The wrapper resolves with your solved token when one is set and falls through to the original otherwise. Puppeteer does the same thing with page.evaluateOnNewDocument.

Where does a reCAPTCHA v2 invisible token go on the page?

Into the hidden textarea that api.js renders, matched by a prefix selector on the id g-recaptcha-response. Set its value and dispatch a bubbling change event. Many pages read grecaptcha.getResponse() instead of the field, and invisible widgets usually pass the token to the function named in the widget's data-callback attribute, so set all three: the field, a getResponse stub returning the token, and a call to the data-callback function. One of them is the path the page actually uses; the other two are harmless.

Do I need a proxy to solve reCAPTCHA from Playwright or Puppeteer?

It depends on the task variant. Every reCAPTCHA task except the ProxyLess variants requires a proxy field, and the ProxyLess variants must not include one. Breaking either rule returns 400 validation_error with an issues array whose entry has path proxy and a message naming the rule you broke; there is no proxy_required or proxy_not_allowed error code, so branch on validation_error and read issues. Choose the proxied task, with the same proxy your browser runs through, whenever the site scores the token against the requester's IP: reCAPTCHA Enterprise assessments accept a userIpAddress on the event, so a token minted from a different address produces an assessment that does not describe the request you are making. Use ProxyLess when there is no proxy in the picture at all, such as your own staging.

How long is a solved reCAPTCHA token valid?

Google documents a two-minute validity window for the reCAPTCHA response token, so the solve has to happen immediately before submission rather than during test setup. Plan the automation so the token is fetched at the point the page would have called execute, not at the start of a long login or navigation sequence. If you use the asynchronous job endpoint, the stored token is cleared five minutes after the job completes, which is longer than the token itself lives; the status, timings and error survive that sweep, so a late poll still gets an answer.

How do I retry a solve safely without paying twice?

On the synchronous POST /solve you cannot, because an Idempotency-Key there is only an in-flight guard: it holds for 120 seconds, a duplicate arriving while the first request is still running returns 409 idempotency_conflict, and a completed result is not replayed, so the same key resent after the window buys a second solve. If a dropped response has to be recoverable, submit to the asynchronous POST /v2/solve instead, where the idempotency key is stored durably across shards and resubmitting it returns the original job id. Do not use the dashboard solves endpoint as a recovery path; it is authenticated by session cookie and will not accept an API key.

What should I do when a solve returns 502 solve_failed?

Do not blindly retry the same request. A 502 solve_failed means a real attempt ran and failed, most often because the supplied proxy was dead or blocked, and it is not billed. Fix the input first, usually the proxy, then retry. By contrast 429 concurrency_limit_exceeded, 503 no_capacity, 503 solver_unavailable, 503 maintenance and 504 solve_timeout are safe to retry with exponential backoff. A 429 key_frozen means a burst of failures froze the key; wait the cool-off period given in the message, because further retries extend it.

SupportHandling reCAPTCHA in Playwright and Puppeteer — KagedCap