Configuration & modes
Zero-config by design: an API key in the environment is the whole setup. Everything else has a defensible default, and both SDKs resolve configuration identically.
Environment variables
| Variable | Default | Effect |
|---|---|---|
LEGIBL_API_KEY |
unset | Enables live mode. Sent as clientKey on every API call. |
LEGIBL_SERVICE_URL |
https://api.legibl.io |
Base URL of the gateway. A trailing slash is stripped. |
LEGIBL_MODE |
inferred | live | stub | replay |
dry_run. An explicit mode always wins over
inference. An unrecognised value raises a configuration error naming the
valid set. |
LEGIBL_FIXTURES |
unset | Path to the fixture corpus. Setting it selects replay mode. |
LEGIBL_TIMEOUT |
30 |
Seconds. Bounds both the HTTP call and the total time spent polling
getTaskResult. |
LEGIBL_ALLOW_LIVE_IN_TESTS |
unset | Set to any non-empty value to disable the test-run downgrade described below. |
How the mode is chosen
In order, first match wins:
LEGIBL_MODEis set → that mode.- This looks like a test run →
stub, even with a key present. LEGIBL_FIXTURESis set →replay.LEGIBL_API_KEYis set →live.- Otherwise →
stub.
Nothing configured means stub mode, so an unconfigured or
half-configured install cannot spend money. A detected test run means
stub mode, because a suite that quietly bills a live solver on every
build is a common and expensive accident. Both cost you one explicit
LEGIBL_MODE=live to override; neither can cost you money by
accident.
What counts as a test run
| SDK | Signals |
|---|---|
| PHP | PHPUNIT_COMPOSER_INSTALL or PEST_RUNNING set;
or APP_ENV / SYMFONY_ENV equal to
test (case-insensitive). |
| Python | pytest or unittest present in
sys.modules; PYTEST_CURRENT_TEST set; or
APP_ENV / ENVIRONMENT equal to
test. |
The detection is deliberately conservative: it only ever downgrades spending to stub and never upgrades anything.
The four modes
| Mode | Network | Spend | What it does |
|---|---|---|---|
live |
yes | yes | Calls the service using createTask /
getTaskResult. Requires an API key; constructing it without
one raises a configuration error. |
stub |
no | no | Returns a synthetic token (legibl-stub-token in PHP) so
the code path runs end to end without contacting anything. The target will
reject it — that is expected. |
replay |
no | no | Answers from the shared fixture corpus. Deterministic and offline.
Requires LEGIBL_FIXTURES; without it, a configuration
error. |
dry_run |
no | no | Solves nothing and returns no solution, but counts what it would have solved by type and target domain and reports an indicative cost. |
Dry run
Dry run answers “what would enabling this cost?” before you spend
it. Point the SDK at a real workload with LEGIBL_MODE=dry_run and
read the report off the solver:
// PHP
$solver = \Legibl\Interceptor::instance()->solver(); // a DryRunSolver in this mode
echo $solver->report();
# Python
interceptor = Interceptor.from_config() # a DryRunSolver in this mode
print(interceptor.solver.report())
Legibl dry run — challenges that would have been solved:
recaptcha_v2 41
turnstile 7
estimated cost ~$0.0412 (indicative list prices)
The per-1000 figures baked into the dry-run solver are vendor list prices verified 2026-07-29 and they drift monthly: reCAPTCHA v2 and invisible $0.80, Turnstile $1.20, image OCR $0.40. Use them for an order of magnitude, never for quoting or reconciliation.
Solution freshness
Challenge tokens are single-use and short-lived. Both SDKs stamp a solution
with the time it was obtained and refuse to submit one older than the freshness
window — 110 seconds by default. An over-age solution produces
the expired outcome and the original response is returned
unchanged.
This exists so that our own latency is not recorded as the solver's
inaccuracy. The window is a constructor argument
(Interceptor::DEFAULT_FRESHNESS in PHP,
DEFAULT_FRESHNESS in legibl.config); there is no
environment variable for it.
Proxies for address-bound challenges
Register credentials against a target domain. When a task is built for a
domain that has them, the task type drops its Proxyless suffix and
the credentials are attached — following the standard convention that a result
bound to the requesting address must be obtained from that address.
// PHP
$config->setProxyFor('example.com', [
'proxyType' => 'http', 'proxyAddress' => '10.0.0.1', 'proxyPort' => '8080',
]);
# Python
config.set_proxy_for("example.com", {
"proxyType": "http", "proxyAddress": "10.0.0.1", "proxyPort": "8080",
})
No currently supported type requires a proxy —
ADDRESS_BOUND is empty in both SDKs, because reCAPTCHA and
Turnstile tokens are validated against the sitekey rather than the requester.
Configure one when your own traffic needs to originate from a particular
address, not because the challenge demands it.
Proxy credentials never appear in describe(), in logs, or in the
gateway's records. The gateway carries them through verbatim and never inspects
them.
Per-target extraction overrides
Pairing a captcha image with the input its answer goes into is heuristic, which is exactly why an override exists. An override short-circuits the heuristic:
// 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",
})
Config declares an extractionOverrides /
extraction_overrides map keyed by target domain, but
nothing reads it yet. The interception path does not consult
registered overrides; today an override only takes effect when you pass it
directly to the image-captcha extractor as above. Registering one in
Config will not change what Legibl\curl_exec or the
Python transports do.
Observability
Every intercepted request produces exactly one outcome, delivered to an observer. Without one, the SDK is a black box that occasionally spends money.
// PHP
use Legibl\Interceptor;
use Legibl\Observer\CallbackObserver;
use Legibl\Outcome;
$observer = new CallbackObserver(function (Outcome $o, ?string $domain, float $secs) {
error_log("{$o->status} {$domain} {$secs}s");
});
Interceptor::setInstance(Interceptor::fromConfig(null, $observer));
# Python
from legibl.interceptor import Interceptor
from legibl.observer import CallbackObserver
observer = CallbackObserver(
lambda outcome, domain, secs: log.info("%s %s %.2fs", outcome.status, domain, secs),
lambda challenge, reason: log.warning("solve failed: %s", reason),
)
interceptor = Interceptor.from_config(None, observer)
The observer interface has two methods:
requestCompleted / request_completed, called
once per intercepted request whatever the outcome; and
solveFailed / solve_failed, called when a
challenge was detected but no solution was obtained. The second is where
the service's errorCode surfaces — without an observer,
that detail is discarded.
Logging the resolved configuration
describe() is built to be logged. It reports
api_key_set as a boolean and proxy domains only. A test
asserts that the API key, the proxy password and the proxy address are all
absent from its output.
{
"mode": "live",
"service_url": "https://api.legibl.io",
"api_key_set": true,
"fixture_path": null,
"timeout": 30.0,
"freshness_window": 110.0,
"proxy_domains": ["example.com"],
"impersonation": "inactive" // PHP only
}
The impersonation field is PHP-only and reports whether
browser-shaped TLS is actually in effect — see
Transport impersonation.