CAPTCHAs
The platform notices a CAPTCHA that is waiting for a person, tells you, and pauses its own automation until someone solves it. It never solves CAPTCHAs itself.
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.
What happens
- 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 |
|---|---|
recaptcha | Google reCAPTCHA: the checkbox, or its picture challenge. |
hcaptcha | hCaptcha. |
turnstile | A Cloudflare Turnstile widget on a page. |
cloudflare | Cloudflare’s full-page “Just a moment…” check. |
datadome | A DataDome challenge. |
arkose | An Arkose Labs puzzle. |
human | The press-and-hold button from HUMAN (PerimeterX). |
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. It only reads the page; it never touches the widget. 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 agent runs and steps do when a CAPTCHA waits for a person. Set it when you create the session, and change it at any time with PATCH/v1/sessions/:id.
captcha | What happens |
|---|---|
"ask" (default) | 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. |
import { Boxline } from "@boxline/sdk";
const client = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL
const session = await client.sessions.create({ browser: true, captcha: "ignore" });
// Later: wait for a person again
await session.update({ captcha: "ask" });from boxline import Boxline
bx = Boxline() # reads BOXLINE_API_KEY and BOXLINE_API_URL
session = bx.sessions.create(captcha="ignore")
# Later: wait for a person again
session.update(captcha="ask")curl -X POST https://api.staging.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.staging.boxline.dev/v1/sessions/$SESSION_ID \
-H "x-api-key: $BOXLINE_API_KEY" \
-H "content-type: application/json" \
-d '{"captcha": "ask"}'Any other value gets 400 invalid_request. There is no “solve” value.
See it on the session
While a CAPTCHA waits for a person, 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"
}| 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). |
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).
{
"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.staging.boxline.dev/v1/sessions/$SESSION_ID/events?types=captcha" \
-H "x-api-key: $BOXLINE_API_KEY"Agent runs
With captcha: "ask", 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) sends{"type": "status", "status": "paused", "by": "captcha", "reason": "…"}. - Once a person has solved it, the run continues by itself (
statusgoes back to"running"). The agent is told that a person solved a CAPTCHA, and looks at the page again before it goes on. - You can also hand back yourself with
POST /v1/agent/runs/:id/handback. 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”.
A run started without sessionId creates its session with captcha: "ask". To run an agent with "ignore", create the session yourself and pass its sessionId. 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 time
started = bx.agent.run("Sign in to shop.example.com and download this month's invoice")
session = bx.sessions.get(started["sessionId"])
notified = False
while 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)Plain-English steps
With captcha: "ask", a plain-English step (session.step("click Sign in")) waits while a CAPTCHA waits for a person, for up to 4 minutes, then runs as usual.
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. 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 for a person (someone solved it, 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) | Calls handler with { state, kind, url, event } on every "detected" and "cleared" event. It checks every 2 seconds and stops by itself when the session ends. The function it returns stops it sooner. |
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.release();
}from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
from boxline import Boxline, BoxlineError
bx = 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")The Python SDK has no onCaptcha. To be told about every CAPTCHA, read the events yourself:
import time
after = 0
while 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["nextAfter"]
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 fresh one with
GET /v1/sessions/:id/live. - 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. 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/crawl | captcha on each page in GET /v1/crawl/:id. |
const 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"]]curl -s -D - -o page.png -X POST https://api.staging.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-captchaWhen 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 context 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.