Puppeteer guide

Handle captchas in Puppeteer flows

Find the widget’s sitekey with page.evaluate, get a token from bypass.so and write it into the form before Puppeteer clicks submit. Image captchas go in as element screenshots.

  • Node.js 18+
  • Puppeteer
  • ES modules
  • No plugin
example run
  1. 0.0sopen example.com/contactloaded
  2. 0.4sprobe · TurnstileTask 0x4AAA…found
  3. 0.6sfill name, email, messagedone
  4. 0.7sPOST /createTasktaskId
  5. 2.7sGET /getTaskResult/7f3c…processing
  6. 6.8sGET /getTaskResult/7f3c…ready
  7. 6.9sinject cf-turnstile-response1 field
  8. 7.4ssubmit form200
Example run of the flow described on this page

Setup

Before you start

  • Node.js 18 or newer
  • Puppeteer, which downloads a compatible Chrome on install
  • A bypass.so API key (create one) with a positive balance

Install

Shellterminal
npm install puppeteer

Store your key

The examples read your key from the BYPASS_API_KEY environment variable, so it never lands in source control.

ShellmacOS / Linux
export BYPASS_API_KEY="your-api-key"
PowerShellWindows PowerShell
$env:BYPASS_API_KEY = "your-api-key"

Check that the key works: curl -H "Authorization: Bearer $BYPASS_API_KEY" https://bypass.so/balance returns your balance.

Overview

How it fits together

Your Puppeteer session stays in charge of the page. bypass.so only sees the sitekey and the page URL you send, and returns a token. The whole round trip is four steps:

  1. 01

    Read the sitekey

    Run a small probe in the page to find the widget type and its public sitekey.

    page.evaluate
  2. 02

    Create a task

    Send the type, sitekey and page URL to the API.

    POST /createTask
  3. 03

    Wait for the token

    Poll every two seconds until the result is ready.

    GET /getTaskResult
  4. 04

    Inject and submit

    Write the token into the response field, fire the callback, submit.

    injectToken

Project layout used below:

Treefiles
bypass.mjs
page-helpers.mjs
contact.mjs

Step 1

Add the API helper

This is the same helper as in the Node.js guide: one solve function that creates a task, polls for the result, raises a clear error and enforces a timeout.

JavaScriptbypass.mjs
const API_URL = "https://bypass.so";
const CLIENT_KEY = process.env.BYPASS_API_KEY ?? "";

export class BypassError extends Error {
  constructor(code, description = "", taskId = null) {
    super(`${code}: ${description}`);
    this.name = "BypassError";
    this.code = code;
    this.taskId = taskId;
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function call(path, init = {}) {
  const res = await fetch(API_URL + path, { ...init, signal: AbortSignal.timeout(30_000) });
  let data;
  try {
    data = await res.json();
  } catch {
    throw new BypassError("HTTP_ERROR", `HTTP ${res.status} without a JSON body`);
  }
  if (data.errorId) {
    throw new BypassError(data.errorCode ?? "ERROR_UNKNOWN", data.errorDescription ?? "", data.taskId);
  }
  return data;
}

export async function solve(type, params, { timeoutMs = 120_000, pollMs = 2_000 } = {}) {
  if (!CLIENT_KEY) {
    throw new BypassError("ERROR_KEY_INVALID", "set the BYPASS_API_KEY environment variable");
  }
  let data = await call("/createTask", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ clientKey: CLIENT_KEY, type, ...params }),
  });
  const taskId = data.taskId;
  const deadline = Date.now() + timeoutMs;
  while (data.status !== "ready") {
    if (Date.now() > deadline) {
      throw new BypassError("CLIENT_TIMEOUT", `task ${taskId} was not ready after ${timeoutMs / 1000}s`, taskId);
    }
    await sleep(pollMs);
    data = await call(`/getTaskResult/${taskId}?clientKey=${encodeURIComponent(CLIENT_KEY)}`);
  }
  return { ...data.solution, taskId };
}

export const tokenOf = (solution) => solution.gRecaptchaResponse ?? solution.token ?? solution.text;

