HTTP API
Five endpoints, all POST, all JSON, all
authenticated by a clientKey in the request body. This is Legibl's
own API, compatible with the task-based protocol the category converged on — an
existing client of a major solver migrates by changing its base URL and key.
The task parser and the error envelope are implemented in the gateway. The public HTTP endpoints are not yet deployed, and no API version identifier has been assigned. This page documents the contract the SDKs are written against; treat it as the specification rather than as a live service until Versions says otherwise.
Conventions
| Base URL | https://api.legibl.io (override with LEGIBL_SERVICE_URL) |
|---|---|
| Method | POST for every endpoint |
| Content type | application/json |
| Authentication | clientKey in the request body. There is no header auth. |
| Scoping | Tasks, reports and corpus entries belong to the key that created them. Another key's taskId is indistinguishable from one that does not exist. |
The errorId / errorCode envelope
Every response carries errorId. Zero means success and the rest
of the object is the payload. One means failure, and errorCode
carries the meaning while errorDescription carries the detail.
{ "errorId": 0, "taskId": "b1f0…" }
{
"errorId": 1,
"errorCode": "ERROR_KEY_DOES_NOT_EXIST",
"errorDescription": "Account authorization key not found"
}
Codes are stable even when the wording changes. Descriptions are for humans and may be extended with detail after a colon. Never parse the description.
POST /createTask
Submits a captcha for solving and returns a task id.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
clientKey | string | yes | Your API key. |
task | object | yes | See task types below. Missing or non-object yields ERROR_TASK_ABSENT. |
callbackUrl | string | no | The task result is POSTed here when it resolves, with a payload identical to getTaskResult. Polling remains available alongside it. |
Task types
reCAPTCHA v2 and v2 invisible
{
"clientKey": "your-key",
"task": {
"type": "RecaptchaV2TaskProxyless",
"websiteURL": "https://example.test/login",
"websiteKey": "6Lc…",
"isInvisible": true
}
}
websiteURL and websiteKey are required non-empty
strings; either missing yields ERROR_INVALID_TASK_DATA.
isInvisible selects the recaptcha_v2_invisible type
and accepts true, "true" or 1, because all
three appear in vendor client libraries in the wild. Anything else is
false.
Cloudflare Turnstile
{
"clientKey": "your-key",
"task": {
"type": "TurnstileTaskProxyless",
"websiteURL": "https://example.test/login",
"websiteKey": "0x4AAA…",
"action": "login",
"cdata": "opaque-string"
}
}
action and cdata are optional and are forwarded when
present. Both alter the token Cloudflare issues, so dropping them yields a token
the target rejects — send them whenever the widget carries
data-action or data-cdata.
Image OCR
{
"clientKey": "your-key",
"task": {
"type": "ImageToTextTask",
"body": "iVBORw0KGgoAAAANSUhEUg…"
}
}
body is the image, base64-encoded, whitespace tolerated. It is
decoded at the door: invalid base64 or an empty decode yields
ERROR_INVALID_TASK_DATA and no job is created, because paying a
backend to discover the same thing is waste.
Accepted type spellings
Vendors disagree on spelling while agreeing on everything else. Legibl accepts their vocabulary and does not require its own. This tolerance is one-directional: these are all accepted on input; solutions come back in the shape each type's clients expect.
| Captcha | Accepted task.type values |
|---|---|
| reCAPTCHA v2 | RecaptchaV2Task, RecaptchaV2TaskProxyless |
| Turnstile | TurnstileTask, TurnstileTaskProxyless,
AntiTurnstileTask, AntiTurnstileTaskProxyLess
(CapSolver's capital L), AntiTurnstileTaskProxyless |
| Image OCR | ImageToTextTask |
Anything else yields ERROR_TASK_NOT_SUPPORTED and creates no job.
hCaptcha, DataDome, Kasada and PerimeterX task types are rejected by this
rule — see Supported captcha types.
Proxy fields
A task may carry proxyType, proxyAddress,
proxyPort, proxyLogin and proxyPassword.
The convention is that the …Task variant carries a proxy and the
…TaskProxyless variant does not; a result bound to the requesting
address has to be obtained from that address.
No currently supported type is address-bound, so no currently supported type requires a proxy. Credentials are carried through verbatim and never inspected, logged, captured to the corpus, or shown in a dashboard.
Responses
{ "errorId": 0, "taskId": "b1f0…" }
| Failure | Code |
|---|---|
| Key missing or unrecognised | ERROR_KEY_DOES_NOT_EXIST |
| Insufficient balance | ERROR_ZERO_BALANCE |
task missing, or not an object, or no type | ERROR_TASK_ABSENT |
| Type outside the supported set | ERROR_TASK_NOT_SUPPORTED |
| Required field missing or malformed | ERROR_INVALID_TASK_DATA |
| Address-bound type submitted with no proxy | ERROR_PROXY_REQUIRED |
| No capacity right now | ERROR_NO_SLOT_AVAILABLE |
POST /getTaskResult
Returns the current state of a task. Poll it, or supply a
callbackUrl on creation and skip polling.
{ "clientKey": "your-key", "taskId": "b1f0…" }
Still working
{ "errorId": 0, "status": "processing" }
Ready
{
"errorId": 0,
"status": "ready",
"solution": { "gRecaptchaResponse": "03AGdBq26…" }
}
The solution object is shaped by captcha type. Vendors disagree on the key even while agreeing on everything else, so Legibl emits the spelling each type's clients already expect rather than inventing a uniform one everybody would have to special-case.
| Captcha type | Solution key | Value |
|---|---|---|
recaptcha_v2, recaptcha_v2_invisible | gRecaptchaResponse | The reCAPTCHA token. |
turnstile | token | The Turnstile token. |
image_ocr | text | The transcribed text. |
The SDKs read gRecaptchaResponse, then token, then
text, and treat a ready result carrying none of them as a
failure.
Failures
| Condition | Code |
|---|---|
| Every eligible backend failed or exhausted retries | ERROR_CAPTCHA_UNSOLVABLE |
| Task unresolved past its maximum lifetime | ERROR_TIMEOUT |
| Unknown task id, or one owned by a different key | ERROR_NO_SUCH_CAPCHA_ID |
ERROR_NO_SUCH_CAPCHA_ID is spelled exactly that way across
every vendor in this category. Legibl keeps the misspelling so that clients
written against any of them work here without a lookup table.
POST /getUsage
{ "clientKey": "your-key" }
{
"errorId": 0,
"usage": [
{ "captchaType": "recaptcha_v2", "targetDomain": "shop.example.com",
"volume": 1420, "spend": "0.852000" }
],
"totalVolume": 1420,
"totalSpend": "0.852000"
}
Your own traffic, broken down by captcha type and target domain. Scoped to the calling key at the query, so one account can never see another's.
Money is a decimal string, not a number. A float renders
as 0.30000000000000004 often enough to matter, and this figure
ends up next to an invoice. Parse it as a decimal.
Which backend served a solve is deliberately absent. That is our supplier relationship rather than yours, and publishing it would also publish our margin.
GET /metrics
Operational metrics in Prometheus text format: job counts by status and captcha type, solve-duration histogram, acceptance verdicts, refused reservations, webhook outcomes and backend health transitions.
curl -H "Authorization: Bearer $LEGIBL_METRICS_TOKEN" https://api.legibl.io/metrics
Off unless you configure a scrape token, and 404 rather than 401 when the token is wrong — an unauthenticated caller learns nothing about whether the endpoint exists on a given host.
Not merely internal-by-convention: the labels name the upstream backends,
so an open /metrics publishes which vendors serve your traffic
and in what proportion. Defaulting to off means a deployment that never
thinks about this does not accidentally announce its supply chain.
Target domain is deliberately absent from the labels. It is unbounded — one
series per site — and unbounded label values are the usual way to take down a
metrics backend. Use getUsage for
per-target figures.
POST /getBalance
{ "clientKey": "your-key" }
{ "errorId": 0, "balance": 42.5 }
This is your Legibl balance, not any upstream vendor's. Card-purchased credit is held briefly before it becomes spendable, so an amount can be credited and not yet available; crypto-purchased credit is spendable immediately, because there is nothing to charge back.
POST /reportCorrect
POST /reportIncorrect
Vendor spellings of the report endpoints
Anti-Captcha names these with the captcha type appended, and real integrations call them by that name. All four aliases are served and behave identically to the short forms:
POST /reportCorrectRecaptcha
POST /reportIncorrectRecaptcha
POST /reportCorrectImageCaptcha
POST /reportIncorrectImageCaptcha
If you are migrating, check which name your code calls. Against a gateway that serves only the short form, these 404 — and the failure is silent. The solve already succeeded, nothing raises, and your accept rate simply stops moving. That figure is what makes cost per accepted solve a real number rather than list price wearing a disguise, so it is the last signal that should fail quietly.
{ "clientKey": "your-key", "taskId": "b1f0…" }
{ "errorId": 0 }
reportCorrect records accepted = true against the
task. reportIncorrect records accepted = false and
attributes the failure to the backend that produced the answer. Reporting the
same task twice keeps the first outcome and still answers
errorId: 0.
Acceptance is the only signal that distinguishes “a backend returned an answer” from “the answer worked”. A backend answering promptly with tokens the target rejects is worse than one that is plainly down, because nothing else notices. If your acceptance-report rate falls below the configured threshold, an operator alert names your key.
The SDKs' interception path observes acceptance directly — it resubmits the
request and sees whether the target took it — so callers on
Legibl\curl_exec or the Python transports do not need to call
these endpoints. Callers driving the API themselves do.
Error codes
The complete table. These are the industry-standard strings, which is the entire migration argument: a client written against another vendor handles ours without a lookup table.
| Code | Description | Retry? | Corrective action |
|---|---|---|---|
ERROR_KEY_DOES_NOT_EXIST |
Account authorization key not found | never | Fix the key. A client that retries an auth failure retries forever — see troubleshooting. |
ERROR_ZERO_BALANCE |
Account has insufficient funds | no | Top up. Check whether a card top-up is still within its hold period. |
ERROR_NO_SUCH_CAPCHA_ID |
Task not found or not owned by this key | no | Check the task id and that you are presenting the key that created it. |
ERROR_CAPTCHA_UNSOLVABLE |
The captcha could not be solved | new task | Terminal for that task. Submit a fresh one; a persistent pattern on one target is worth reporting. |
ERROR_NO_SLOT_AVAILABLE |
No capacity is currently available | yes | Back off and retry. Several distinct backend conditions collapse into this one because from your side they mean the same thing. |
ERROR_TASK_ABSENT |
The task object is missing | no | Send a task object with a type. |
ERROR_TASK_NOT_SUPPORTED |
The task type is not supported | no | Use one of the four supported types. |
ERROR_INVALID_TASK_DATA |
The task object is malformed | no | A required field is missing, empty, the wrong type, or — for image tasks — not decodable base64. |
ERROR_PROXY_REQUIRED |
This captcha type requires a caller-supplied proxy | no | Resubmit with proxy fields on the non-Proxyless
variant. |
ERROR_RATE_LIMIT |
Too many requests | yes | Back off. |
ERROR_TIMEOUT |
The task exceeded its maximum lifetime | new task | Terminal for that task. |
ERROR_UNKNOWN |
An unexpected error occurred | yes | Retry once, then report it. An internal failure is never dressed up as a client error code, so seeing this means something genuinely unclassified happened. |
Compatibility, stated honestly
The task-based protocol is a genuine de-facto standard —
createTask / getTaskResult, shared task-type
strings, a shared error envelope — but there is no specification, no conformance
suite and no governing body. It is convergence by copying: stable in the common
path and divergent at the edges. Compatibility with it is a property Legibl
states per version, not an open-ended commitment.