Troubleshooting
Three failures account for most integration problems. All three are already surfaced honestly by the SDKs; the job of this page is to connect the message you see to the change you make.
First: make the SDK talk
The SDK degrades gracefully by design — it returns your original response rather than raising — which means a broken integration and a clean run look identical from the outside. Before diagnosing anything, attach an observer and log the resolved configuration.
// PHP
error_log(json_encode(\Legibl\Config::fromEnvironment()->describe()));
$observer = new \Legibl\Observer\CallbackObserver(
function (\Legibl\Outcome $o, ?string $domain, float $secs) {
error_log(sprintf('legibl %s %s %s', $o->status, $domain, $o->reason ?? ''));
},
function (\Legibl\Extraction $c, string $reason) {
error_log('legibl solve failed: ' . $reason);
}
);
\Legibl\Interceptor::setInstance(\Legibl\Interceptor::fromConfig(null, $observer));
# Python
print(Config.from_environment().describe())
observer = CallbackObserver(
lambda outcome, domain, secs: print("legibl", outcome.status, domain, outcome.reason),
lambda challenge, reason: print("legibl solve failed:", reason),
)
Two fields answer most questions immediately: mode tells you
whether you are actually spending, and api_key_set tells you
whether a key was found. Neither ever prints a credential.
1. Invalid or missing API key
This has four distinct presentations, and only one of them is loud.
Presentation A — a thrown configuration error
Legibl is in live mode but no API key is set. Set the LEGIBL_API_KEY
environment variable, or use stub mode for local development.
You set LEGIBL_MODE=live explicitly and no key was found.
Fix: set LEGIBL_API_KEY, or remove the explicit
mode and let inference pick stub. The usual cause is a key that reaches the web
process but not a queue worker, a cron entry, or a container that loads a
different env file. Check the process that actually threw, not the one you
developed in.
Presentation B — silence, and tokens the target rejects
No key set and no explicit mode means the SDK infers stub
mode. It solves nothing, returns the synthetic token
legibl-stub-token, resubmits the request with it, and the target
rejects it. Nothing throws. Nothing is logged. It looks exactly like a solver
with terrible accuracy.
Diagnose: describe() reports
"mode": "stub" and "api_key_set": false.
Fix: set LEGIBL_API_KEY. This is the safety
default working as intended — an unconfigured install cannot spend money — but
it is also the most confusing of the four, so check it first.
Presentation C — stub mode in your test suite despite a key
Both SDKs downgrade to stub when they detect a test run, even with a key
present, because a suite that quietly bills a live solver on every build is
a common and expensive accident. PHP looks for
PHPUNIT_COMPOSER_INSTALL, PEST_RUNNING, or
APP_ENV/SYMFONY_ENV equal to test. Python
looks for pytest or unittest in
sys.modules, PYTEST_CURRENT_TEST, or
APP_ENV/ENVIRONMENT equal to test.
Fix, when you genuinely want live solves under test: set
LEGIBL_ALLOW_LIVE_IN_TESTS=1. It disables the downgrade and nothing
else.
Presentation D — the key is present and wrong
A rejected key comes back inside a normal response envelope, not as a
transport failure. The solver returns nothing, the outcome is
solve_failed, and your observer's solve-failure callback receives:
createTask failed: ERROR_KEY_DOES_NOT_EXIST
Without an observer this message is discarded and all you see is an unsolved page. That is why the observer comes first on this page.
Fix: re-issue or correct the key. Related codes worth
distinguishing: ERROR_ZERO_BALANCE means the key is valid and the
account is out of credit — and card-purchased credit is deliberately held
briefly before it becomes spendable, so a top-up that has not cleared its hold
presents this way.
ERROR_KEY_DOES_NOT_EXIST is permanent by definition. The
gateway keeps the auth/transient boundary intact precisely so clients do not
retry it forever; treat it as fatal and alert on it.
2. CURLOPT_RETURNTRANSFER left off
PHP only. This is the single most common integration failure.
What you see
The response body is printed straight to output instead of being returned.
curl_exec() hands back true rather than a string. No
captcha is ever handled, and the outcome is:
outcome: unavailable
reason: CURLOPT_RETURNTRANSFER is off or the body is routed to a callback or
file, so the response cannot be inspected for a challenge
Why the SDK cannot work around it
With CURLOPT_RETURNTRANSFER off, libcurl writes the body to
standard output as it arrives and returns a boolean. There is no body in the
process to inspect. The SDK could install its own write callback to capture it —
and would then be silently changing where your output goes, which is a
worse failure than the one it fixed. So it reports and passes through.
Fix
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
Set it through Legibl\curl_setopt (or
Legibl\curl_setopt_array), not the global one — see
partial adoption below.
The same reason string, two other causes
| Cause | Fix |
|---|---|
CURLOPT_WRITEFUNCTION is set — the body goes to your
callback. |
Drop the write callback for requests that may carry a challenge, or accept that those requests get no handling. |
CURLOPT_FILE is set — the body goes to a stream. |
Same. File downloads rarely carry captchas; the page that links to them does. |
Its close relative: partial adoption
A different reason string, the same unavailable outcome:
the request cannot be reconstructed for resubmission; the handle was
configured through the global curl_setopt rather than Legibl\curl_setopt
You imported curl_exec but not curl_setopt.
Resubmitting means rebuilding the request, and libcurl does not let the SDK read
most options back — so it records them as you set them. If it never saw them, it
will not resubmit a guess.
Fix: import the whole set in that file:
use function Legibl\{curl_init, curl_setopt, curl_setopt_array, curl_exec, curl_close};
Remember this import is required once per file that calls
curl. PHP's namespace fallback resolves an unqualified
curl_exec() to the global function and never to
Legibl\curl_exec — the full
explanation is in the PHP quickstart.
3. Challenges detected but unreachable by form injection
The outcome is unhandled: a challenge was found and correctly
identified, and it is out of reach. This is a normal, expected
outcome, not an error — and reporting it is the entire point, because
silently returning the challenge page looks identical to a clean response.
Why it happens
The SDKs solve by form injection: read the challenge parameters out of the server-rendered HTML, obtain a token, write it into the field the page submits, replay the request. That requires two things to exist in the HTML the server sent — the challenge parameters, and a field to write the token into. Four situations break one of them.
| Reason | What is actually going on |
|---|---|
| recaptcha script present but no data-sitekey in server HTML; challenge is injected client-side | The page loads recaptcha/api.js but the widget is created
by JavaScript after load. The sitekey is not in the HTML, so there is
nothing to extract. |
| turnstile script present but no cf-turnstile element in server HTML; widget is rendered explicitly in JavaScript | Turnstile's explicit rendering mode deliberately omits the
cf-turnstile class and calls
turnstile.render() instead. Same problem. |
| cf-turnstile element present but carries no data-sitekey | The element is a placeholder; the sitekey is supplied at render time from script. |
| data-response-field="false": no hidden token field is created, so the token is consumed in JavaScript | The parameters are all there, but Cloudflare creates no hidden input. The token is handed to a JS callback and posted by the application's own code. There is no field to inject into. |
A fifth case applies to image captchas: captcha image found but its input field is ambiguous; needs a per-target override. The image was located but the page has several text inputs and none of them looks captcha-related, so pairing them would be a guess.
Which surfaces reach them
| Surface | Reaches a server-rendered widget | Reaches a JS-rendered widget |
|---|---|---|
PHP Legibl\curl_exec |
yes | no |
| Python httpx transport / requests adapter | yes | no |
Per-target override + direct createTask |
yes | yes, if a submittable field exists |
| A JavaScript-executing client (headless browser) | yes | yes |
Legibl does not ship a browser-driven surface, and deliberately so: no SDK in any language carries a browser engine or an inference runtime. If a target genuinely requires executing its JavaScript to produce a submittable token, that is outside what these SDKs do, and this page says so rather than implying otherwise.
How a per-target override resolves it
Everything that broke above is a value detection could not infer from the HTML. If you know that value for a specific target — you read its JavaScript once — you can supply it, and the solve proceeds normally. That is what a per-target override is.
Image captchas: the override is a parameter
Implemented and supported. It short-circuits the heuristic entirely:
// PHP
$extraction = \Legibl\Detect::imageCaptcha($html, [
'image' => '/captcha/render.php?id=9f2',
'submit_field' => 'security_code',
]);
# Python
extraction = legibl.extract_image_captcha(html, {
"image": "/captcha/render.php?id=9f2",
"submit_field": "security_code",
})
reCAPTCHA and Turnstile: build the extraction yourself
There is no configuration-level override wired up for these in this version (see the gap note below), but the same effect is available directly, because the extraction factories and the solver are both public API. Pin the sitekey and the field name you found in the target's JavaScript:
// PHP — per-target override for a Turnstile widget rendered in JavaScript
use Legibl\{Config, Extraction};
use Legibl\Solver\HttpSolver;
$solver = new HttpSolver(Config::fromEnvironment());
$challenge = Extraction::turnstile(
'0x4AAAAAAA_sitekey_from_the_targets_js',
'https://example.test/login',
'cf-turnstile-response' // the field the page's own JS posts
);
$solution = $solver->solve($challenge, null);
$fields = $solution !== null ? $solution->asFields() : []; // ['cf-turnstile-response' => '…']
# Python — the same, for a client-side reCAPTCHA
from legibl.config import Config
from legibl.extraction import Extraction
from legibl.solver import HttpSolver
solver = HttpSolver(Config.from_environment())
challenge = Extraction.recaptcha(
"6Lc_sitekey_from_the_targets_js",
"https://example.test/login",
False, # invisible?
"g-recaptcha-response",
)
solution = solver.solve(challenge, None)
fields = solution.as_fields() if solution else {}
Attach fields to your own request. This works whenever a
submittable field exists — including the JS-rendered cases, where the field is
created at render time rather than server-side. It does not help when
the token is consumed entirely inside JavaScript and never posted, because then
there is no field to attach anything to.
Config declares an extractionOverrides /
extraction_overrides map keyed by target domain — the intended
home for registering these per target so the interception path applies them
automatically. Nothing reads that map yet. Until it is wired
up, overrides work as shown above: a parameter to the image extractor, or a
hand-built extraction passed to the solver.
What not to do
They are different outcomes for a reason. Collapsing them means a target
that changes its rendering mode stops working and nothing notices, because
every request still reports “fine”. Alert on
unhandled by target domain; a type that starts appearing on a
target that previously worked is the earliest signal you will get.
Other things worth checking
| Symptom | Likely cause |
|---|---|
Every request reports no_challenge, but the page is
plainly a challenge page. |
The captcha type is not supported — hCaptcha, DataDome, Kasada and PerimeterX are not detected. Or the target refused you before serving the real page at all, which on PHP is often a TLS-fingerprint refusal: see Transport impersonation. |
Outcomes are expired. |
More than 110 seconds elapsed between obtaining the solution and submitting it. Look at your own queueing or retry wrapper, not at the solver. |
timed out waiting for a solution. |
LEGIBL_TIMEOUT is 30 seconds by default and reCAPTCHA v2
p95 latencies run far higher on some backends. Raise it or accept the
miss. |
An exception from Legibl\curl_multi_exec(). |
Intentional. The multi-handle API is not wrapped — see the error reference. |
| Image captchas are never solved automatically. | Correct for this version. The SDK detects them but its service client
does not build ImageToTextTask — call
createTask yourself. |