Most captcha-solving APIs speak the protocol 2Captcha made popular. You POST the captcha to in.php and get a task id back. Then you ask res.php for the answer until it's ready. The happy path is ten lines of Python.
The trouble is everything around those ten lines. Code that checks for CAPTCHA_NOT_READY, with a T, never sees the reply it is waiting for. A loop that retries every error keeps calling the API with a wrong key until the IP gets banned. A loop that retries nothing gives up on a server hiccup that would have cleared in ten seconds.
I wanted the replies the live API actually sends, not the ones I expected, so I ran the loop for real: image captchas and reCAPTCHA v2 tasks end to end, then a batch of broken requests on purpose (fake keys, missing parameters, task ids that were never issued, garbage uploads). This post has those replies as they came back, what each one means, which ones are worth retrying, and a Python module of about 80 lines that handles all of them.
The loop in four lines
Two endpoints. With json=1 both answer with a small JSON object:
in.php key, method, <captcha data>, json=1 -> {"status": 1, "request": <task id>}
res.php key, action=get, id=<task id>, json=1 -> {"status": 0, "request": "CAPCHA_NOT_READY"} wait, ask again
-> {"status": 1, "request": "<the answer>"} done
-> {"status": 0, "request": "ERROR_..."} failed
status tells you whether request holds a result or a message. Every reply in this post, errors included, came back as HTTP 200, so the HTTP status code tells you almost nothing. Two details in the raw replies trip up parsers:
-
The task id is a number. A submit came back as
{"status":1,"request":5540198133}: an integer, while the answer is a string. Run the id throughstr()before you store or compare it. -
Not every reply is JSON, and the Content-Type won't tell you which. The successful replies above were valid JSON labelled
text/html. Some errors arrived as plain text, alsotext/html, and one was an empty body. Tryr.json()and catch the failure instead.
Without json=1 you get the same information as plain text: OK|5540103145 from in.php, CAPCHA_NOT_READY while the task runs, and OK|u6kyr once it's solved. It works, but JSON is easier to keep apart from the error texts below.
CAPCHA_NOT_READY is not an error
The not-ready reply is spelled CAPCHA_NOT_READY, without the T. The spelling goes back to the original 2Captcha protocol, and compatible APIs keep it so old clients still work. Compare against that exact string. If your code checks for CAPTCHA_NOT_READY, the first poll lands in your error branch, and depending on how that branch is written you either fail every task or poll forever.
It comes with status: 0, like an error, but it only means "not yet". Here is a real reCAPTCHA v2 task on Google's checkbox demo, first poll at 15 seconds, then every 5:
+ 0.6s in.php -> 5540087475
+ 16.1s res.php -> CAPCHA_NOT_READY
+ 21.6s res.php -> CAPCHA_NOT_READY
+ 27.1s res.php -> CAPCHA_NOT_READY
+ 32.7s res.php -> CAPCHA_NOT_READY
+ 38.2s res.php -> 0cAFcWeA6nNiZNfNuoUvZYcr9gRCG61N...(2510 chars)
How long to wait, and when to stop
The right numbers depend on the captcha type, and the difference is large:
-
Image (text) captchas: across 14 polled tasks, the answer was there on the very first poll every time, even when I polled the moment
in.phpreplied. I never sawCAPCHA_NOT_READYfor an image. - reCAPTCHA v2 checkbox tokens: 38 seconds today. Earlier runs on two public demo pages took 45, 90, 91 and 114 seconds. Three tasks across today's and the earlier runs were still not ready at 180 seconds.
So use one loop with per-type settings, not one timeout for everything:
| First poll after | Then every | Give up after | |
|---|---|---|---|
| Image captcha | 2 s (the docs say 5) | 5 s | 60 s |
| Token (reCAPTCHA and similar) | 15 s | 5 s | 180 s |
The 5-second interval is what the API docs ask for. Polling faster doesn't make the solver faster. It only adds requests.
When you give up, the task doesn't stop on the server. Both of today's timed-out tasks kept running and then failed by themselves, about four minutes after they were submitted:
17:18:06 in.php -> 5540125413
17:21:11 my loop gives up: TIMEOUT after 180 s (29 polls, all CAPCHA_NOT_READY)
17:21:27 res.php -> CAPCHA_NOT_READY
17:22:17 res.php -> ERROR_CAPTCHA_UNSOLVABLE
The other one went the same way: submitted at 17:10:36, ERROR_CAPTCHA_UNSOLVABLE by 17:14:29. A five-minute timeout would not have saved either of them. Give up at your limit and submit a new task.
Finished results don't stay around for long either. Polling a solved id again returned the same answer for a while, then ERROR_ID_NOT_EXIST. Some results were still there after nine minutes. But three tasks submitted minutes apart all turned into ERROR_ID_NOT_EXIST within the same 20 seconds, which looks like a periodic cleanup rather than a fixed lifetime per task, so a result can vanish sooner than you'd guess. Read the answer once and keep it yourself. res.php is not storage.
Every reply I got, and what to do with it
These are the replies from the live API, not from the docs. The fake keys were obviously fake strings, and I kept those calls to a handful, because the docs warn that repeated bad authorization gets an IP banned (IP_BANNED, wait about five minutes).
| Reply | What triggered it | Retry? |
|---|---|---|
CAPCHA_NOT_READY |
polling a task that is still running | Yes: wait 5 s, ask again |
ERROR_SERVER_ERROR |
in.php with method=base64 and no body, on all three tries |
Docs: transient, retry after ~10 s. Here it was my bug, so cap the retries |
ERROR_WRONG_USER_KEY |
a 14-character fake key, a 32-character fake key, and my real key with a trailing newline | No |
ERROR_METHOD_ERROR |
in.php with no method
|
No: fix the request |
ERROR_INVALID_METHOD |
method=nosuchmethod |
No |
ERROR_BAD_PARAMETERS |
a reCAPTCHA task without pageurl
|
No |
Sitekey length is invalid. |
googlekey=abc. A sentence, not an ERROR_ code |
No |
{"status":1,...} with a task id |
body that wasn't base64, and a 20-byte PNG fragment |
Accepted, but see the next row |
CaptchaAI INTERNAL ERROR: PROBABLY, INCORRECT REQUEST |
the first poll of either of those tasks, as text/html despite json=1
|
No: fix the image |
{"status":0,"request":""} |
later polls of the same broken task | No |
ERROR_ID_NOT_EXIST |
polling id 1, 99999999999, abc or no id at all, and real ids after they expired |
No: stop polling this id |
CaptchaAI**IN |
in.php with no key field at all |
No |
ERROR_CAPTCHA_UNSOLVABLE |
the two reCAPTCHA tasks above, about four minutes in | Yes, as a new task |
| empty body |
res.php with no action
|
No |
The error-handling page in the docs uses a few different names for some of these. It lists ERROR_WRONG_CAPTCHA_ID and ERROR_WRONG_ID_FORMAT for bad ids, ERROR_EMPTY_ACTION for a missing action, and ERROR_PAGEURL for a missing page URL. The table above is what came back. The practical lesson: don't write a branch per error name, because you will meet names, sentences and empty strings you didn't plan for. Keep a short allow-list of replies worth retrying and treat everything else as fatal.
ERROR_KEY_DOES_NOT_EXIST, ERROR_WRONG_USER_KEY and "account authorization key not found in the system"
All three mean the same thing: the API doesn't know the key you sent.
-
ERROR_WRONG_USER_KEY: the docs define it as a key in the wrong format (it must be 32 characters). In my runs it was also the reply to a 32-character key that doesn't exist. -
ERROR_KEY_DOES_NOT_EXIST: the docs define it as a well-formed key that no account has. I couldn't make the live API return it. The 32-character fake key gotERROR_WRONG_USER_KEYinstead. Handle the two the same way. -
"Account authorization key not found in the system" is how Anti-Captcha's documentation describes its
ERROR_KEY_DOES_NOT_EXIST. Same meaning.
None of these is retryable, and retrying them is how you collect an IP_BANNED. Check these causes instead:
-
Whitespace. My real key with a
\non the end, as you get from reading a key file without.strip(), came backERROR_WRONG_USER_KEY. -
An unset variable.
os.environ.get("KEY")givesNone. An f-string turns that into the string"None", andrequestsleaves aNonefield out of the form entirely, so the API sees no key at all (theCaptchaAI**INrow above). - The key of another service. If you moved from one 2Captcha-style API to another, you changed the base URL. Check that you changed the key too.
Balance errors: ERROR_ZERO_BALANCE
I didn't trigger this one on purpose, so here is what the docs say: ERROR_ZERO_BALANCE comes from in.php when there is "insufficient balance/threads to accept the task", and the fix is to "top up or reduce concurrency and retry".
The "threads" part matters on a thread-based plan, where you pay for concurrent slots instead of per solve. There, res.php?action=getbalance returns your thread count, not money. My test key replied {"status":1,"request":"5"}. So, going by the docs' wording, ERROR_ZERO_BALANCE can mean that every thread is busy, not only that the plan ran out. The module below backs off for 10, then 20 seconds and tries again. If it still fails, it raises, because at that point the plan needs a human. If you run tasks in parallel, cap the number of workers at the getbalance number so you don't run into it in the first place.
The module
This is the whole thing, standard requests and nothing else. call() turns every kind of reply (JSON, plain text, empty, HTTP 5xx, network failure) into an (ok, text) pair. submit() and poll() decide what to retry. solve() sends a new task when the solver gave up on the old one.
"""captcha_api.py: submit a captcha task to an in.php/res.php API and poll for the answer.
Needs: pip install requests
"""
import os
import time
import requests
API = "https://ocr.captchaai.com"
API_KEY = os.environ.get("CAPTCHAAI_KEY", "").strip() # a stray newline = a wrong key
NOT_READY = "CAPCHA_NOT_READY" # sic: no T. Match this exact string.
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR", "NETWORK_ERROR"}
BUSY = {"ERROR_ZERO_BALANCE"} # no funds or no free thread: wait, then give up
RESUBMIT = {"ERROR_CAPTCHA_UNSOLVABLE"} # this task failed; a new task can still work
class CaptchaError(Exception):
def __init__(self, stage, code):
super().__init__(f"{stage}: {code or '(empty reply)'}")
self.code = code
def call(path, **params):
"""One request. Returns (ok, text) and never raises for HTTP or network trouble."""
try:
r = requests.post(f"{API}/{path}", data={**params, "key": API_KEY, "json": 1}, timeout=30)
except requests.RequestException:
return False, "NETWORK_ERROR"
if r.status_code >= 500:
return False, "ERROR_SERVER_ERROR"
try:
reply = r.json()
except ValueError: # seen live: a plain-text error even with json=1
return False, r.text.strip()[:120]
return reply.get("status") == 1, str(reply.get("request", "")) # the id can be an int
def submit(task, tries=3, log=None):
"""POST the task to in.php and return its id. Retries only what a retry can fix."""
if not API_KEY:
raise SystemExit("Set CAPTCHAAI_KEY to your API key first")
for attempt in range(tries):
if attempt:
time.sleep(10 * 2 ** (attempt - 1)) # 10 s, then 20 s
ok, text = call("in.php", **task)
if log:
log("in.php", text)
if ok:
return text
if text not in TRANSIENT | BUSY:
raise CaptchaError("in.php", text) # bad key or bad request: retrying won't help
raise CaptchaError("in.php", text)
def poll(task_id, first_wait=5, interval=5, timeout=120, log=None):
"""Poll res.php until the answer arrives, the task fails, or the time runs out."""
deadline = time.monotonic() + timeout
time.sleep(first_wait)
errors = 0
while time.monotonic() < deadline:
ok, text = call("res.php", action="get", id=task_id)
if log:
log("res.php", text)
if ok:
return text
if text == NOT_READY:
time.sleep(interval)
elif text in TRANSIENT and errors < 3:
errors += 1
time.sleep(interval * 2 ** errors)
else: # unsolvable, unknown id, a broken upload, too many server errors
raise CaptchaError("res.php", text)
raise CaptchaError("res.php", f"TIMEOUT: task {task_id} not solved in {timeout}s")
def solve(task, resubmits=1, log=None, **poll_args):
"""submit() + poll(). A task the solver gave up on is sent again, `resubmits` times."""
for attempt in range(resubmits + 1):
try:
return poll(submit(task, log=log), log=log, **poll_args)
except CaptchaError as e:
if e.code not in RESUBMIT or attempt == resubmits:
raise
Set the key with CAPTCHAAI_KEY=... in the environment. Then call it with the task fields for your captcha type and the timing for that type:
"""example.py: use captcha_api.solve() for an image captcha or a reCAPTCHA v2 checkbox.
CAPTCHAAI_KEY=... python example.py captcha.png
CAPTCHAAI_KEY=... python example.py recaptcha <sitekey> <page url>
"""
import base64
import sys
import time
from captcha_api import CaptchaError, solve
started = time.monotonic()
def log(path, text):
print(f" +{time.monotonic() - started:5.1f}s {path} -> {text[:50]}")
try:
if sys.argv[1] == "recaptcha": # a token: slow, so wait longer before the first poll
task = {"method": "userrecaptcha", "googlekey": sys.argv[2], "pageurl": sys.argv[3]}
answer = solve(task, first_wait=15, interval=5, timeout=180, log=log)
else: # an image: usually answered in a few seconds
body = base64.b64encode(open(sys.argv[1], "rb").read()).decode()
answer = solve({"method": "base64", "body": body}, first_wait=2, interval=5, timeout=60, log=log)
print("answer:", answer[:50])
except CaptchaError as e:
print("failed:", e.code) # fatal, or still failing after the retries
A real run on a generated test captcha that shows Q8TP3:
$ CAPTCHAAI_KEY=... python example.py captcha.png
+ 1.1s in.php -> 5540206752
+ 4.2s res.php -> q8tp3
answer: q8tp3
Answers come back in lowercase. If the site cares about case, add regsense=1 to the task. And here is the same script on the reCAPTCHA demo when the task doesn't finish:
$ CAPTCHAAI_KEY=... python example.py recaptcha 6LfW6wATAAAAAHLqO2pb8bDBahxlMxNdo9g947u9 https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php
+ 0.5s in.php -> 5540125413
+ 16.3s res.php -> CAPCHA_NOT_READY
+ 22.4s res.php -> CAPCHA_NOT_READY
...
+180.3s res.php -> CAPCHA_NOT_READY
failed: TIMEOUT: task 5540125413 not solved in 180s
To check the error paths, I pushed broken tasks through the same functions with my real key:
1. no method: a request bug, raised on the first reply
+ 0.7s in.php -> ERROR_METHOD_ERROR
+ 0.7s raised CaptchaError('ERROR_METHOD_ERROR')
2. method=base64 but no body: the server says ERROR_SERVER_ERROR, so it is retried, 3 tries max
+ 0.6s in.php -> ERROR_SERVER_ERROR
+ 13.5s in.php -> ERROR_SERVER_ERROR
+ 35.8s in.php -> ERROR_SERVER_ERROR
+ 35.8s raised CaptchaError('ERROR_SERVER_ERROR')
3. a 20-byte image fragment: accepted by in.php, fails on the first poll
+ 1.6s in.php -> 5540121107
+ 4.2s res.php -> CaptchaAI INTERNAL ERROR: PROBABLY, INCORRECT REQUEST
+ 4.2s raised CaptchaError('CaptchaAI INTERNAL ERROR: PROBABLY, INCORRECT REQUEST')
4. polling an id that was never issued
+ 0.8s res.php -> ERROR_ID_NOT_EXIST
+ 0.8s raised CaptchaError('ERROR_ID_NOT_EXIST')
Case 2 is why every retry needs a cap. The server called a missing parameter a server error, three times in a row. A loop that retries "transient" errors without a limit would have kept going forever.
What can still go wrong
-
status: 1means the solver answered, not that the answer is right. Some of my test images came back with wrong characters. Only the site can tell you. On a rejection, load a fresh captcha and submit a new task. Don't send the same image again with a different guess, since most sites have already thrown that code away. -
in.phpaccepting a task doesn't mean the input was good. A string that wasn't base64 and a 20-byte fragment both got a task id. The error only showed up on the first poll. Check the image size and format before you submit. The docs ask for JPG, PNG or GIF between 100 bytes and 100 KB. -
A network error on
in.phpis ambiguous. The task may have been created even though you never saw its id. Retrying creates a second task. That's harmless for correctness, but on a thread-based plan the lost one may hold a thread until it finishes. - Tokens expire. A reCAPTCHA token is valid for two minutes and can be verified only once. Solve right before you need it, not when the page loads.
-
The
TRANSIENTset is a guess about the future. If your logs show an error text you haven't seen before, look at it before you add it to a retry list. Most of the replies in the table above are bugs on the caller's side.
FAQ
What does CAPCHA_NOT_READY mean?
The task is still being solved. It's status: 0 but not an error: wait about 5 seconds and poll res.php again with the same id. Note the spelling, without the T.
What is the difference between ERROR_WRONG_USER_KEY and ERROR_KEY_DOES_NOT_EXIST?
On paper, the first is a key in the wrong format and the second is a well-formed key that doesn't exist. In practice, the live API answered ERROR_WRONG_USER_KEY to both kinds of fake key. Either way: check the key for whitespace, for an unset variable, and for a key from another service. Don't retry.
How often should I poll a captcha API?
Every 5 seconds. Start after about 2 seconds for an image captcha and 15 seconds for a token. Stop at 60 seconds for images and around 180 for tokens.
Can I skip polling?
For image captchas, yes: the docs list a solve.php endpoint that takes the same fields as in.php and replies with the answer in one call. Mine came back in 1.0 seconds. Token tasks still go through in.php and res.php.
Which errors are worth retrying?
CAPCHA_NOT_READY (wait and poll), ERROR_SERVER_ERROR and ERROR_INTERNAL_SERVER_ERROR (back off, at most three times), ERROR_ZERO_BALANCE (back off, then stop), and ERROR_CAPTCHA_UNSOLVABLE (as a new task). Everything else is a problem with the key or the request.
Disclosure: I run CaptchaAI, the API these examples call. This article was written by an AI agent; the harness, the raw replies and the module above came from real runs against it on 2026-10-12.
— Bassem
Top comments (0)