BASIC ($15/month, 5 threads) lets five CAPTCHAs be solved at the same moment and puts no limit on how many you solve over the month. The real question is how many you need in flight at once: ten reCAPTCHA v2 tasks a minute at about 30 seconds each fill all five threads, while Turnstile or image CAPTCHAs go much further. Below are the per-type capacity of five threads, a formula for your own traffic, Python and Node.js clients that never ask for a sixth thread, and the measurements that say it is time for STANDARD ($30/month, 15 threads).
What the $15 actually buys
A CaptchaAI plan is a number of concurrent threads, not a bundle of solves. On BASIC ($15/month, 5 threads) that means:
- Five tasks in flight. A thread is one CAPTCHA being worked on; when the solve finishes, it takes the next task.
- Unlimited solves per thread. No monthly quota and no daily cap.
- One price for every type. A GeeTest v3 puzzle and an image CAPTCHA occupy a thread the same way, with no per-type surcharge.
Because the price is flat, cost per solve falls the harder you use the threads: at 10,000 reCAPTCHA v2 solves a month the $15 works out to $1.50 per 1,000, at 100,000 to $0.15. CaptchaAI's sizing guidance puts this plan at up to 100,000 reCAPTCHA v2 solves a month; the sections below show where that number comes from. See how the unlimited-solves model works for the billing mechanics and the CaptchaAI pricing page for current prices.
Five threads, type by type
Most GA types have a published speed ceiling, the longest a solve should take. Five busy threads at those ceilings give the table below. The monthly column is theoretical: it assumes all five threads busy every second of a 30-day month and every solve taking the full ceiling. Solves faster than the ceiling push the real figure up; idle gaps, poll delay and retries pull it down.
| CAPTCHA type | Speed ceiling | Solves per minute, 5 threads busy, every solve at the ceiling | Theoretical monthly volume on 5 threads |
|---|---|---|---|
| Image / OCR | <0.5 s | 600 | ~26,000,000 |
| Grid image | <1 s | 300 | ~13,000,000 |
| BLS | <1 s | 300 | ~13,000,000 |
| reCAPTCHA v3 | <4 s | 75 | ~3,240,000 |
| reCAPTCHA v3 Enterprise | <4 s | 75 | ~3,240,000 |
| Cloudflare Turnstile | <10 s | 30 | ~1,295,000 |
| GeeTest v3 | <12 s | 25 | ~1,080,000 |
| Cloudflare Challenge | <15 s | 20 | ~865,000 |
| reCAPTCHA v2 Invisible | <30 s | 10 | ~432,000 |
| reCAPTCHA v2 | <60 s | 5 | ~216,000 |
| reCAPTCHA v2 Enterprise | <60 s | 5 | ~216,000 |
For planning, use the working figure instead: CaptchaAI sizes plans on a conservative 50,000 reCAPTCHA v2 solves per thread per month. A realistic average of about 30 seconds per solve would allow roughly 80,000 on a thread that never idles, so the working figure keeps about 40% in reserve. At 100,000 a month only one or two threads are busy on average, and the rest absorb peaks.
Three details the table does not show:
- Beta types are not in it. CaptchaFox (beta), Friendly Captcha (beta) and Lemin (beta) have no published speed figures; measure them on your own pages.
- Two types need a proxy on every task. Cloudflare Challenge and CaptchaFox (beta) require
proxyandproxytype, and the proxy guide says proxy use is disabled on accounts by default. Ask support to enable it before you count on either type. - For fast types, your polling is the bottleneck. A client that holds a slot through a 5-second first wait finishes one solve per slot every five seconds: 60 image solves a minute from five slots, roughly 2.6 million a month. For normal image (OCR) CAPTCHAs, the one-shot
solve.phpendpoint takes the same fields asin.phpand returns the answer in its own response, so no slot sits on a poll timer.
Turn your traffic into a thread count
Little's law from queueing theory: the average number of tasks in flight equals the arrival rate multiplied by the time each task spends in the system.
threads in use = CAPTCHAs per second × seconds per solve
- 10 reCAPTCHA v2 per minute at about 30 seconds each: (10 ÷ 60) × 30 = 5 threads, the plan fully booked. Any spike above that rate waits in a queue.
- 100,000 reCAPTCHA v2 per month, spread evenly: 100,000 ÷ 43,200 minutes ≈ 2.3 per minute, × 0.5 minutes ≈ 1.2 threads. Peaks up to about four times the average still fit. That headroom is the reason for the 100,000 ceiling: sized at the 50,000 working figure, 100,000 a month takes two threads and keeps three in reserve.
- 30 Turnstile tokens per minute: (30 ÷ 60) × at most 10 seconds = at most 5 threads.
Use the rate of your busiest hour, not the monthly total spread evenly. A job that runs two hours a night squeezes a month of traffic into about 60 hours, twelve times the even-spread rate.
When arrivals outrun capacity, the backlog grows in a straight line. Five threads at 30 seconds per solve clear 10 reCAPTCHA v2 a minute; feed them 12 a minute for half an hour and 60 tasks are waiting, six minutes of extra delay for the last one.
From threads to browser or HTTP workers
A Playwright or Selenium worker only holds a CaptchaAI thread while it waits for a solve; the rest of the time it navigates, parses and fills forms. With T as the seconds a worker spends per page outside the CAPTCHA, p as the share of page loads that show one, and S as the solve time:
threads busy ≈ workers × (p × S) ÷ (T + p × S)
Rearranged for five threads, the most workers BASIC keeps busy without a queue is 5 × (T + p × S) ÷ (p × S). With T = 10 seconds:
| Page loads that show a CAPTCHA | reCAPTCHA v2 (S = 30 s) | Cloudflare Turnstile (S = 10 s, the ceiling) |
|---|---|---|
| Every page (p = 1) | 6 workers | 10 workers |
| 1 in 4 (p = 0.25) | 11 workers | 25 workers |
| 1 in 20 (p = 0.05) | 38 workers | 105 workers |
Take p and T from your own logs. In Playwright Test the worker count is the workers option in playwright.config or the --workers flag (see the Playwright parallelism docs); on a Selenium Grid it is the number of concurrent sessions. A few workers over the line is fine if your client caps itself at five: the extras wait locally, and that wait shows how far over you are. An HTTP-only pipeline can hold hundreds of requests open; only those parked on a CAPTCHA count against the five. Run all of this against your own applications, staging environments, or sites you are authorized to automate.
Read your plan from the API
Two res.php actions report on the plan. Despite its name, getbalance returns the number of threads on your plan, not a money balance, so a single BASIC plan should answer 5. threadsinfo returns JSON with threads (the plan total, as a string) and working_threads (threads busy right now, as an integer). Both are in the CaptchaAI API documentation.
# Plan threads (not money): a single BASIC plan should print 5
curl -s "https://ocr.captchaai.com/res.php?key=${CAPTCHAAI_API_KEY}&action=getbalance"
echo
# Plan threads and how many are busy at this moment
curl -s -X POST https://ocr.captchaai.com/res.php \
-F "key=${CAPTCHAAI_API_KEY}" \
-F "action=threadsinfo"
echo
Expected output on a BASIC plan with two tasks running:
5
{"threads":"5","working_threads":2}
Sample threadsinfo once a minute through your busiest hour; the share of samples reading 5 of 5 shows how tight the plan is:
for i in $(seq 60); do
curl -s -X POST https://ocr.captchaai.com/res.php \
-F "key=${CAPTCHAAI_API_KEY}" -F "action=threadsinfo"
echo
sleep 60
done | tee threadsinfo.log
grep -c '"working_threads":5}' threadsinfo.log # samples with all five threads busy
Keep your client at five
Send a sixth task while five are running and in.php can answer ERROR_ZERO_BALANCE, which the API reference describes as "You don't have free threads". Comments in the official Python SDK say the API may instead queue a surplus task silently, which shows up only as slower solves. Either way, queue on your side, where you can measure the wait.
Both scripts below:
- Call
getbalanceonce and use the answer as the pool size (5 on BASIC). Anything but a positive number means a key or plan problem, so they stop before sending a task. - Submit to
https://ocr.captchaai.com/in.phpwithmethod=userrecaptcha,googlekey(the widget's sitekey),pageurlandjson=1; success is{"status":1,"request":"<task ID>"}. - Wait 20 seconds, then call
res.phpwithaction=getand the taskidevery 5 seconds untilstatusis 1 and the token is inrequest.CAPCHA_NOT_READYmeans keep waiting; they give up at 120 seconds. - On
ERROR_ZERO_BALANCE, askthreadsinfo: all threads busy means back off and retry (8, 16, 24, 32 and 40 seconds, about two minutes in all), free threads mean the key has no active plan. - Stop sending new tasks on
ERROR_WRONG_USER_KEY,ERROR_KEY_DOES_NOT_EXIST,IP_BANNEDor a key with no active plan. Retry transient server errors and the rare HTML 500/502 page, and fail only the affected job on anything else, such asERROR_CAPTCHA_UNSOLVABLE.
The Python version uses asyncio.Semaphore as the cap and httpx for requests (pip install httpx):
"""basic_pool.py: solve reCAPTCHA v2 jobs with no more tasks in flight than the plan has threads."""
import asyncio
import os
import sys
import time
import httpx
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")
BASE = "https://ocr.captchaai.com"
FIRST_POLL, POLL_EVERY, MAX_WAIT = 20, 5, 120
STOP = {"ERROR_WRONG_USER_KEY", "ERROR_KEY_DOES_NOT_EXIST", "IP_BANNED"}
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}
halted = [] # first stop reason; once set, queued jobs are never sent
class StopRun(Exception):
"""Retrying cannot help: bad key, banned IP or no active plan."""
def retryable(code):
return code in TRANSIENT or code.startswith("<") # "<": an HTML 500/502 page
async def api(client, path, **fields):
r = await client.post(f"{BASE}/{path}", data={"key": API_KEY, "json": 1, **fields})
text = r.text.strip()
if not text.startswith("{"): # plain-text code or an HTML error page
return {"status": 0, "request": text[:60]}
return r.json()
async def submit(client, sitekey, pageurl):
for attempt in range(1, 7):
res = await api(client, "in.php", method="userrecaptcha",
googlekey=sitekey, pageurl=pageurl)
code = str(res.get("request"))
if res.get("status") == 1:
return code # task ID
if code in STOP:
raise StopRun(code)
if code == "ERROR_ZERO_BALANCE":
info = await api(client, "res.php", action="threadsinfo")
if int(info.get("working_threads", 0)) < int(info.get("threads", 0)):
raise StopRun("ERROR_ZERO_BALANCE with free threads: no active plan on this key")
elif not retryable(code):
raise RuntimeError(f"in.php: {code}") # fix the request, do not resend it
if attempt < 6:
await asyncio.sleep(8 * attempt) # threads full or server hiccup: back off
raise RuntimeError("in.php: no task accepted after 6 attempts")
async def poll(client, task_id):
await asyncio.sleep(FIRST_POLL)
for _ in range(FIRST_POLL, MAX_WAIT + 1, POLL_EVERY):
res = await api(client, "res.php", action="get", id=task_id)
code = str(res.get("request"))
if res.get("status") == 1:
return code # g-recaptcha-response token
if code in STOP:
raise StopRun(code)
if code != "CAPCHA_NOT_READY" and not retryable(code):
raise RuntimeError(f"task {task_id}: {code}")
await asyncio.sleep(POLL_EVERY)
raise TimeoutError(f"task {task_id}: no answer after {MAX_WAIT}s")
async def solve(client, slots, n, sitekey, pageurl):
queued = time.monotonic()
async with slots: # only as many jobs as the plan has threads get past this line
if halted:
raise StopRun(f"not sent, run stopped: {halted[0]}")
start = time.monotonic()
try:
token = await poll(client, await submit(client, sitekey, pageurl))
except StopRun as exc:
halted.append(str(exc))
raise
print(f"job {n:2}: {start - queued:5.1f}s waiting for a slot, "
f"{time.monotonic() - start:5.1f}s solving, token {token[:16]}...")
return token
async def main():
if len(sys.argv) < 3 or API_KEY in ("", "YOUR_API_KEY"):
sys.exit("usage: CAPTCHAAI_API_KEY=... python basic_pool.py SITEKEY PAGEURL [JOBS]")
sitekey, pageurl = sys.argv[1], sys.argv[2]
if sitekey == "YOUR_SITE_KEY" or not pageurl.startswith(("http://", "https://")):
sys.exit("pass the widget's real sitekey and the full URL of the page that shows it")
jobs = int(sys.argv[3]) if len(sys.argv) > 3 else 12
async with httpx.AsyncClient(timeout=30) as client:
plan = str((await api(client, "res.php", action="getbalance")).get("request"))
if not plan.isdigit() or int(plan) < 1:
sys.exit(f"getbalance returned {plan!r}: check the key and that a plan is active")
print(f"plan threads: {plan}")
slots = asyncio.Semaphore(int(plan)) # 5 on a single BASIC plan
results = await asyncio.gather(
*(solve(client, slots, n, sitekey, pageurl) for n in range(1, jobs + 1)),
return_exceptions=True)
failed = [r for r in results if isinstance(r, Exception)]
print(f"{jobs - len(failed)} solved, {len(failed)} failed {[str(f) for f in failed[:3]]}")
if __name__ == "__main__":
asyncio.run(main())
Run it with twelve jobs against a page you control, replacing the sitekey and URL with the real values from that page:
export CAPTCHAAI_API_KEY="YOUR_API_KEY" # from your CaptchaAI dashboard
python basic_pool.py "YOUR_SITE_KEY" "https://staging.your-app.test/signup" 12
Typical output (tokens shortened):
plan threads: 5
job 2: 0.0s waiting for a slot, 25.6s solving, token 03AFcWeA5vgLQUZ9...
job 4: 0.0s waiting for a slot, 25.9s solving, token 03AFcWeA7Hq2mTbX...
job 1: 0.0s waiting for a slot, 30.7s solving, token 03AFcWeA4mR1xQ2k...
job 5: 0.0s waiting for a slot, 30.8s solving, token 03AFcWeA9pLw3NcV...
job 3: 0.0s waiting for a slot, 35.9s solving, token 03AFcWeA2kD8sYfJ...
job 6: 25.6s waiting for a slot, 25.4s solving, token 03AFcWeA6tG5bRmP...
...
job 12: 56.3s waiting for a slot, 30.5s solving, token 03AFcWeA1zX7cWqE...
12 solved, 0 failed []
Jobs 6 to 12 waited for a free slot instead of reaching CaptchaAI as a sixth or seventh task; that "waiting for a slot" figure is the one to watch in production. Solve times run a little longer than the solver's own, because a result only becomes visible at the next poll.
The Node.js version (Node 18 or newer, no dependencies) uses a small limiter in place of the semaphore:
// basic-pool.mjs: at most as many CaptchaAI tasks in flight as the plan has threads.
import { setTimeout as sleep } from 'node:timers/promises';
const API_KEY = process.env.CAPTCHAAI_API_KEY ?? 'YOUR_API_KEY';
const BASE = 'https://ocr.captchaai.com';
const STOP = new Set(['ERROR_WRONG_USER_KEY', 'ERROR_KEY_DOES_NOT_EXIST', 'IP_BANNED']);
const TRANSIENT = new Set(['ERROR_SERVER_ERROR', 'ERROR_INTERNAL_SERVER_ERROR']);
const retryable = (code) => TRANSIENT.has(code) || code.startsWith('<'); // '<': an HTML 500/502 page
let halted = null; // first stop reason; once set, queued jobs are never sent
function limiter(max) {
let active = 0;
const queue = [];
const pump = () => {
while (active < max && queue.length > 0) {
const { fn, resolve, reject } = queue.shift();
active += 1;
fn().then(resolve, reject).finally(() => { active -= 1; pump(); });
}
};
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); pump(); });
}
async function api(path, fields) {
const body = new URLSearchParams({ key: API_KEY, json: '1', ...fields });
const res = await fetch(`${BASE}/${path}`, { method: 'POST', body, signal: AbortSignal.timeout(30000) });
const text = (await res.text()).trim();
return text.startsWith('{') ? JSON.parse(text) : { status: 0, request: text.slice(0, 60) };
}
async function submit(sitekey, pageurl) {
for (let attempt = 1; attempt <= 6; attempt += 1) {
const res = await api('in.php', { method: 'userrecaptcha', googlekey: sitekey, pageurl });
const code = String(res.request);
if (res.status === 1) return code; // task ID
if (STOP.has(code)) throw new Error(`stop: ${code}`);
if (code === 'ERROR_ZERO_BALANCE') {
const info = await api('res.php', { action: 'threadsinfo' });
if (Number(info.working_threads) < Number(info.threads)) throw new Error('stop: no active plan on this key');
} else if (!retryable(code)) {
throw new Error(`in.php: ${code}`); // fix the request, do not resend it
}
if (attempt < 6) await sleep(8000 * attempt); // threads full or server hiccup: back off
}
throw new Error('in.php: no task accepted after 6 attempts');
}
async function poll(taskId) {
await sleep(20000);
for (let waited = 20; waited <= 120; waited += 5) {
const res = await api('res.php', { action: 'get', id: taskId });
const code = String(res.request);
if (res.status === 1) return code; // g-recaptcha-response token
if (STOP.has(code)) throw new Error(`stop: ${code}`);
if (code !== 'CAPCHA_NOT_READY' && !retryable(code)) {
throw new Error(`task ${taskId}: ${code}`);
}
await sleep(5000);
}
throw new Error(`task ${taskId}: no answer after 120s`);
}
const [sitekey, pageurl, jobsArg = '12'] = process.argv.slice(2);
const jobCount = Number.parseInt(jobsArg, 10);
if (!sitekey || sitekey === 'YOUR_SITE_KEY' || !/^https?:\/\//.test(pageurl ?? '') || !(jobCount > 0)
|| !API_KEY || API_KEY === 'YOUR_API_KEY') {
console.error('usage: CAPTCHAAI_API_KEY=... node basic-pool.mjs SITEKEY PAGEURL [JOBS]');
process.exit(1);
}
const plan = String((await api('res.php', { action: 'getbalance' })).request);
if (!/^\d+$/.test(plan) || Number(plan) < 1) {
console.error(`getbalance returned ${plan}: check the key and that a plan is active`);
process.exit(1);
}
console.log(`plan threads: ${plan}`);
const limit = limiter(Number(plan)); // 5 on a single BASIC plan
const jobs = Array.from({ length: jobCount }, (_, i) => i + 1);
const results = await Promise.allSettled(jobs.map((n) => {
const queued = Date.now();
return limit(async () => {
if (halted) throw new Error(`not sent, run stopped: ${halted}`);
const start = Date.now();
let token;
try {
token = await poll(await submit(sitekey, pageurl));
} catch (err) {
if (err.message.startsWith('stop:')) halted ??= err.message;
throw err;
}
const wait = ((start - queued) / 1000).toFixed(1);
const took = ((Date.now() - start) / 1000).toFixed(1);
console.log(`job ${n}: ${wait}s waiting for a slot, ${took}s solving, token ${token.slice(0, 16)}...`);
return token;
});
}));
const failed = results.filter((r) => r.status === 'rejected');
console.log(`${jobs.length - failed.length} solved, ${failed.length} failed`,
failed.slice(0, 3).map((r) => r.reason.message));
Run it the same way with node basic-pool.mjs SITEKEY PAGEURL 12; it prints the same per-job lines and summary as the Python run. The semaphore patterns guide adds separate submit and poll limits and an adaptive cap.
The cap belongs to the account, not the script. Five processes that each allow five tasks can send 25 at once, and the surplus is refused with ERROR_ZERO_BALANCE or waits in CaptchaAI's queue. Give each process a fixed share of the five (3 and 2, say, in place of the getbalance figure) or put one queue in front of the key; a Redis work queue is one way to put several machines behind a single pool.
Signs you have outgrown BASIC
Any one of these, seen in your normal busy hour rather than a one-off spike, means five threads are not enough:
working_threadsreads 5 in most samples. With 5 of 5 for most of the hour, there is no headroom for a peak.- Slot wait keeps climbing. "Waiting for a slot" should fall back near zero between bursts. If it rises through the run and never recovers, the backlog is growing.
ERROR_ZERO_BALANCEkeeps coming back with all five threads busy. Occasional back-offs are the cap doing its job; a steady stream means the workload wants more concurrency. If the surplus queues on CaptchaAI's side instead, the same signal appears as solve times that stretch only when all five are busy.
The next step is STANDARD ($30/month, 15 threads): three times the threads for twice the price. A second BASIC ($15/month, 5 threads) stacked on the first reaches only 10 threads for the same combined spend, so STANDARD is the better buy; stacking CaptchaAI plans covers where combining plans does make sense. The thread-limit upgrade guide walks through confirming saturation before you pay, and the thread count optimizer sizes larger workloads.
Some symptoms look like capacity problems but are not:
ERROR_ZERO_BALANCEwhilethreadsinfoshows free threads. The key has no active plan, for example because it expired. Renew it; a bigger plan will not help.ERROR_CAPTCHA_UNSOLVABLEor tokens the site rejects. These usually point at inputs (wrong sitekey, wrong page URL, token used too late), not thread count.- One slow solve while only one or two threads are busy. That is solve time, not concurrency; more threads do not speed up a single solve.
When BASIC is not the cheapest option
A flat $15 pays off with volume. At 10,000 reCAPTCHA v2 a month it is $1.50 per 1,000, so a per-solve service priced below that can cost less at low volume. For your break-even, divide $15 by the per-solve price per 1,000: at $1.00 per 1,000, per-solve is cheaper below 15,000 solves a month. CaptchaAI pricing for solo developers compares the low-volume options.
Test on your own pages before you pay. The API quickstart tells new users to contact CaptchaAI support for a free trial or to buy a plan. Run the pool script during the trial and record solve times (your S) and slot waits; the free trial guide covers what else to measure.
Next step
Run the capped client against your busiest workload for an hour, sample threadsinfo once a minute, and check the three signals above. If five threads keep up, BASIC ($15/month, 5 threads) is enough; if not, move to STANDARD ($30/month, 15 threads) on the CaptchaAI pricing page.
Discussions (0)
Join the conversation
Sign in to share your opinion.
Sign InNo comments yet.