This recipe gives you a single Playwright script for Node.js that finds a reCAPTCHA v2 widget, reads its parameters from the live page, gets a token from the SurSolver API and submits the form with it. The pieces are separate functions, so you can swap the widget-specific parts for another type later.
What the script does
The solving does not happen in your browser. Your script reads the page URL and the site key, sends them to the API, and receives a token. The browser's job is to load the page, receive the token into the right place and submit.
The order matters, because a reCAPTCHA v2 token is single-use and short-lived: about two minutes, and a shorter window is safer. Read the widget first, solve second, put the token into the page and submit immediately.
The solver client
This part is the same call-and-poll logic that the API examples use. API errors are HTTP 200 with errorId: 1, so it branches on errorId; polling runs every 3 seconds, at most 60 times. Because it uses await sleep(...), waiting for a solve does not block Node's event loop, and other work in the same process keeps running.
const { chromium } = require('playwright');
const { setTimeout: sleep } = require('node:timers/promises');
const API = 'https://api.sursolver.com';
const CLIENT_KEY = process.env.SURSOLVER_API_KEY;
const POLL_ATTEMPTS = 60;
const POLL_INTERVAL_MS = 3000;
const WIDGET_TIMEOUT_MS = 15000;
const PAGE_URL = 'https://example.com/form';
const SUBMIT_SELECTOR = 'button[type=submit]';
// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
return data;
}
async function solve(task) {
const created = await call('/createTask', { clientKey: CLIENT_KEY, task });
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
const result = await call('/getTaskResult', {
clientKey: CLIENT_KEY,
taskId: created.taskId,
});
if (result.status === 'ready') return result.solution;
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}
Detect the widget and read its parameters
Wait for the widget to exist, then read what the task needs from the DOM. The site key is the data-sitekey attribute of the g-recaptcha element; when the page builds the widget from JavaScript, it is the k parameter of the anchor iframe URL. Two more facts decide the task type. A script enterprise.js or an /enterprise/anchor request means reCAPTCHA v2 Enterprise, and size=invisible in the iframe URL means an invisible widget.
async function readWidget(page) {
await page
.locator('.g-recaptcha, iframe[src*="/recaptcha/"]')
.first()
.waitFor({ state: 'attached', timeout: WIDGET_TIMEOUT_MS });
return page.evaluate(() => {
const widget = document.querySelector('.g-recaptcha[data-sitekey]');
const frame = document.querySelector('iframe[src*="/recaptcha/"][src*="anchor"]');
const frameUrl = frame ? new URL(frame.src) : null;
const loadsEnterprise = [...document.scripts].some((script) =>
script.src.includes('/recaptcha/enterprise.js'),
);
return {
websiteKey: widget?.dataset.sitekey ?? frameUrl?.searchParams.get('k') ?? null,
enterprise: loadsEnterprise || (frameUrl?.pathname.includes('/enterprise/') ?? false),
invisible: frameUrl?.searchParams.get('size') === 'invisible',
callback: widget?.dataset.callback ?? null,
};
});
}
function buildTask(widget) {
const task = {
type: widget.enterprise ? 'ReCaptchaV2EnterpriseTaskProxyLess' : 'ReCaptchaV2TaskProxyLess',
websiteURL: PAGE_URL,
websiteKey: widget.websiteKey,
};
if (widget.invisible) task.isInvisible = true;
return task;
}
The script sends PAGE_URL as the full URL of the page that shows the widget. On Enterprise pages that pass extra values to grecaptcha.enterprise.render, such as s, add them as enterprisePayload and read them fresh for every task; this script does not. See the reCAPTCHA v2 guide for the variant details and the public catalog (GET /v1/task-types) for every parameter.
Inject the token and submit
There are two ways a page consumes the token. Most forms read the hidden g-recaptcha-response textarea when you submit. Pages with a data-callback expect the token to be passed to that function instead. The code handles both: it fills the textarea when it exists, calls the named callback when the widget declares one, and clicks submit only when there was no callback.
async function applyToken(page, widget, token) {
await page.evaluate(
({ callback, value }) => {
const field = document.getElementById('g-recaptcha-response');
if (field) field.value = value;
if (callback) window[callback](value);
},
{ callback: widget.callback, value: token },
);
}
async function submit(page, widget) {
// A callback usually submits or navigates by itself; otherwise click the submit button.
if (widget.callback) return null;
const [response] = await Promise.all([
page.waitForResponse((res) => res.request().method() === 'POST'),
page.locator(SUBMIT_SELECTOR).click(),
]);
return response;
}
If the callback name is a path such as app.onVerified, window[callback] will not resolve it; use the exact expression from the page's own script. The path of an anonymous callback inside ___grecaptcha_cfg varies between pages, so find it in the page rather than copying one from a tutorial.
The full script
The main function opens the page twice. The first visit only reads the widget. After the solve, a second context opens with the userAgent from the solution when the solver reported one (it is optional, so the code checks), and the token goes into that page, which is the one that submits.
async function openPage(browser, userAgent) {
const context = await browser.newContext(userAgent ? { userAgent } : {});
const page = await context.newPage();
await page.goto(PAGE_URL);
return page;
}
async function main() {
const browser = await chromium.launch();
try {
const probe = await openPage(browser);
const probed = await readWidget(probe);
await probe.context().close();
if (!probed.websiteKey) throw new Error('no site key found on the page');
const solution = await solve(buildTask(probed));
const page = await openPage(browser, solution.userAgent);
const widget = await readWidget(page);
await applyToken(page, widget, solution.gRecaptchaResponse);
const response = await submit(page, widget);
console.log(response ? `submitted, HTTP ${response.status()}` : 'callback invoked');
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with SURSOLVER_API_KEY=ssk_live_... node recipe.js after npm install playwright and npx playwright install chromium. Check the outcome on the page itself: an HTTP 200 from your own form says the post arrived, not that the site accepted the token.
Headed and headless
chromium.launch({ headless: false }) opens a visible window and is the better setting while you write the script, because you can watch the form being filled. Nothing in the SurSolver API depends on how you launch the browser: the solve runs on the service's side, and only the token and the optional userAgent come back. Whether a particular target treats headless sessions differently is a property of that site; the sources for this site do not document it, so test your target in both modes instead of assuming.
Common failures
no site key found: the widget is inside a frame you did not search, or it loads after a user action. Trigger the action first, or read thekparameter from the Network tab as shown in identifying any CAPTCHA.- The site rejects a valid-looking token: the page may use Enterprise while the task was standard, an invisible widget may have been solved without
isInvisible, or the token is older than about two minutes or was already used. Solve again; do not resubmit the old token. - Nothing happens after injection: the page probably waits for a callback instead of a form post. See how to submit a CAPTCHA token.
ERROR_NO_SLOT_AVAILABLE: the submission was refused and nothing was charged. Wait the(retry in Ns)hint and retry once; the rate limits guide has the defaults.- A task that was ready too late: a client timeout after 60 polls does not change the server task status. Fetch the same task later; a task that finishes as failed automatically returns its original charge once.
Next steps
Put your API key in SURSOLVER_API_KEY, point PAGE_URL at a form you are allowed to automate, and run the script once. The quickstart shows the same two calls with curl, and solving CAPTCHAs in a Python scraper covers the retry and proxy logic around them.