CAPTCHAs
The platform notices a CAPTCHA that is waiting for a person, tells you, and pauses its own automation until someone solves it. On paid plans a session can opt in to automatic solving first.
Some pages ask for a CAPTCHA: a check that a person is there. When a page in a session shows one that needs a person, Boxline notices it and tells you. Agent runs and plain-English steps then wait until someone has solved it in the live view.
On paid plans, a session can also opt in to automatic solving. The platform then tries to solve the CAPTCHA first, and asks a person only when it cannot.
What happens by default
- A page in the session shows a CAPTCHA that needs a person.
- The platform notices it within a few seconds. The session’s
attentionshows it, and acaptchaevent with state"detected"is logged. - Agent runs pause and plain-English steps wait. Your own Playwright or Puppeteer code is not paused: it decides for itself what to do.
- A person opens the session’s live view and solves it: you, a teammate, or one of your own users.
- The platform sees that it is gone.
attentiongoes back tonull, a"cleared"event is logged, and paused work continues by itself.
If nobody solves it in time (5 minutes for agent runs, 4 for steps), they fail with a clear error. The session itself keeps running until its own timeout, and is billed as usual while it waits.
Which CAPTCHAs are recognised
kind | What it is | Tried by "solve" |
|---|---|---|
recaptcha | Google reCAPTCHA: the checkbox, or its picture challenge. | Yes (reCAPTCHA v2) |
hcaptcha | hCaptcha. | Yes |
turnstile | A Cloudflare Turnstile widget on a page. | Yes |
cloudflare | Cloudflare’s full-page “Just a moment…” check. | No: a person is asked |
datadome | A DataDome challenge. | No: a person is asked |
arkose | An Arkose Labs puzzle. | No: a person is asked |
human | The press-and-hold button from HUMAN (PerimeterX). | No: a person is asked |
When a CAPTCHA counts as waiting for a person
- A widget is visible on the page and not answered yet, or the whole page is a challenge (Cloudflare’s “Just a moment…”, HUMAN’s press-and-hold).
- It is seen twice, about 2 seconds apart. A check that passes by itself within a couple of seconds, like most Cloudflare Turnstile checks, is never reported.
- Invisible widgets are not reported (for example reCAPTCHA v3, or invisible reCAPTCHA and hCaptcha until they show a challenge). They need nothing from a person.
- It is cleared when it has been answered, when it is gone, or when the page has moved on, again seen twice.
The platform looks after every page load and whenever a challenge appears, then every 2 seconds while one is open. With "ask" and "ignore" it only reads the page and never touches the widget. With "solve" it also fills in the answer (see Automatic solving). When several tabs have one, they are reported one at a time: the next one after the first is cleared.
The captcha option
Choose what happens when a CAPTCHA waits for a person. Set it when you create the session (or in the session of an agent run), and change it at any time with PATCH/v1/sessions/:id, in Update a session.
captcha | What happens |
|---|---|
"ask" (default, unless your project default says otherwise) | Agent runs pause and plain-English steps wait until a person has solved it, for up to 5 minutes (steps: 4). |
"ignore" | Agent runs and steps carry on as if nothing happened. attention and the captcha events still report it. |
"solve" | The platform tries to solve it first, and nobody is asked meanwhile. If it cannot or may not, a person is asked, exactly like "ask". Opt-in, on plans that include automatic CAPTCHA solving. See Automatic solving. |
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"browser": true, "captcha": "ignore"}'curl -X PATCH https://api.boxline.dev/v1/sessions/$SESSION_ID \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"captcha": "ask"}'import { Boxline } from "@boxline/sdk";const client = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst session = await client.sessions.create({ browser: true, captcha: "ignore" });// Later: wait for a person againawait session.update({ captcha: "ask" });from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsession = bx.sessions.create(captcha="ignore")# Later: wait for a person againsession.update(captcha="ask")Any other value gets 400 invalid_request. On a plan without automatic CAPTCHA solving, "solve" gets 402 feature_not_in_plan: on create, on update and in a run’s session. See Plans and features.
The project default
A session or agent run that does not set captcha (or sets it to null) gets your project’s captchaDefault: "ask" unless you change it. In the console, Settings → General, “When a CAPTCHA appears”: “Solve automatically” sets it to "solve".
- An explicit
captchaalways wins, and is checked against your plan as before. - Sessions already running keep the setting they started with.
- Crawls, and fetch, screenshot, PDF and extract, do not use it: they cannot wait for a person or solve, so they report the CAPTCHA’s kind. See below.
- If your plan loses automatic solving, a
"solve"default stays saved but new sessions get"ask":captchaDefaultEffectiveshows which.
curl -X PUT https://api.boxline.dev/v1/project/settings \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"captchaDefault": "solve"}'import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URLconst settings = await bx.project.setSettings({ captchaDefault: "solve" });console.log(settings.captchaDefaultEffective); // what new sessions get now (402 first if your plan cannot solve)from boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URLsettings = bx.project.set_settings(captcha_default="solve")print(settings["captchaDefaultEffective"]) # what new sessions get now (402 first if your plan cannot solve)See Project settings.
Automatic solving
With captcha: "solve", the platform tries to solve a CAPTCHA before anyone is asked. You turn it on per session. It is off by default.
Only plans that include automatic CAPTCHA solving can use it. By default that is every paid plan, and not Free.
What it tries
Three kinds: reCAPTCHA v2 (the checkbox and its picture challenge), hCaptcha and Cloudflare Turnstile. The others always go to a person (see the table above).
What happens
- The CAPTCHA is noticed as usual, and a
"detected"event is logged. - If it may be solved,
attention.stateis"solving". Nobody needs to act. Agent runs and plain-English steps wait. - The platform sends the page’s address and the widget’s public site key to a third-party solving service. It fills the answer into the widget, and logs
"solving"and"solved"events. - Once the page shows the widget answered, the CAPTCHA is cleared:
attentiongoes back tonull, a"cleared"event is logged, and paused work continues by itself.
Filling in the answer does not submit the page’s form. The agent, your step or your own code carries on from there, as after a person has solved it.
Each CAPTCHA is tried once. If it cannot or may not be solved, a person is asked, exactly like "ask": attention.state becomes "waiting" and attention.reason says why.
When a person is asked instead
| When | Event data.state | Event data.reason |
|---|---|---|
| The kind cannot be solved: Cloudflare’s “Just a moment…” page, DataDome, Arkose or HUMAN. | solve_refused | unsupported |
| The site is a government or military site (.gov, .mil and national forms such as .gov.uk or .gouv.fr), or one the platform never solves on. | solve_refused | blocked_site |
| Your plan does not include automatic solving, or this month’s allowance is used up. | solve_refused | plan_limit |
| Solving is switched off on the platform right now. | solve_refused | disabled |
| The solving service could not solve it, or the widget could not be read. | solve_failed | none |
Refusals are not billed. Their attention.reason says the same in words, for example “this kind of CAPTCHA cannot be solved automatically”.
How long work waits
- Agent runs wait while it is being solved, with a
handover.reasonthat says so. Their 5 minutes for a person start only if it becomes a person’s turn. - Plain-English steps wait up to 4 minutes in all, solving included.
waitForHuman/wait_for_humanwait through solving too. They return once no CAPTCHA is waiting, however it was solved.
Allowance and billing
- Every attempt that reached the solving service is billed, solved or not. Refusals are free.
- The price is per 1,000 attempts:
captchaSolving.per1000AttemptsinGET /v1/pricing. It may change during the beta, so read it from there or the pricing page. - Your plan sets a monthly allowance:
project.limits.captchaSolvesPerMonthinGET /v1/auth/me(0means none,nullmeans no cap). It counts attempts that reached the service in the calendar month (UTC), in all the projects of your organization together. It is a ceiling, not free attempts. - When the allowance is used up, CAPTCHAs go to a person (
plan_limit) until the 1st. Sessions keep working. GET /v1/project/usagereportscaptchaSolves, and its totalcostUsdincludes them.
"captchaSolves": { "solved": 41, "failed": 3, "refused": 7, "costUsd": …}Use it safely
- Turn it on only for the sessions that need it, and leave the others on
"ask". You can switch a running session in Update a session. The change applies within about 3 seconds, and a CAPTCHA that is already waiting is tried then. - The page’s full address goes to the solving service, query string included. Do not use
"solve"on pages whose address carries a secret or personal data, such as a sign-in link with a token in it. - Only the address and the widget’s public site key are sent. Your cookies, the page’s content and your API key are not.
- Watch the solving events and
captchaSolvesin usage. If a script keeps meeting CAPTCHAs, slow it down instead of paying for more attempts (see Meet fewer CAPTCHAs).
Example
Check the plan, start a session with "solve", and tell a person only when it is their turn:
curl -X POST https://api.boxline.dev/v1/sessions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"browser": true, "captcha": "solve"}'import { Boxline } from "@boxline/sdk";const client = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// "solve" needs a plan with automatic CAPTCHA solving (402 feature_not_in_plan otherwise).const captcha = (await client.hasFeature("captchaSolving")) ? "solve" : "ask";const session = await client.sessions.create({ browser: true, captcha });// Later, when a page does not look the way you expect:await session.refresh();const a = session.data.attention;if (a && a.state !== "solving") { // A person's turn. a.reason says why it was not solved (notifyOperator is your own function). await notifyOperator(`${a.kind} on ${a.url} (${a.reason ?? "needs a person"}): ${session.liveUrl}`);}await session.waitForHuman({ timeoutMs: 10 * 60_000 }); // returns once it is gone, however it was solvedfrom boxline import Boxlinebx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL# "solve" needs a plan with automatic CAPTCHA solving (402 feature_not_in_plan otherwise).features = bx.me()["project"]["limits"]["features"]session = bx.sessions.create(captcha="solve" if features["captchaSolving"] else "ask")# Later, when a page does not look the way you expect:attention = session.refresh().attentionif attention and attention.get("state") != "solving": # A person's turn. attention["reason"] says why it was not solved. notify_operator(f"{attention['kind']} on {attention['url']}: {session.live_url}")session.wait_for_human(timeout=600) # returns once it is gone, however it was solvedSee it on the session
While a CAPTCHA is open, the session object has an attention field. Otherwise it is null.
"captcha": "ask","attention": { "type": "captcha", "kind": "recaptcha", "url": "https://shop.example.com/login", "tabId": "…", "since": "2026-09-29T12:00:04.000Z", "state": "waiting"}| Field | Description |
|---|---|
type | Always "captcha". |
kind | Which CAPTCHA (see the table above). |
url | The page that shows it. |
tabId | The tab it is in. |
since | When it was first reported (ISO time). |
state | "waiting": a person’s turn (always, with "ask" and "ignore"). "solving": it is being solved automatically, and nobody needs to act. |
reason | Only when automatic solving was refused or failed: why, in words. |
With "solve", a CAPTCHA that could not be solved looks like this:
"captcha": "solve","attention": { "type": "captcha", "kind": "human", "url": "https://shop.example.com/login", "tabId": "…", "since": "2026-09-29T12:00:04.000Z", "state": "waiting", "reason": "this kind of CAPTCHA cannot be solved automatically"}Events
Each CAPTCHA logs two session events of type captcha: one when it starts waiting for a person (data.state: "detected", level: "warn") and one when it is gone ("cleared", with data.waitedMs: how long it waited).
With "solve", more events come in between:
data.state | When |
|---|---|
"detected" | A CAPTCHA started waiting (level: "warn"). |
"solving" | The solving service is working on it. |
"solved" | The answer was filled in. "cleared" follows once the page shows it answered. |
"solve_failed" | The service could not solve it. A person is asked (level: "warn"). |
"solve_refused" | It may not be solved. data.reason is unsupported, blocked_site, plan_limit or disabled (see the table above). A person is asked (level: "warn"). |
"cleared" | It is gone, with data.waitedMs. |
{ "seq": 41, "at": "2026-09-29T12:00:04.000Z", "type": "captcha", "level": "warn", "text": "reCAPTCHA on shop.example.com", "url": "https://shop.example.com/login", "tabId": "…", "data": { "kind": "recaptcha", "state": "detected" }}{ "seq": 57, "at": "2026-09-29T12:00:52.000Z", "type": "captcha", "text": "reCAPTCHA cleared on shop.example.com", "url": "https://shop.example.com/login", "tabId": "…", "data": { "kind": "recaptcha", "state": "cleared", "waitedMs": 48000 }}Read them with types=captcha, or follow them live on /v1/sessions/:id/events/stream:
curl "https://api.boxline.dev/v1/sessions/$SESSION_ID/events?types=captcha" \ -H "x-api-key: $BOXLINE_API_KEY"Agent runs
With captcha: "ask" or "solve", an agent run checks for a CAPTCHA before each of its steps. When one is waiting, the run pauses:
{ "status": "paused", "handover": { "by": "captcha", "reason": "reCAPTCHA on shop.example.com: solve it in the live view and the agent continues" }}- The run’s event stream (
/v1/agent/runs/:id/events/stream) sends{"type": "status", "status": "paused", "by": "captcha", "reason": "…"}. - With
"solve", thereasonfirst says it is being solved automatically, and that you are asked only if that fails. If it fails, the reason changes to say it is a person’s turn, and the stream sends a newstatusevent. - Once it is solved, the run continues by itself (
statusgoes back to"running"). The agent is told that a CAPTCHA was solved, and looks at the page again before it goes on. - You can also resume the run yourself with Resume a run (
POST /v1/agent/runs/:id/resume). If the CAPTCHA is still waiting, the run pauses again. - If nobody solves it within 5 minutes, the run fails with
status: "failed"and anerrorthat starts with “nobody solved the CAPTCHA within 5 minutes”. With"solve", the 5 minutes start only when it becomes a person’s turn.
Pass captcha in the run’s session to choose for the session it creates ("ask" by default; see the agent’s session). With sessionId, that session’s own option applies. With "ignore" the agent sees the CAPTCHA like any other page, and may still ask for help itself (handover.by: "agent").
This example sends someone the live view link when a run needs a person:
const { id, sessionId } = await client.agent.run({ task: "Sign in to shop.example.com and download this month's invoice",});const session = await client.sessions.get(sessionId);let notified = false;for (;;) { const run = await client.agent.get(id); if (run.status !== "running" && run.status !== "paused") { console.log(run.status, run.result ?? run.error); break; } const needsPerson = run.handover?.by === "captcha"; if (needsPerson && !notified) { // notifyOperator is your own function: chat, email, a ticket... await notifyOperator(`${run.handover!.reason}: ${session.liveUrl}`); } notified = needsPerson; await new Promise((r) => setTimeout(r, 2000));}import timestarted = bx.agent.run("Sign in to shop.example.com and download this month's invoice")session = bx.sessions.get(started["sessionId"])notified = Falsewhile True: run = bx.agent.get(started["id"]) if run["status"] not in ("running", "paused"): print(run["status"], run["result"] or run["error"]) break handover = run.get("handover") or {} needs_person = handover.get("by") == "captcha" if needs_person and not notified: # notify_operator is your own function: chat, email, a ticket... notify_operator(f"{handover['reason']}: {session.live_url}") notified = needs_person time.sleep(2)With "solve", the run is also paused while the CAPTCHA is being solved. Before you notify someone, check that the session’s attention.state is "waiting".
Plain-English steps
With captcha: "ask" or "solve", a plain-English step (session.step("click Sign in")) waits while a CAPTCHA is open, for up to 4 minutes in all, then runs as usual. With "solve", the time spent solving counts toward the 4 minutes.
If it is still there after 4 minutes, the step fails with the code captcha_timeout. The action result has ok: false, the code, and an error that names the CAPTCHA and the site:
{ "results": [ { "ok": false, "action": "step", "code": "captcha_timeout", "error": "reCAPTCHA on shop.example.com is still waiting for a person after 240 s: solve it in the live view, then run the step again", "ms": … } ]}The SDKs raise it as a BoxlineError with status 409 and code captcha_timeout, so you can tell it apart from other failures:
try { await session.step("click Sign in");} catch (err) { if (!(err instanceof BoxlineError) || err.code !== "captcha_timeout") throw err; // Nobody solved the CAPTCHA within 4 minutes. Tell someone, wait, then try again. await notifyOperator(`A CAPTCHA is waiting: ${session.liveUrl}`); await session.waitForHuman({ timeoutMs: 10 * 60_000 }); await session.step("click Sign in");}try: session.step("click Sign in")except BoxlineError as err: if err.code != "captcha_timeout": raise # Nobody solved the CAPTCHA within 4 minutes. Tell someone, wait, then try again. notify_operator(f"A CAPTCHA is waiting: {session.live_url}") session.wait_for_human(timeout=600) session.step("click Sign in")A step that waits keeps its request open for up to 4 minutes, which stays under the 5-minute limit of common HTTP clients such as Node's fetch (the Python SDK allows 7 minutes for requests with a step). When you expect a CAPTCHA, calling waitForHuman before the step avoids the long request.
Your own Playwright or Puppeteer code
The platform does not pause code that drives the browser over CDP. Your script decides what to do. With "solve", the platform fills in the answer while your code runs, so wait for it before you submit the page’s form. The SDKs have two helpers:
| Helper | What it does |
|---|---|
session.waitForHuman({ timeoutMs }) (TypeScript), session.wait_for_human(timeout=300) (Python) | Returns once no CAPTCHA is waiting (it was solved, by a person or automatically, or the page moved on). Returns at once if none is waiting. After the timeout (default 5 minutes) it throws a BoxlineError with code captcha_timeout. |
session.onCaptcha(handler) (TypeScript), session.on_captcha(handler) (Python) | Calls handler with { state, kind, url, event } (in Python a dict with the same keys) on every "detected" and "cleared" event. It checks every 2 seconds and stops by itself when the session stops. The function it returns stops it sooner. For the solving states, read the events yourself. |
The platform needs a few seconds to report a new CAPTCHA, so calling waitForHuman right after page.goto can return too early. Call it when the page does not look the way you expect:
import { Boxline, BoxlineError } from "@boxline/sdk";import { chromium } from "playwright";const client = new Boxline();const session = await client.sessions.create({ browser: true, keepAlive: true });// Tell someone as soon as a CAPTCHA needs a person (notifyOperator is your own function).const stop = session.onCaptcha(async ({ state, kind, url }) => { if (state === "detected") await notifyOperator(`${kind} on ${url}: ${session.liveUrl}`);});try { const browser = await chromium.connectOverCDP(session.connectUrl!); const page = browser.contexts()[0].pages()[0]; await page.goto("https://shop.example.com/login"); const email = page.getByLabel("Email"); try { await email.waitFor({ timeout: 10_000 }); } catch { // The form did not show up: maybe a CAPTCHA is waiting for a person. await session.waitForHuman({ timeoutMs: 5 * 60_000 }); } await email.fill("me@example.com");} catch (err) { if (err instanceof BoxlineError && err.code === "captcha_timeout") { console.error("Nobody solved the CAPTCHA in time"); } throw err;} finally { stop(); await session.stop();}from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutfrom boxline import Boxline, BoxlineErrorbx = Boxline()with bx.sessions.create(keep_alive=True) as session, sync_playwright() as p: browser = p.chromium.connect_over_cdp(session.connect_url) page = browser.contexts[0].pages[0] page.goto("https://shop.example.com/login") email = page.get_by_label("Email") try: email.wait_for(timeout=10_000) except PlaywrightTimeout: # The form did not show up: maybe a CAPTCHA is waiting for a person. attention = session.refresh().attention if attention: # notify_operator is your own function notify_operator(f"{attention['kind']} on {attention['url']}: {session.live_url}") try: session.wait_for_human(timeout=300) except BoxlineError as err: if err.code == "captcha_timeout": print("Nobody solved the CAPTCHA in time") raise email.fill("me@example.com")In Python, on_captcha does the same. To be told about every CAPTCHA, including the solving states, read the events yourself:
import time# Simple: be told when a CAPTCHA needs a person, and when it is gone.def on_change(change): if change["state"] == "detected": notify_operator(f"{change['kind']} on {change['url']}: {session.live_url}")stop = session.on_captcha(on_change) # call stop() to stop watching# Or read every captcha event yourself:after = 0while session.refresh().status == "RUNNING": batch = session.events(types=["captcha"], after=after) for event in batch.data: if event["data"]["state"] == "detected": notify_operator(f"{event['text']}: {session.live_url}") after = batch.next_after time.sleep(2)Let your own users solve it
When your product works on your users’ own accounts, the right person to answer a CAPTCHA is often that user. Send them the session’s liveUrl: they see the browser, solve the CAPTCHA with their mouse and keyboard, and your code carries on when waitForHuman returns or the "cleared" event arrives.
- Open it for them in a new tab or window, from a page of your product they are signed in to. Get a session returns a fresh one every time.
- The live view can be framed (in an
<iframe>) only by Boxline’s own apps, such as the console. Embedding it on your own site needs embed origins set for your project, which are not available yet.
Fetch, screenshot, PDF and crawl
One-shot requests cannot wait for a person, and automatic solving is for sessions only. They return the page as it is, and tell you when a CAPTCHA was waiting on it:
| API | Where to look |
|---|---|
POST /v1/fetch | captcha in the response: a kind, or null. |
POST /v1/screenshot, /v1/pdf | The x-page-captcha response header: a kind, or empty. |
POST /v1/crawls | captcha on each page in GET /v1/crawls/:id. |
curl -s -D - -o page.png -X POST https://api.boxline.dev/v1/screenshot \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"url": "https://shop.example.com/prices"}' | grep -i x-page-captchaconst page = await client.fetch("https://shop.example.com/prices", { format: "markdown" });if (page.captcha) { // The content is probably the challenge, not the page you wanted. console.log(`${page.captcha} on ${page.finalUrl}`);}// A crawl: which pages had a CAPTCHA waiting?const job = await client.crawl.start({ url: "https://shop.example.com", maxPages: 50 });const done = await client.crawl.wait(job.id);const blocked = done.data.filter((p) => p.captcha).map((p) => `${p.captcha} on ${p.url}`);page = bx.fetch("https://shop.example.com/prices", format="markdown")if page["captcha"]: # The content is probably the challenge, not the page you wanted. print(f"{page['captcha']} on {page['finalUrl']}")# A crawl: which pages had a CAPTCHA waiting?job = bx.crawl.start("https://shop.example.com", max_pages=50)done = bx.crawl.wait(job["id"])blocked = [f"{p['captcha']} on {p['url']}" for p in done["data"] if p["captcha"]]When a page you need is behind a CAPTCHA, open it in a session instead, so a person can solve it there, or leave that page out.
Meet fewer CAPTCHAs
Sites show CAPTCHAs to visitors they do not recognise or that behave unlike people. These habits make them rarer, without trying to trick anyone:
- Stay signed in. Start sessions from a browser profile so the account’s cookies come along, and save it back with
persist: true. A known, signed-in visitor is asked less often than a new one. - Keep one session for a whole task instead of many short ones.
- Use a steady address near the account’s usual place. A sticky residential IP in the country, state or city where the account usually signs in helps: sites often ask for a check when an account suddenly signs in from somewhere new. Do not switch IPs in the middle of a login.
- Keep the browser and the address in one story. With a proxy in another country, add
"browser": { "mode": "realistic" }so the clock, language andAccept-Languagematch it. See Browser mode. It does not spoof fingerprints or hide that the session is automated. - Slow down. Load fewer pages per minute, wait for pages to finish, and do not open the same page over and over. The crawl API follows robots.txt and its crawl delay for you.
- Use the site’s own API when it has one.
- Respect the site’s terms. If a site does not allow automated access, do not automate it.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.