Playwright is the fastest way to automate a modern website, and the fastest way to hit a CAPTCHA: headless Chromium trips the same defenses a human does, only without the patience to solve them. This guide shows the three real routes out, and then builds the one you should be using — solving the challenge through the EndCaptcha API and injecting the token into the page — with code you can copy into a project today.
Why a Playwright script stops at the challenge
A CAPTCHA-protected page does not care that your script is honest. The
challenge appears when the site's rules decide the visit looks
automated: Playwright's default automation flags, a headless user
agent with no plugins, a WebDriver object on window, or simply a
login attempt from an address with no history. When the challenge
appears it usually lives in its own iframes — reCAPTCHA widgets render
inside iframe[title*="reCAPTCHA"] — which is why clicking blindly
with page.click() times out: you are clicking the wrapper, not the
checkbox.
The practical consequence: your script needs a token, not a click loop.
The three routes, honestly
Clicking through it. Fragile by design. Even when the widget
cooperates headless — Cloudflare's documentation test key
3x00000000000000000000FF is the classic trick — the solving services
do not accept it: we measured Capsolver rejecting it outright with
ERROR_INVALID_TASK_DATA: invalid websiteKey. Test keys prove
plumbing, they do not solve production traffic.
Building your own solver. Out of scope for this article and for most teams: it needs a farm, fingerprints and upkeep.
Using a solving API. You submit the challenge details, the service returns a token, you put the token where the page expects it. The rest of this article is that path with the official EndCaptcha client.
Step one: get the token
The Python client ships in the downloads of the API documentation page. For a reCAPTCHA v2 widget you send the site key and the full URL of the page that hosts it:
import json
from endcaptcha import HttpClient
client = HttpClient("YOUR_USERNAME", "YOUR_PASSWORD")
captcha = client.decode(
type=4,
token_params=json.dumps({
"googlekey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
"pageurl": "https://www.example.com/signup",
}),
)
token = captcha["text"]
decode() does the upload and the polling for you. If you poll by
hand, know the protocol: right after upload the API answers
UNSOLVED_YET:/poll/<id> while the job waits in queue, the poll
answers ? while a solver is working on it, and the token text when
it is solved. Poll no faster than once every three seconds.
For reCAPTCHA v3 the same call takes type=5 plus action and
min_score inside the token parameters — see the
API reference for the full field list.
Step two: put the token into the page
Form-based sites store the answer in a hidden field before they submit:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://www.example.com/signup")
page.evaluate(
"""(token) => {
const field = document.querySelector(
'[name="g-recaptcha-response"]');
if (field) {
field.value = token;
field.dispatchEvent(new Event('input', {bubbles: true}));
field.dispatchEvent(new Event('change', {bubbles: true}));
}
}""",
token,
)
page.click('button[type="submit"]')
page.wait_for_url("**/dashboard")
browser.close()
Sites that submit through fetch() instead of a form need the token
on the wire, not in the DOM — intercept the request and add it:
import re
def pass_token(route, token):
response = route.fetch()
body = response.text() + "&g-recaptcha-response=" + token
route.fulfill(response=response, body=body)
page.route(re.compile(r".*/api/signup"), lambda r: pass_token(r, token))
If the widget refuses the injected token (the page verifies a
callback), the troubleshooting table below has the two causes worth
checking first: wrong pageurl and token reuse.
The same flow in Node.js
The Node client in the zip speaks the identical contract. The example
example_recaptchaV3.js that ships in ec_api_v1_1_nodejs.zip is the
shape you copy — an object carrying the type and the fields, decode()
returning an event emitter:
const HttpClient = require('./lib/endcaptcha.js');
const client = new HttpClient(process.env.EC_USER, process.env.EC_PASS);
const job = {
type: 4,
googlekey: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
pageurl: 'https://www.example.com/signup',
};
client.decode(job).on('response', (res) => {
if (res.error) return console.error(res.error);
console.log('token:', res.text);
});
Drive the page with page.evaluate() exactly as in the Python
version; the injection code does not care which language produced the
token. For automation that already lives in Node, type: 5 with
action and min_score in the same object covers reCAPTCHA v3.
Choosing between reCAPTCHA v2 and v3
Both arrive as a token you inject; what changes is the conversation
with the page. v2 is a widget: the page expects the
g-recaptcha-response field to be filled and a checkbox to appear
ticked. v3 has no widget: the token rides along with whatever the page
submits, and the page scores it — send min_score with your request
so the service solves for your threshold instead of accepting anything
the site will reject. A site that scores below the line rejects the
token at submit time, which looks exactly like an expired token in
your logs; check the score before you blame the poll loop.
If the site runs both, solve the type the page actually renders:
inspect the widget iframe for v2 (iframe[title*="reCAPTCHA"] or the
data-sitekey attribute in the script), and read data-action for v3
before you choose type=4 or type=5.
Proxies and the pageurl rule
Send the full URL of the page that hosts the widget, path
included: https://www.example.com/signup, never the domain root. If
the solving address and the submitting address differ, pages reject
the answer — pass proxy and proxytype inside the token parameters
so the solver comes from the same address you submit from. The fields
are documented in the Turnstile and
Amazon WAF guides, which follow the same shape.
Troubleshooting
| Answer from the API | What it means | What to do |
|---|---|---|
UNSOLVED_YET:/poll/<id> |
Your job is in queue | Keep polling, no faster than every 3 seconds |
? |
A solver is working on it | Keep polling; the job expires if abandoned for too long |
ERROR:NOT A VALID CAPTCHA |
A field failed validation | Check sitekey/googlekey and the pageurl format |
ERROR:UNSUCCESSFUL UPLOAD |
The upload itself failed | Credentials, balance, or a malformed token payload |
| Token accepted by API, rejected by the page | Wrong pageurl, or the token was already used |
Match the URL exactly; tokens are single-use and expire in about two minutes |
| Works headless=False, fails headless=True | The site fingerprints the browser | Keep the same pageurl, add a consistent proxy, avoid changing fingerprints between solve and submit |
Questions developers ask
Does this work in headless Chromium? Yes — the token is produced off the browser by the API; only the injection happens in the page.
How long does a solve take? Seconds when the queue is free; poll
with the timeout your SLA can afford (the client's decode() caps it
for you).
What about reCAPTCHA v3 scores? Send min_score with the request
and treat a low score like a failed login, not like an exception —
see the v3 type page.
Can I run this inside an existing Playwright fixture? Yes — keep
the solve step as a plain function call before page.goto, cache the
token only until the form is submitted, and never reuse it: the API
returns single-use tokens that expire in about two minutes.
What if the automation lives in Selenium instead? The solve step is
identical - the token loop does not care which driver produced it. The
Selenium integration guide
covers the same flow with execute_script injection and the Node
client.
Where do I start? The quick start shows the same flow with cURL, and creating an account takes a minute.