export const balance = () =>
  call("/balance", { headers: { Authorization: `Bearer ${CLIENT_KEY}` } });

export const reportBad = (taskId) =>
  call(`/reportBad?task_id=${encodeURIComponent(taskId)}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${CLIENT_KEY}` },
  });

Step 2

Read the sitekey from the page

reCAPTCHA, hCaptcha and Turnstile all publish a sitekey in the page: usually a data-sitekey attribute on the widget element, otherwise a parameter in the widget’s iframe URL. The probe below checks both, tells you which widget it found and picks up the data-callback name if the page sets one. The second function writes a token into the right field later.

JavaScriptpage-helpers.mjs
export const FIELDS = {
  RecaptchaV2Task: "g-recaptcha-response",
  RecaptchaV3Task: "g-recaptcha-response",
  HCaptchaTask: "h-captcha-response",
  TurnstileTask: "cf-turnstile-response",
};

export function probeWidget() {
  const kinds = [
    ["TurnstileTask", ".cf-turnstile", "iframe[src*='challenges.cloudflare.com']"],
    ["HCaptchaTask", ".h-captcha", "iframe[src*='hcaptcha.com']"],
    ["RecaptchaV2Task", ".g-recaptcha", "iframe[src*='/recaptcha/']"],
  ];
  for (const [type, box, frame] of kinds) {
    const host = document.querySelector(box + "[data-sitekey]");
    if (host) return { type, sitekey: host.dataset.sitekey, callback: host.dataset.callback || null };
    const iframe = document.querySelector(frame);
    if (iframe) {
      const url = new URL(iframe.src, location.href);
      const hash = new URLSearchParams(url.hash.slice(1));
      const sitekey = url.searchParams.get("k") || url.searchParams.get("sitekey")
        || hash.get("sitekey") || (url.pathname.match(/0x4[\w-]+/) || [])[0];
      if (sitekey) return { type, sitekey, callback: null };
    }
  }
  const any = document.querySelector("[data-sitekey]");
  if (!any) return null;
  const key = any.dataset.sitekey;
  const type = key.startsWith("0x4") ? "TurnstileTask"
    : /^[0-9a-f-]{36}$/i.test(key) ? "HCaptchaTask" : "RecaptchaV2Task";
  return { type, sitekey: key, callback: any.dataset.callback || null };
}

export function injectToken({ field, token, callback }) {
  let targets = [...document.querySelectorAll('[name="' + field + '"]')];
  if (field === "h-captcha-response") {
    targets.push(...document.querySelectorAll('[name="g-recaptcha-response"]'));
  }
  if (!targets.length) {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = field;
    (document.querySelector("form") || document.body).appendChild(input);
    targets = [input];
  }
  for (const el of targets) {
    el.value = token;
    el.dispatchEvent(new Event("input", { bubbles: true }));
    el.dispatchEvent(new Event("change", { bubbles: true }));
  }
  const fn = callback && callback.split(".").reduce((o, k) => (o ? o[k] : undefined), window);
  if (typeof fn === "function") fn(token);
  return targets.length;
}

Wait for the probe to return something, because many widgets load after the rest of the page:

JavaScriptstep 2
const handle = await page.waitForFunction(probeWidget, { timeout: 20_000 });
const widget = await handle.jsonValue();

Step 3

Request a token

Send the widget type, its sitekey and the exact URL of the page the widget is on. Fill in the rest of the form before this step, because tokens are short-lived.

JavaScriptstep 3
const solution = await solve(widget.type, {
  websiteURL: page.url(),
  websiteKey: widget.sitekey,
});

What comes back

For token types the solution holds the token (in token or gRecaptchaResponse) and usually tokenField, the name of the field it belongs in. The helper’s tokenOf reads either key.

Step 4

Inject the token and submit

Write the token into the hidden response field, call the page’s callback if it has one, then submit the form as a user would. If the field does not exist yet, injectToken creates it inside the form.

JavaScriptstep 4
const field = solution.tokenField ?? FIELDS[widget.type];
await page.evaluate(injectToken, { field, token: tokenOf(solution), callback: widget.callback });
await Promise.all([page.waitForNavigation(), page.click('form [type="submit"]')]);

