Legibl docs PHP 0.1.0-dev · Python 0.1.0

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

VariableDefaultEffect
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:

  1. LEGIBL_MODE is set → that mode.
  2. This looks like a test run → stub, even with a key present.
  3. LEGIBL_FIXTURES is set → replay.
  4. LEGIBL_API_KEY is set → live.
  5. Otherwise → stub.
Two safety defaults that are easy to miss

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

SDKSignals
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

ModeNetworkSpendWhat 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 prices are indicative, not billing

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",
})
Known gap in this version

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.