Selenium is still the most common way teams automate a website, and the
most common way those teams get stuck: the form fills fine until the
CAPTCHA page appears, and no amount of click() gets past it. This
guide builds the integration that does work — request a token from the
EndCaptcha API, inject it into the page the driver is on, submit while
it is fresh — in Python and in Node, with code that runs against the
clients we ship.
Why Selenium runs into the challenge
A site's rules do not care that your script is honest; they care that
the visit looks automated. Selenium gives them the tells: the
navigator.webdriver flag on window, a browser user agent with no
plugins, a page that loads and submits faster than any human manages.
When the rules fire, the challenge appears — usually as an interstitial
before the form you wanted, or as a reCAPTCHA widget rendered inside
iframe[title*="reCAPTCHA"].
Chasing the widget from the driver is a dead end. Clicking the checkbox means switching into the iframe, clicking an element that may not be there yet, and repeating until the site decides you are a robot — which it already suspected. The reliable unit of work is a token the page accepts, produced outside the browser, not a battle with the widget.
The integration pattern
Every CAPTCHA-solving integration with Selenium is the same three steps:
- Request the token with the details of the page the driver will be on: the site key and the full URL of that page.
- Inject the token into the response field the page reads.
- Submit while the token is still valid — tokens are single-use and expire in about two minutes.
The solve happens outside the browser, which is what makes this shape portable: it works headless, on a Selenium Grid node, behind a proxy, and with the same code whether the page shows reCAPTCHA v2, v3 or a Cloudflare Turnstile. There is no browser farm to run and no widget to train.
Step one: get the token (Python)
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. A Turnstile
widget is the same loop with type=12 and sitekey instead of
googlekey, as the Turnstile type page
shows.
Step two: inject the token and submit
The response field lives in the main document — only the widget's
visual part is inside the iframe — so execute_script reaches it
without switching frames:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
options = Options()
options.add_argument("--headless=new")
options.add_argument("--lang=en-US")
driver = webdriver.Chrome(options=options)
driver.get("https://www.example.com/signup")
driver.execute_script(
"""
const field = document.querySelector(
'[name="g-recaptcha-response"]');
if (field) {
field.value = arguments[0];
field.dispatchEvent(new Event('input', {bubbles: true}));
field.dispatchEvent(new Event('change', {bubbles: true}));
}
""",
token,
)
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, 'button[type="submit"]'))
).click()
WebDriverWait(driver, 10).until(EC.url_contains("dashboard"))
driver.quit()
Two Selenium-specific details matter here. First, inject and click in
one flow: forms often re-render after the token lands, and a stale
reference between the two steps is the most common
StaleElementReferenceException in this pattern — the explicit wait
above re-finds the button at click time. Second, inject right before
submitting: the token is single-use and expires in about two minutes,
so solving first and submitting an hour later is a guaranteed rejection.
When the page submits with fetch instead of a form
SPA-style pages read the response field in JavaScript and send it with
fetch(). For Chrome you can wire the token onto the wire before the
page even loads, with the DevTools protocol:
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
window.__ec_token = null;
const origFetch = window.fetch;
window.fetch = function (...args) {
const init = args[1] || {};
if (window.__ec_token && typeof init.body === 'string') {
init.body += '&g-recaptcha-response=' + window.__ec_token;
args[1] = init;
}
return origFetch.apply(this, args);
};
""",
})
driver.get("https://www.example.com/signup")
driver.execute_script("window.__ec_token = arguments[0];", token)
driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
The patch runs on every document of that driver session, reads the
token you store on window after the solve, and appends it only to
request bodies — the page's own code never knows the difference.
The same flow in Node.js
The Node client in the zip speaks the identical contract; the example
example_recaptchav2.js that ships with the client is the shape you
copy. Driving the page with selenium-webdriver:
const { Builder, By } = require('selenium-webdriver');
const HttpClient = require('./lib/endcaptcha.js');
async function main() {
const pageurl = 'https://www.example.com/signup';
const client = new HttpClient(process.env.EC_USER, process.env.EC_PASS);
const driver = await new Builder().forBrowser('chrome').build();
const job = await new Promise((resolve) => {
client.decode({
type: 4,
googlekey: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
pageurl,
}).on('response', resolve);
});
if (job.error) throw new Error(job.error);
await driver.get(pageurl);
await driver.executeScript(`
const field = document.querySelector('[name="g-recaptcha-response"]');
field.value = arguments[0];
field.dispatchEvent(new Event('change', {bubbles: true}));
`, job.text);
await driver.findElement(By.css('button[type="submit"]')).click();
await driver.quit();
}
main().catch((err) => { console.error(err); process.exit(1); });
The injection code does not care which language produced the token; keep the solve as a plain function call in whatever language the automation already lives in.
pageurl, proxies and the session you submit from
Send the full URL of the page that hosts the widget, path included:
https://www.example.com/signup, never the domain root, and the same
URL the driver actually loads — driver.current_url is the reference.
If the solving address and the submitting address differ, pages reject
the answer: run the driver through a proxy with --proxy-server and
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.
On Selenium Grid nothing changes: the token is produced off the node, so any grid node can inject it — keep the pageurl pointing at the public URL of the page, not at the node's address. Keep the browser fingerprint stable between attempts too: the same headless flags, the same user agent, the same proxy. A run that works headed and fails headless is almost always a fingerprint change between the solve and the submit, not a problem with the API.
Troubleshooting
| Answer from the API or the driver | 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 googlekey/sitekey and the pageurl format |
ERROR:UNSUCCESSFUL UPLOAD |
The upload itself failed | Credentials, balance, or a malformed token payload |
| Token from the API, rejection from the page | The pageurl does not match driver.current_url, or the token was reused |
Match the URL exactly; tokens are single-use and expire in about two minutes |
StaleElementReferenceException between inject and click |
The form re-rendered after the token landed | Re-find the element; inject and click inside one explicit wait |
| Works headed, fails headless | The fingerprint changed between attempts | Keep flags, user agent and proxy identical across the run |
Questions developers ask
Which CAPTCHA types can I solve this way? reCAPTCHA v2 (type=4),
reCAPTCHA v3 (type=5) and Cloudflare Turnstile (type=12) all follow
this loop with different parameters — see the
v2 type page, the
v3 type page and the
Turnstile type page for the field
lists.
Does it work with Selenium Grid? Yes — the token is produced outside the browser, so any node can inject it. Keep the pageurl on the public URL of the page.
How long does a solve take? The service publishes a seven-second
SLA; in practice the poll returns in seconds when the queue is free,
and decode() caps the wait for you.
Do I need a proxy? Only when the site cares about the address: if
the driver submits from one address, solve from the same one by passing
proxy and proxytype with the token parameters.
Is this different from the Playwright flow? Only the injection API — the solve step is identical. The Playwright guide covers the same token loop for Playwright scripts, and our Selenium handling guide covers driver-side patterns around challenges.
Where do I start? The quick start shows the same flow with cURL, and creating an account takes a minute.