Field reference

WidgetWhere the sitekey livesWhere the answer goes
reCAPTCHA v2RecaptchaV2Task
.g-recaptcha[data-sitekey] or the k parameter of its iframe
textarea[name="g-recaptcha-response"]
reCAPTCHA v3 Coming soonRecaptchaV3Task
render= parameter of the api.js script tag
The field or request body the page sends, usually g-recaptcha-response
hCaptcha Coming soonHCaptchaTask
.h-captcha[data-sitekey] or sitekey in the iframe URL
textarea[name="h-captcha-response"] plus g-recaptcha-response if present
TurnstileTurnstileTask
.cf-turnstile[data-sitekey] (keys start with 0x4)
input[name="cf-turnstile-response"]
ImageImageToTextTask
Screenshot of the captcha <img> as base64
Type solution.text into the answer input

Full code

Complete example

Everything above in one file. It opens a contact form, fills it, finds the captcha, gets a token, injects it and submits. Replace the URL and field names with your own page.

JavaScriptcontact.mjs
import puppeteer from "puppeteer";
import { solve, tokenOf } from "./bypass.mjs";
import { FIELDS, injectToken, probeWidget } from "./page-helpers.mjs";

const PAGE_URL = "https://example.com/contact";

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });

  const handle = await page.waitForFunction(probeWidget, { timeout: 20_000 });
  const widget = await handle.jsonValue();
  console.log("found", widget.type, widget.sitekey);

  await page.type('input[name="name"]', "Ada");
  await page.type('input[name="email"]', "[email protected]");
  await page.type('textarea[name="message"]', "Hello from my test run");

  const solution = await solve(widget.type, { websiteURL: page.url(), websiteKey: widget.sitekey });
  const field = solution.tokenField ?? FIELDS[widget.type];
  await page.evaluate(injectToken, { field, token: tokenOf(solution), callback: widget.callback });

  await Promise.all([
    page.waitForNavigation({ waitUntil: "domcontentloaded" }),
    page.click('form [type="submit"]'),
  ]);
  console.log("submitted, now at", page.url());
} finally {
  await browser.close();
}
Shellrun it
node contact.mjs
Outputexpected output
found TurnstileTask 0x4AAAAAAA-SITE-KEY
submitted, now at https://example.com/contact/thanks

What it costs

Recognition

Image captchas: screenshot to text

For classic text-in-an-image captchas there is no token. Take a screenshot of just the image element, send it as imageBase64 and type the returned text into the answer box. A screenshot works even when the image URL changes on every request.

JavaScriptimage-captcha.mjs
const image = await page.$("img.captcha");
const imageBase64 = await image.screenshot({ encoding: "base64" });
const { text } = await solve("ImageToTextTask", { imageBase64 });

await page.type('input[name="captcha"]', text);
await Promise.all([page.waitForNavigation(), page.click('form [type="submit"]')]);

Math and audio

For a written sum, read the text and send it as captchaText with MathCaptchaTask. For audio, download the clip in your session and send it base64-encoded as audioBase64 with AudioCaptchaTask.

Coming soon

reCAPTCHA v3 pages

Coming soon

RecaptchaV3Task is not live yet: createTask currently returns ERROR_TASK_NOT_SUPPORTED and nothing is charged. The steps below are ready for launch day. Get notified.

reCAPTCHA v3 has no visible widget. The sitekey is the render parameter of the script tag, and the page asks for a token with an action name when the user submits. Find that name in the page source (look for grecaptcha.execute) and send the same value.

JavaScriptrecaptcha v3
const siteKey = await page.evaluate(() => {
  const s = document.querySelector("script[src*='recaptcha'][src*='render=']");
  return s ? new URL(s.src).searchParams.get("render") : null;
});
const solution = await solve("RecaptchaV3Task", {
  websiteURL: page.url(),
  websiteKey: siteKey,
  action: "submit",
});

Where the token goes

Version 3 pages often send the token from JavaScript rather than a form field. Watch the network request the page makes on submit and put the token in the same field or parameter.

Errors

Troubleshooting

ErrorSourceWhat it meansHow to fix it
ERROR_KEY_INVALID
HTTP 403
The key is missing, mistyped or revoked.
Check that BYPASS_API_KEY is set in the same shell that runs your code, with no quotes or spaces around it.
ERROR_INSUFFICIENT_CREDITS
HTTP 402
Your balance cannot cover the task price.
Top up from the dashboard. Nothing was charged for this request.
ERROR_TASK_NOT_SUPPORTED
HTTP 200
The type string is unknown, or the type is still coming soon (reCAPTCHA v3, hCaptcha).
Copy the exact name, for example TurnstileTask. Names are case-sensitive. Nothing is charged.
ERROR_BAD_REQUEST
HTTP 400
The body is not valid JSON, or a field has the wrong type.
Send Content-Type: application/json and string values for clientKey, type and the task fields.
ERROR_CAPTCHA_UNSOLVABLE
HTTP 200
This task could not be completed.
You are not charged. Check the image, sitekey and page URL, then submit a new task.
ERROR_NOT_FOUND
HTTP 404
The taskId is unknown or belongs to another key.
Poll with the taskId returned by your own createTask call, using the same key.
ERROR_INTERNAL
HTTP 500
Something went wrong on our side.
Retry after a short pause. The helper’s retry wrapper does this for you.
No widget found
your code
The probe returned nothing within 20 seconds.
The widget may render late or only after a click. Wait for the form, scroll it into view, or pass the sitekey yourself.
Site rejects the token
your code
The token expired, or it was requested for a different URL or sitekey.
Request the token right before you submit, and send the exact page URL as websiteURL.
Form still asks for the captcha
your code
The page waits for its widget callback before enabling submit.
Pass the data-callback name to injectToken (the probe does this when the attribute exists).
Helper timeout
your code
No result inside your timeout.
Raise the timeout for token types, then retry with a new task.

Every error body has the same shape: {"errorId":1,"errorCode":"…","errorDescription":"…"}. Full list in the API reference.

Production

Best practices

  • Keep the key out of code

    Read it from an environment variable or your secret store. Never commit it or ship it to a web page.

  • Solve right before submit

    Tokens are short-lived. Fill the form first, then request the token and submit at once.

  • Cap your concurrency

    Start with 5–10 parallel tasks and raise it while you watch error rates and spend.

  • Log every taskId

    It is the only thing you need to debug a result or report a wrong answer.

  • Match the page exactly

    Send the URL where the widget renders and the sitekey from that page, not from a different form.

  • Report wrong answers

    If a site rejects a solved result, call reportBad with its taskId. The price returns to your balance once.

FAQ

Questions

Do I need to click the captcha widget?

No. The probe only reads attributes from the page. You request a token through the API and place it in the hidden field the widget would have filled. The checkbox does not need to look ticked, because the site checks the token, not the widget’s appearance.

The submit button stays disabled. What now?

Some pages enable the button from the widget’s JavaScript callback. When the widget element has a data-callback attribute, the probe returns it and injectToken calls that function with the token. If the callback is registered another way, find its name in the page source and pass it yourself.

Does this work for invisible reCAPTCHA v2?

Yes. Use RecaptchaV2Task. The sitekey is on the element with data-sitekey (often the submit button) or in the k parameter of the widget iframe. Invisible widgets almost always rely on a callback, so pass it along.

How long does a token stay valid?

Not long. reCAPTCHA tokens expire after about two minutes, and Turnstile results include tokenLifetime when it is known. Fill the form first, then request the token and submit straight away.

Can I run many sessions in parallel?

Yes. Every call to solve is independent. Run one per session and cap how many run at once so a slow site does not pile up open tasks.

Policy

Responsible use

Use it where you are allowed to

Automate pages you own, test environments you run, or sites whose terms permit automated access. Do not use bypass.so for fraud, account takeover, spam, scalping, evading bans or collecting personal data without a lawful basis. Keep request rates reasonable and stop when a site asks you to. Our acceptable use policy applies to every key, and keys used for abuse are closed.

Keep going

Next steps