# ZeroCaptcha integration brief

> For an AI coding assistant. Read all of it before you write code, then work through the checklist at the end. It is generated from ZeroCaptcha's API contract (version 0.1.0), so every endpoint, field and error code below is the API's own. The same as JSON: https://zerocaptcha.io/ai/zerocaptcha.json; the full contract: https://zerocaptcha.io/openapi.json; the docs: https://zerocaptcha.io/docs.

## What ZeroCaptcha does

ZeroCaptcha solves Cloudflare Turnstile widgets and Cloudflare WAF and 5-second challenge pages (the "Just a moment..." screen) over HTTP. You create a task naming the page, ZeroCaptcha solves it, and you read the result: a Turnstile token for the page's form, or a challenge page's `cf_clearance` cookie with the user agent it is bound to.

Every task is real and paid from the account's prepaid US-dollar balance. There is no sandbox, test key or free credit. A task's price is held when it is created and charged only if it succeeds; a task that fails or expires costs nothing. Send tasks only for sites the user is allowed to automate.

## Configuration

- **Base URL:** `https://api.zerocaptcha.io`. Read it from the `ZEROCAPTCHA_API` environment variable, with this value as the default.
- **API key:** read it from the `ZEROCAPTCHA_KEY` environment variable, or the project's secret manager. A key starts with `zc_live_` and is 41 characters. Never write a key into source code, a test, a log line, an error message, a URL or a browser bundle, and never ask the user to paste it into chat.
- Every call is HTTPS to that one host. No other service is involved.

## Which API to call

The same host serves three formats over one pipeline, with the same prices and checks. Use **REST v1** for new code: bearer keys, resource URLs, an `Idempotency-Key` header and RFC 9457 problem details for every error.

REST v1:
- `GET /v1/tasks` (listTasks): List tasks. Auth: Authorization: Bearer <key>.
- `POST /v1/tasks` (createTask): Create a task. Auth: Authorization: Bearer <key>.
- `GET /v1/tasks/events` (streamTaskEvents): Live task updates. Auth: Authorization: Bearer <key>.
- `GET /v1/tasks/{id}` (getTask): Get a task. Auth: Authorization: Bearer <key>.
- `POST /v1/tasks/{id}/callback/resend` (resendTaskCallback): Send a task's callback again. Auth: Authorization: Bearer <key>.
- `POST /v1/tasks/{id}/report` (reportTask): Report a task's token. Auth: Authorization: Bearer <key>.
- `GET /v1/balance` (getBalance): Get the balance. Auth: Authorization: Bearer <key>.
- `GET /v1/prices` (listPrices): List prices. Auth: none.

The createTask format, for clients written for other providers' `createTask` APIs (every reply is HTTP 200; a failure is `errorId: 1` with `errorCode`):
- `POST /createTask` (compatCreateTask): Create a task (compatible). Auth: clientKey in the JSON body.
- `POST /feedbackTask` (compatFeedbackTask): Report how a token did (CapSolver). Auth: clientKey in the JSON body.
- `POST /getBalance` (compatGetBalance): Get the balance (compatible). Auth: clientKey in the JSON body.
- `POST /getTaskResult` (compatGetTaskResult): Get a task's result (compatible). Auth: clientKey in the JSON body.
- `POST /reportCorrect` (compatReportCorrect): Report a token that worked (2Captcha). Auth: clientKey in the JSON body.
- `POST /reportCorrectRecaptcha` (compatReportCorrectRecaptcha): Report a token that worked (Anti-Captcha). Auth: clientKey in the JSON body.
- `POST /reportIncorrect` (compatReportIncorrect): Report a refused token (2Captcha). Auth: clientKey in the JSON body.
- `POST /reportIncorrectRecaptcha` (compatReportIncorrectRecaptcha): Report a refused token (Anti-Captcha). Auth: clientKey in the JSON body.

2Captcha's `in.php` and `res.php`, for clients written for them (Turnstile only; every reply is HTTP 200 with a code):
- `GET /in.php` (twoCaptchaSubmit): Submit a task (2Captcha). Auth: key parameter.
- `POST /in.php` (twoCaptchaSubmitForm): Submit a task by POST (2Captcha). Auth: key parameter.
- `GET /res.php` (twoCaptchaResult): Get a result or the balance (2Captcha). Auth: key parameter.

## Authentication

REST: `Authorization: Bearer <key>`. The createTask format: `clientKey, in the JSON body`. 2Captcha's: `key, a query or form parameter`.

A key has one or both scopes: `tasks` and `balance` (`tasks` creates and reads tasks, `balance` reads the balance). A key may be held to allowed IP addresses and a daily spend cap. Keys are created, rotated and revoked in the dashboard only; no API key can manage keys.

## Task types

| type | Solves | Needs | Optional |
| --- | --- | --- | --- |
| `TurnstileTaskProxyless` | A Turnstile widget's token | `websiteKey`, `websiteURL` | `action`, `callbackUrl`, `cdata` |
| `TurnstileTask` | A Turnstile widget's token, through your proxy | `proxy`, `websiteKey`, `websiteURL` | `action`, `callbackUrl`, `cdata` |
| `AntiTurnstileTaskProxyLess` (alias) | A Turnstile widget's token | `websiteKey`, `websiteURL` | `action`, `callbackUrl`, `cdata` |
| `AntiTurnstileTask` (alias) | A Turnstile widget's token, through your proxy | `proxy`, `websiteKey`, `websiteURL` | `action`, `callbackUrl`, `cdata` |
| `CloudflareChallengeTask` | A challenge page's `cf_clearance` cookie, through your proxy | `proxy`, `websiteURL` | `callbackUrl` |
| `AntiCloudflareTask` (alias) | A challenge page's `cf_clearance` cookie, through your proxy | `proxy`, `websiteURL` | `callbackUrl` |

The type names are case-insensitive. `Anti…` names are aliases of the same tasks. There is no proxyless challenge task: a clearance works only from the address that earned it, so `CloudflareChallengeTaskProxyless` is refused.

## Create a task: POST /v1/tasks

A Turnstile task:

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `action` | string \| null | no | The widget's action, if it sets one: up to 32 ASCII letters, digits, `_` and `-`. (Limits: at most 32 characters; pattern `^[\-0-9A-Z_a-z]*$`.) |
| `callbackUrl` | string \| null | no | Where to POST the result once the task ends, signed with your callback secret (`ZeroCaptcha-Signature`): an http or https URL of at most 2048 characters, without credentials, naming a public domain or a public IP address on a port no other protocol reserves. A call that is not answered 2xx is retried with backoff, eight attempts in all over roughly 65 to 95 minutes; one to a name that resolves to a private address is not made. `callbackUrl` in this dialect. (Limits: at most 2048 characters.) |
| `cdata` | string \| null | no | The widget's cData, if it sets one: up to 255 ASCII letters, digits, `_` and `-`. (Limits: at most 255 characters; pattern `^[\-0-9A-Z_a-z]*$`.) |
| `proxy` | string \| null | no | Your proxy as a URL with its port, such as `http://user:pass@proxy.example.net:8080`: http or https, as SOCKS is not supported yet. `TurnstileTask` needs one, and `TurnstileTaskProxyless` takes none. The server also checks that the host is public, that the port is not one another protocol reserves, such as 25, and that the login and password are at most 255 bytes each, a password only with a login. Never logged, and deleted when the task finishes. (Limits: pattern `^(?:https?://[^/?#]+:[0-9]+(?:[/?#].*)?)?$`.) |
| `type` | string | yes | `TurnstileTaskProxyless`, or `TurnstileTask` to solve the widget through your proxy. The same names with CapSolver's `Anti` prefix work too, and case does not matter. (Limits: one of `TurnstileTaskProxyless`, `TurnstileTask`, `AntiTurnstileTaskProxyLess`, `AntiTurnstileTask`.) |
| `websiteKey` | string | yes | The widget's site key: 1 to 100 ASCII letters, digits, `_` and `-`. (Limits: 1 to 100 characters; pattern `^[\-0-9A-Z_a-z]*$`.) |
| `websiteURL` | string | yes | The page the widget is on, or behind the challenge: an http or https URL of at most 2048 characters, without credentials, on its scheme's default port. The server also checks that it names a public domain, not an IP address, a name of one label or one kept for local use such as `localhost`, `*.local` or `*.internal`; the task keeps the URL as the server normalizes it, such as with a lowercase host. (Limits: at most 2048 characters; pattern `^(?:http://[^/?#@:]+(?::80)?\|https://[^/?#@:]+(?::443)?)(?:[/?#].*)?$`.) |

A Cloudflare WAF or 5-second challenge page's task:

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `callbackUrl` | string \| null | no | Where to POST the result once the task ends, signed with your callback secret (`ZeroCaptcha-Signature`): an http or https URL of at most 2048 characters, without credentials, naming a public domain or a public IP address on a port no other protocol reserves. A call that is not answered 2xx is retried with backoff, eight attempts in all over roughly 65 to 95 minutes; one to a name that resolves to a private address is not made. `callbackUrl` in this dialect. (Limits: at most 2048 characters.) |
| `proxy` | string | yes | Your proxy as a URL with its port, such as `http://user:pass@proxy.example.net:8080`: http or https, as SOCKS is not supported yet. The clearance is earned through it and works only from its address. The server also checks that the host is public, that the port is not one another protocol reserves, such as 25, and that the login and password are at most 255 bytes each, a password only with a login. Never logged, and deleted when the task finishes. (Limits: pattern `^https?://[^/?#]+:[0-9]+(?:[/?#].*)?$`.) |
| `type` | string | yes | `CloudflareChallengeTask`, or CapSolver's `AntiCloudflareTask`, in any case: pass a Cloudflare challenge page (the WAF's managed, JS or interactive challenge) through your proxy, for its `cf_clearance` cookie. A clearance works only from the IP address and with the user agent that earned it, so there is no proxyless challenge task: `CloudflareChallengeTaskProxyless` is refused. (Limits: one of `CloudflareChallengeTask`, `AntiCloudflareTask`.) |
| `websiteURL` | string | yes | The page the widget is on, or behind the challenge: an http or https URL of at most 2048 characters, without credentials, on its scheme's default port. The server also checks that it names a public domain, not an IP address, a name of one label or one kept for local use such as `localhost`, `*.local` or `*.internal`; the task keeps the URL as the server normalizes it, such as with a lowercase host. (Limits: at most 2048 characters; pattern `^(?:http://[^/?#@:]+(?::80)?\|https://[^/?#@:]+(?::443)?)(?:[/?#].*)?$`.) |

```bash
curl "$ZEROCAPTCHA_API/v1/tasks" \
  -H "Authorization: Bearer $ZEROCAPTCHA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"TurnstileTaskProxyless","websiteURL":"https://example.com/login","websiteKey":"0x4AAAAAAAB1cD2eF3gH4iJ5","action":"login","cdata":"session-7f3a9c2e"}'
```

The reply is `201` with the task (as below, status `queued`) and `Location: /v1/tasks/{id}`. Find `websiteKey` in the page's HTML: the widget's `data-sitekey` attribute, or the `sitekey` passed to `turnstile.render()`; `action` and `cdata` are its `data-action` and `data-cdata` (`action` and `cData` in `render`) when it sets them. Send them whenever the widget sets them, exactly as it does, and leave them out when it sets none: many sites check both when they verify the token, and refuse one solved without them, though the task itself succeeds and is charged. In the createTask format they go in the task's `metadata` (`metadata.action`, `metadata.cdata`); in 2Captcha's `in.php`, as `action` and `data`. Add `proxy` (with `TurnstileTask`) to solve through your own proxy, and `callbackUrl` to be called when the task ends.

## Read the result: GET /v1/tasks/{id}

| Field | Type | Always present | What it is |
| --- | --- | --- | --- |
| `action` | string \| null | no |  |
| `attempts` | integer | yes | Solve attempts made so far, each one a solver node took on and finished. Waiting for a node with room, however long, is none. |
| `callback` | TaskCallback \| null | no | The callback the task named, where it stands and each delivery attempt; null when it named none. Filled on `GET /v1/tasks/{id}` only: null in lists, live events and callback payloads. |
| `cdata` | string \| null | no |  |
| `cost` | Usd | yes | What the task has cost: its price once it succeeds, otherwise zero. |
| `createdAt` | string | yes |  |
| `deadline` | string | yes | Unsolved by this time, the task expires and nothing is charged. |
| `errorCode` | string \| null | no |  |
| `errorDescription` | string \| null | no |  |
| `finishedAt` | string \| null | no |  |
| `held` | Usd | yes | Held on the balance while the task is queued or running. |
| `id` | string | yes |  |
| `idempotencyKey` | string \| null | no |  |
| `kind` | TaskType | yes | What it solves, whichever name it was sent with: `turnstile`, a widget's token, or `cloudflare`, a challenge page's clearance. |
| `maxAttempts` | integer | yes | The most solve attempts the task gets. |
| `price` | Usd | yes | What the task is charged if it succeeds. |
| `solution` | Solution \| null | no | Present while the token is available, and only on single-task reads. |
| `startedAt` | string \| null | no |  |
| `status` | Status | yes |  |
| `tokenExpiresAt` | string \| null | no |  |
| `tokenIssuedAt` | string \| null | no |  |
| `tokenState` | TokenState | yes |  |
| `type` | string | yes | The task type as it was sent, such as `TurnstileTaskProxyless`. |
| `updatedAt` | string | yes |  |
| `usesProxy` | boolean | yes | Whether the task runs through the customer's proxy. |
| `version` | integer | yes | Raised on every change; a newer version replaces an older one. |
| `websiteKey` | string \| null | no | The widget's site key; `null` for a challenge page. |
| `websiteURL` | string | yes |  |

`status` moves from `queued` to `running` (and back to `queued` for a retry) and ends in one of `succeeded`, `failed` or `expired`. `tokenState` is one of `pending`, `available`, `expired`, `deleted`, `none`. `solution` is present only while the token can be used, and only when you read one task.

A solved Turnstile task:

```json
{
  "id": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b",
  "type": "TurnstileTaskProxyless",
  "kind": "turnstile",
  "status": "succeeded",
  "websiteURL": "https://example.com/login",
  "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5",
  "action": "login",
  "cdata": "session-7f3a9c2e",
  "usesProxy": false,
  "price": "0.000800",
  "held": "0.000000",
  "cost": "0.000800",
  "attempts": 1,
  "maxAttempts": 3,
  "errorCode": null,
  "errorDescription": null,
  "solution": {
    "token": "0.Zm9vYmFy…",
    "userAgent": null,
    "cookie": null
  },
  "tokenState": "available",
  "tokenIssuedAt": "2026-09-30T14:02:14Z",
  "tokenExpiresAt": "2026-09-30T14:07:14Z",
  "createdAt": "2026-09-30T14:02:05Z",
  "startedAt": "2026-09-30T14:02:06Z",
  "finishedAt": "2026-09-30T14:02:14Z",
  "deadline": "2026-09-30T14:04:35Z",
  "updatedAt": "2026-09-30T14:02:14Z",
  "idempotencyKey": "2c6ad4a4-5d0b-4be4-9d60-3f1f1e1b8a52",
  "version": 3
}
```

A failed task (nothing charged):

```json
{
  "id": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b",
  "type": "TurnstileTaskProxyless",
  "kind": "turnstile",
  "status": "failed",
  "websiteURL": "https://example.com/login",
  "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5",
  "action": null,
  "cdata": null,
  "usesProxy": false,
  "price": "0.000800",
  "held": "0.000000",
  "cost": "0.000000",
  "attempts": 3,
  "maxAttempts": 3,
  "errorCode": "ERROR_CAPTCHA_UNSOLVABLE",
  "errorDescription": "Every attempt to solve the challenge failed. Nothing was charged.",
  "solution": null,
  "tokenState": "none",
  "tokenIssuedAt": null,
  "tokenExpiresAt": null,
  "createdAt": "2026-09-30T14:02:05Z",
  "startedAt": "2026-09-30T14:02:06Z",
  "finishedAt": "2026-09-30T14:02:40Z",
  "deadline": "2026-09-30T14:04:35Z",
  "updatedAt": "2026-09-30T14:02:40Z",
  "idempotencyKey": null,
  "version": 7
}
```

A solved challenge page has `solution.token` (the cookie's value), `solution.userAgent` and `solution.cookie` (`name` `cf_clearance`, `value`, `expiresAt`, `null` while unknown). Send the cookie with exactly that User-Agent, through the proxy the task used; the API serves it for 30 minutes after it is issued.

## Wait for the result

- **Poll:** read the task every 2 seconds until its status is final. Stop waiting at a deadline: each task carries its own `deadline` (150 seconds after creation by default, with up to 3 solve attempts), after which an unsolved task expires with `ERROR_TASK_TIMEOUT` and nothing is charged. The reference clients give the whole solve 180 seconds.
- **Or take a callback:** add `callbackUrl` (`pingback` in 2Captcha's format), a public http or https URL. When the task ends, it is POSTed there in the format the task was created in: application/json: the task, as GET /v1/tasks/{id} shows it; application/json: the reply getTaskResult would give; application/x-www-form-urlencoded: id=<task id>&code=<token or error code>.
- Each call carries `ZeroCaptcha-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of `<t>.<raw body>` keyed with the callback secret>` and `ZeroCaptcha-Delivery`, the same ID on every attempt. The secret starts with `zcsig_` (dashboard, API keys page, owners only). Verify the HMAC over the raw body before parsing it, compare in constant time, and refuse a timestamp more than 300 seconds from now. Answer 2xx within 10 seconds; any other answer is retried from 30 seconds, doubling up to an hour apart, 8 attempts in all. A call can arrive twice: handle each task once. Keep polling as a fallback: a missed call never loses a result.
- `GET /v1/tasks/events` streams task changes as server-sent events (without tokens), from a list's `liveCursor`.

## Use the token

A Turnstile token works once, for 300 seconds from `tokenIssuedAt` (`tokenExpiresAt`). Put it where the widget would: the form's `cf-turnstile-response` field, or the widget's callback (`data-callback`, or `callback` in `turnstile.render`), then submit. Reading a solved task after its token expired gives no token (REST: `tokenState` `expired`; createTask format: `ERROR_TOKEN_EXPIRED`), and the task was still charged: use tokens as soon as they are ready.

## Errors and what to do

REST errors are `application/problem+json` with a 4xx or 5xx status: branch on `code`, never on `title`; quote `request_id` to support. Every code has a policy:

| Policy | What the client does | Codes |
| --- | --- | --- |
| `retry` | Retry the same request with exponential backoff (a create with the same Idempotency-Key); honour Retry-After when present. | `internal_error`, `service_unavailable`, `request_timeout`, `payments_unavailable`, `ERROR_SERVICE_UNAVAILABLE` |
| `wait` | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | `idempotency_key_in_use`, `queue_full`, `rate_limited`, `ERROR_NO_SLOT_AVAILABLE`, `ERROR_RATE_LIMIT`, `ERROR_IDEMPOTENCY_KEY_IN_USE` |
| `fix` | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | `bad_request`, `not_found`, `method_not_allowed`, `payload_too_large`, `unauthorized`, `invalid_credentials`, `key_revoked`, `key_limit_reached`, `key_state_conflict`, `csrf_rejected`, `insufficient_scope`, `ip_not_allowed`, `insufficient_funds`, `validation_failed`, `idempotency_key_reused`, `reauthentication_required`, `link_invalid`, `link_expired`, `link_used`, `weak_password`, `state_conflict`, `role_required`, `spend_cap_reached`, `email_taken`, `email_unverified`, `ERROR_TASK_ABSENT`, `ERROR_TASK_NOT_SUPPORTED`, `ERROR_INVALID_TASK_DATA`, `ERROR_KEY_DOES_NOT_EXIST`, `ERROR_KEY_REVOKED`, `ERROR_IP_NOT_ALLOWED`, `ERROR_ACCESS_DENIED`, `ERROR_ZERO_BALANCE`, `ERROR_IDEMPOTENCY_KEY_REUSED`, `ERROR_NO_SUCH_CAPCHA_ID`, `ERROR_REPORT_NOT_RECORDED`, `ERROR_DUPLICATE_REPORT`, `ERROR_INVALID_REQUEST`, `ERROR_SPEND_CAP_REACHED`, `ERROR_PROXY_NOT_ALLOWED` |
| `new-task` | The task is over and nothing more will come of it: create a new task if you still need a token. | `ERROR_TOKEN_EXPIRED`, `ERROR_CAPTCHA_UNSOLVABLE`, `ERROR_TASK_TIMEOUT` |
| `stop` | Stop: do not send it again. Tell the user; a person must act (support, or not using this site). | `account_suspended`, `domain_blocked`, `ERROR_ACCOUNT_SUSPENDED`, `ERROR_DOMAIN_BLOCKED`, `ERROR_ACCOUNT_DELETED` |

REST problem codes:

| Code | HTTP | Policy | Meaning | What to do |
| --- | --- | --- | --- | --- |
| `bad_request` | 400 | `fix` | The request is malformed in a way no more specific code covers, such as a missing or unsupported Content-Type. Other 4xx statuses without a code of their own also use it. | Compare the method, headers and body with the API reference, and send JSON with Content-Type: application/json. |
| `not_found` | 404 | `fix` | No route matches the path, or no task or key with this ID belongs to your account. | Check the path and the ID. A task or a key is visible only to its own account. |
| `method_not_allowed` | 405 | `fix` | The path exists, but not with this HTTP method. | Use the method the reference gives for the path, such as POST /v1/tasks to create a task. |
| `payload_too_large` | 413 | `fix` | The request body is larger than the API accepts. | Send only the documented fields. A task needs the page URL, its site key and, when you use them, action, cdata and a proxy. |
| `internal_error` | 500 | `retry` | Something failed on our side. The reply has no detail; the cause is logged under its request ID. | If it keeps happening, contact support and quote the request_id. |
| `service_unavailable` | 503 | `retry` | A service the API depends on is down or overloaded, so the request could not be served. | Retry. If it lasts, check the status page. |
| `request_timeout` | 504 | `retry` | The API did not finish the request in time. The timeout is on our side, not a slow client. | Retry. To check whether a create went through, list tasks with the idempotencyKey filter. |
| `unauthorized` | 401 | `fix` | No valid API key or session came with the request: the Authorization header is missing or malformed, or the key does not exist. | Send Authorization: Bearer followed by a key from the dashboard, and check that the whole key was copied. |
| `invalid_credentials` | 401 | `fix` | Dashboard log-in only: the email and password do not match an account. | Check the email address and the password, then log in again. |
| `key_revoked` | 401 | `fix` | The key was revoked, or it was rotated and its overlap has ended, so it can no longer call the API. The detail says which. | Use the key that replaced it, or create a new key in the dashboard, and replace the old one wherever it is used. |
| `key_limit_reached` | 409 | `fix` | Dashboard only: the account has as many active API keys as it may. A key being replaced by a rotation, and a revoked key, does not count. | Revoke a key you no longer use, then create the new one. To replace a key, rotate it instead: a rotation never needs a free place. |
| `key_state_conflict` | 409 | `fix` | Dashboard only: the key cannot change this way. It no longer works, so it cannot be renamed, restricted or rotated; it was rotated already, so it cannot be rotated again; or it was never rotated, so it has no overlap to end. | Reload the key to see where it stands. Rotate the key that replaced it, revoke a key to stop it at once, or create a new key. |
| `csrf_rejected` | 403 | `fix` | Dashboard sessions only: a browser request came from another site, or without the session's CSRF token. | In the dashboard, reload the page. From code, call the API with an API key instead. |
| `insufficient_scope` | 403 | `fix` | The key lacks the scope this call needs: tasks:write to create tasks, tasks:read to read them and balance:read for the balance. No API key may manage keys: that takes a dashboard session. | Use a key with the scope the detail names, or create one that has it. Manage keys from the dashboard. |
| `ip_not_allowed` | 403 | `fix` | The key works only from the addresses on its allowlist, and this request came from another. | Add the address to the key's allowlist in the dashboard, or call from an allowed address. |
| `account_suspended` | 403 | `stop` | The account is suspended: its keys are refused, and it cannot create tasks, make keys or add funds. | Contact support to appeal. |
| `insufficient_funds` | 402 | `fix` | Your available balance cannot cover the task's price. Prices held for tasks still running count against it. | Add funds in the dashboard, then create the task again. |
| `domain_blocked` | 403 | `stop` | Tasks for this site are not allowed: it is in a category the Acceptable Use Policy excludes, such as banking or government, or its owner opted out. | Do not send tasks for this site. |
| `validation_failed` | 422 | `fix` | A field is missing or has an invalid value, such as an unsupported task type, a websiteURL that is not a URL, or a proxy on a proxyless task. The detail names the field. | Fix the field the detail names, then send the request again. |
| `idempotency_key_reused` | 422 | `fix` | This Idempotency-Key was used within the last 24 hours for a different request. | Use a new key for a new task. Reuse a key only to retry exactly the same request. |
| `idempotency_key_in_use` | 409 | `wait` | A request with this Idempotency-Key is still being processed. | Wait, then send the same request with the same key. |
| `queue_full` | 429 | `wait` | Your account's share of the queue, or the solver pool, is full. | Wait, then retry, and spread tasks out over time. |
| `rate_limited` | 429 | `wait` | The request is over a budget: reads of tasks and the balance for your key or account, sign-in attempts from your address or failures for an email, sign-ups, password reset requests and uses of email links from your address or for an email, emails asked for by one person, or the live streams your account may hold open. Creating tasks has no budget: your balance and your share of the queue bound it instead. The RateLimit and RateLimit-Policy headers show where the key's and account's budgets stand. | Wait, then retry, and pace requests by the RateLimit header: poll results less often, or close a live stream you no longer need. |
| `reauthentication_required` | 403 | `fix` | The change needs a recent sign-in, such as changing the email address or the password, or turning two-factor or a passkey on or off, and the session last proved who it is longer ago than the account policy's reauthenticationWindow. Managing API keys never needs one. | Confirm the password with POST /v1/session/reauthentication, then send the change again. |
| `link_invalid` | 400 | `fix` | The email link is not one this service made, or not all of it arrived, as when a mail client cuts a long link short. | Open the link straight from the email, or copy all of it. If it still fails, ask for a new email. |
| `link_expired` | 410 | `fix` | The email link is past its lifetime, or was replaced: by a newer reset link, a change of address, or a password changed since it was sent. | Ask for a new verification email or a new password reset email; the page that opened the link offers it. |
| `link_used` | 409 | `fix` | The email link was already used. Each link works once. | Nothing, if the address is verified or the password was reset already: sign in. Otherwise ask for a new email. |
| `weak_password` | 422 | `fix` | The new password breaks a rule: it has fewer than 8 characters, is the account's email address, or is a common password. The problem's detail says which. | Use at least 8 characters, not the email address and not a common password. A password manager's generated password passes. |
| `state_conflict` | 409 | `fix` | What was asked conflicts with where the resource stands, such as asking for a verification email for an address that is verified already, or registering a passkey that is registered already. | Read the resource again, such as GET /v1/session, and act on what it shows. |
| `role_required` | 403 | `fix` | The signed-in person's role does not allow the action: in the dashboard, a member doing what only an owner may, such as managing keys, billing or the team; in the staff console, a staff member without the role the action needs. | Ask an owner of the account (or, for staff, an admin) for the role, or leave the action to someone who holds it. |
| `spend_cap_reached` | 402 | `fix` | The API key has a daily spend cap, and this task would take what its tasks created today (UTC) hold or were charged past it. Tasks that failed or expired do not count. | Raise or remove the key's cap in the dashboard, use another key, or wait for the next UTC day. |
| `payments_unavailable` | 503 | `retry` | A top-up cannot be started now: no payment processor is set up, or it did not answer. Balances, tasks and receipts are unaffected. | Try the top-up again later. If it lasts, contact support. |
| `email_taken` | 409 | `fix` | Sign-up only: this email address already has an account. The detail says: This email already has an account. Log in or reset your password. | Log in with the address, or reset its password if you have forgotten it. To open another account, use another address. |
| `email_unverified` | 403 | `fix` | Dashboard only: creating an API key or starting a top-up needs the signed-in owner's email address confirmed, and it is not yet. Keys the account already has keep working, and everything else in the dashboard works as before. | Open the link in the email ZeroCaptcha sent when you signed up. If it is lost or expired, send a new one from the dashboard, or with POST /v1/email-verification/resend. Wrong address? Change it in Settings. |

Task outcomes (`errorCode` of a `failed` or `expired` task; none is charged):

| Code | Policy | Meaning | What to do |
| --- | --- | --- | --- |
| `ERROR_CAPTCHA_UNSOLVABLE` | `new-task` | Every attempt to solve the challenge failed. | If it keeps happening, check the websiteURL and websiteKey and, with TurnstileTask or a challenge page, that your proxy works. |
| `ERROR_TASK_TIMEOUT` | `new-task` | The task was not solved before its deadline. | Create a new task. If timeouts keep happening, check the status page. |
| `ERROR_DOMAIN_BLOCKED` | `stop` | Tasks for this site are not allowed: it is in a category the Acceptable Use Policy excludes, or its owner opted out. As a task outcome, the site was blocked after the task was created. | Do not send tasks for this site. |
| `ERROR_ACCOUNT_SUSPENDED` | `stop` | The account is suspended. As a task outcome, it was suspended after the task was created and before it ran. | Contact support to appeal. |
| `ERROR_ACCOUNT_DELETED` | `stop` | An owner deleted the account while the task was queued, which cancels every task it had queued. | Nothing to do. To start again, sign up for a new account. |
| `ERROR_PROXY_NOT_ALLOWED` | `fix` | The proxy points at a private or reserved address, such as 127.0.0.1 or 10.0.0.0/8, which tasks cannot use. | Use a proxy with a public address. |
| `ERROR_INVALID_TASK_DATA` | `fix` | A task field is missing or invalid: a websiteURL that is not a URL, no websiteKey on a Turnstile task, a proxy on a proxyless task or none on TurnstileTask or a challenge page. The errorDescription names the problem. As a task outcome, the solver refused the task's parameters after it was queued. | Fix the field the errorDescription names, then create the task again. |

The createTask format's codes (`errorCode` with `errorId: 1`):

| Code | Policy | Meaning | What to do |
| --- | --- | --- | --- |
| `ERROR_TASK_ABSENT` | `fix` | The body has no task object, or the task has no type. | Send a task with a type, such as TurnstileTaskProxyless. |
| `ERROR_TASK_NOT_SUPPORTED` | `fix` | The task type is not one ZeroCaptcha solves. | Use TurnstileTaskProxyless, TurnstileTask with your proxy, or CloudflareChallengeTask with your proxy for a challenge page. AntiTurnstileTaskProxyLess, AntiTurnstileTask and AntiCloudflareTask work too, as the same tasks. A challenge page without a proxy is refused this way too: its clearance would not work from your address. |
| `ERROR_INVALID_TASK_DATA` | `fix` | A task field is missing or invalid: a websiteURL that is not a URL, no websiteKey on a Turnstile task, a proxy on a proxyless task or none on TurnstileTask or a challenge page. The errorDescription names the problem. As a task outcome, the solver refused the task's parameters after it was queued. | Fix the field the errorDescription names, then create the task again. |
| `ERROR_KEY_DOES_NOT_EXIST` | `fix` | The clientKey is missing, or is not a valid key. | Send a key from the dashboard as clientKey, and check that the whole key was copied. |
| `ERROR_KEY_REVOKED` | `fix` | The key was revoked, or it was rotated and its overlap has ended, so it can no longer call the API. The errorDescription says which. | Use the key that replaced it, or create a new key in the dashboard, and replace the old one wherever it is used. |
| `ERROR_ACCOUNT_SUSPENDED` | `stop` | The account is suspended. As a task outcome, it was suspended after the task was created and before it ran. | Contact support to appeal. |
| `ERROR_IP_NOT_ALLOWED` | `fix` | The key works only from the addresses on its allowlist, and this request came from another. | Add the address to the key's allowlist in the dashboard, or call from an allowed address. |
| `ERROR_ACCESS_DENIED` | `fix` | The key lacks the scope this call needs: tasks:write for createTask, tasks:read for getTaskResult and balance:read for getBalance. | Use a key with the scope the errorDescription names, or create one that has it. |
| `ERROR_DOMAIN_BLOCKED` | `stop` | Tasks for this site are not allowed: it is in a category the Acceptable Use Policy excludes, or its owner opted out. As a task outcome, the site was blocked after the task was created. | Do not send tasks for this site. |
| `ERROR_ZERO_BALANCE` | `fix` | Your available balance cannot cover the task's price. Prices held for tasks still running count against it. | Add funds in the dashboard, then create the task again. |
| `ERROR_NO_SLOT_AVAILABLE` | `wait` | Your account's share of the queue, or the solver pool, is full. | Wait, then retry, and spread tasks out over time. |
| `ERROR_RATE_LIMIT` | `wait` | The call is over a budget of your key or account: getTaskResult and getBalance share one. createTask has none: your balance and your share of the queue bound it instead. The same condition as rate_limited, under the name clients of this format already handle. | Wait, then retry, and poll getTaskResult less often. |
| `ERROR_IDEMPOTENCY_KEY_REUSED` | `fix` | The Idempotency-Key header was used within the last 24 hours for a different createTask request. | Use a new key for a new task. Reuse a key only to retry exactly the same request. |
| `ERROR_IDEMPOTENCY_KEY_IN_USE` | `wait` | A createTask request with this Idempotency-Key is still being processed. | Wait a moment, then send the same request with the same key. |
| `ERROR_NO_SUCH_CAPCHA_ID` | `fix` | The taskId is missing, is not a task ID, or names no task of this key's account. CAPCHA is the dialect's own spelling. | Poll with the taskId that createTask returned, using a key of the same account. |
| `ERROR_SERVICE_UNAVAILABLE` | `retry` | The API could not serve the request right now. | Retry. If it lasts, check the status page. |
| `ERROR_REPORT_NOT_RECORDED` | `fix` | A report (reportIncorrect, reportCorrect, their Recaptcha forms, or feedbackTask) named a task that did not succeed, so it has no token to report on. | Report only tasks that succeeded. Reports are recorded for our staff and never refund a task. |
| `ERROR_DUPLICATE_REPORT` | `fix` | The task has a report already: one per task. | Send one report per task. |
| `ERROR_INVALID_REQUEST` | `fix` | The body is not valid JSON, or is not a JSON object; or feedbackTask came without result.invalid. | Send a JSON object. The Content-Type does not matter here, so clients that send JSON as text/plain work. |
| `ERROR_TOKEN_EXPIRED` | `new-task` | getTaskResult only: the task succeeded, but its token has expired. Turnstile tokens work once, for 300 seconds. The reply keeps status ready and includes the cost. | Create a new task, and use each token as soon as it is ready. |
| `ERROR_SPEND_CAP_REACHED` | `fix` | createTask only: the API key's daily spend cap would be passed by this task. The same condition as spend_cap_reached. | Raise or remove the key's cap in the dashboard, or wait for the next UTC day. |

2Captcha format codes (in place of the result, HTTP 200):

| Code | Policy | When |
| --- | --- | --- |
| `ERROR_WRONG_USER_KEY` | `fix` | The key is missing or is not a ZeroCaptcha key. |
| `ERROR_KEY_DOES_NOT_EXIST` | `fix` | The key is unknown, revoked or expired. |
| `ERROR_IP_NOT_ALLOWED` | `fix` | The key's allowlist does not include this address. |
| `ERROR_ACCESS_DENIED` | `fix` | The key lacks the scope: `tasks` to create and read tasks, `balance` for `getbalance`. |
| `ERROR_ZERO_BALANCE` | `fix` | Your available balance does not cover the task. Add funds. |
| `ERROR_SPEND_CAP_REACHED` | `fix` | The key's daily spend cap is reached. |
| `ERROR_PAGEURL` | `fix` | `pageurl` is missing, or is not a public `http` or `https` page. |
| `ERROR_BAD_PARAMETERS` | `fix` | A parameter is missing or wrong: `method`, `sitekey`, `pingback` or `action`; or `res.php` was asked for a challenge-page task, which this format cannot carry. |
| `ERROR_PROXY_FORMAT` | `fix` | The proxy is not `login:password@host:port` or `host:port`, is SOCKS, or is not public. |
| `ERROR_DOMAIN_BLOCKED` | `stop` | The site is on our blocklist. |
| `ERROR_ACCOUNT_SUSPENDED` | `stop` | The account is suspended. |
| `ERROR_NO_SLOT_AVAILABLE` | `wait` | The queue is full for a moment: send the task again shortly. |
| `ERROR_IDEMPOTENCY_KEY_REUSED` | `fix` | The `Idempotency-Key` was used for a different task. |
| `ERROR_IDEMPOTENCY_KEY_IN_USE` | `wait` | The first request with this `Idempotency-Key` is still being served: send it again shortly. |
| `ERROR_SERVICE_UNAVAILABLE` | `retry` | We could not serve the request just now: try again shortly. |
| `MAX_USER_TURN` | `wait` | `in.php` is called too often: wait the seconds in `Retry-After`. |
| `ERROR_EMPTY_ACTION` | `fix` | `res.php` was called without `action`. |
| `ERROR_WRONG_ID_FORMAT` | `fix` | `id` is not a task ID. |
| `ERROR_WRONG_CAPTCHA_ID` | `fix` | No task of this account has that ID. |
| `ERROR_REPORT_NOT_RECORDED` | `fix` | `reportbad` or `reportgood` named a task that did not succeed. |
| `ERROR_DUPLICATE_REPORT` | `fix` | `reportbad` or `reportgood` named a task reported already: one report per task. |
| `ERROR_CAPTCHA_UNSOLVABLE` | `new-task` | The task was not solved, or not before its deadline; nothing is charged. |
| `ERROR_BAD_PROXY` | `fix` | Your proxy pointed at an address tasks cannot use; nothing is charged. |
| `ERROR_TOKEN_EXPIRED` | `new-task` | The task was solved and charged, but its token has expired. |
| `ERROR: 1005` | `wait` | `res.php` is called too often: wait the seconds in `Retry-After`. |

## Retries and backoff

- Retry a request only when a retry can help: HTTP 429, 500, 502, 503, 504, a lost connection or a timeout, and `409 idempotency_key_in_use`. The createTask and 2Captcha formats say the same with `ERROR_RATE_LIMIT`, `ERROR_NO_SLOT_AVAILABLE`, `ERROR_SERVICE_UNAVAILABLE`, `ERROR_IDEMPOTENCY_KEY_IN_USE`, `MAX_USER_TURN` and `ERROR: 1005`.
- Wait as `Retry-After` says (whole seconds). Without it, wait 1 s, then double up to 16 s, and multiply by a random 0.5 to 1. Give each request 15 seconds, and give up when the next wait would pass the overall deadline.
- `queue_full` and `idempotency_key_in_use` send `Retry-After: 2`; `rate_limited` sends the seconds until its budget has room, and the `RateLimit` and `RateLimit-Policy` headers say where the budgets stand.
- Never retry `failed` or `expired` tasks by polling harder: they are final.

## Idempotency

Send `Idempotency-Key` (1 to 255 visible ASCII characters; one new value per task, reused only to retry that task) with every create, on `POST /v1/tasks`, `POST /createTask` and `in.php`. For 24 hours the same key with the same request returns the first reply, with `Idempotent-Replayed: true`, instead of making a second task. The same key with a different request is refused (idempotency_key_reused (422), or ERROR_IDEMPOTENCY_KEY_REUSED); while the first is still being served, idempotency_key_in_use (409, Retry-After), or ERROR_IDEMPOTENCY_KEY_IN_USE. To find the task a lost reply created: `GET /v1/tasks?idempotencyKey=<key>`.

## Limits

- Request bodies up to 64 KiB; the server answers within 10 seconds or with `504 request_timeout`.
- Reads (tasks and the balance) have budgets per key and per account (by default 200 per 2 seconds each); creations have none by default: the balance and the account's share of the queue (50 tasks queued or running by default) bound them, with `429 queue_full` beyond it.
- An account holds at most 10 live-update streams open by default.
- Prices are public at `GET /v1/prices` (no key); a task is charged the price in effect when it was created.

## Balance

`GET /v1/balance` answers `available` (what new tasks can be held against) and `held` (for tasks still running), as decimal strings in US dollars with six decimals:
```json
{
  "available": "12.345600",
  "held": "0.001600",
  "currency": "USD"
}
```
Funds are added in the dashboard, in crypto, from $10; top-ups are final. A task the balance cannot cover is refused with `402 insufficient_funds` (`ERROR_ZERO_BALANCE`) and costs nothing.

## Reference clients

Each is complete and tested against a stand-in for the API: it creates a Turnstile task with an Idempotency-Key, polls every 2 seconds, retries only what the policies above allow, and prints the token or the error code. Adapt the task (type, proxy, action, cdata) to the page; keep the rest.

### zerocaptcha.mjs: Node.js 20+ (JavaScript, works from TypeScript)

Run: `node zerocaptcha.mjs <websiteURL> <websiteKey> [action] [cdata]`

```js
// ZeroCaptcha reference client for Node.js 20 or later: no dependencies.
//
//   ZEROCAPTCHA_KEY=zc_live_... node zerocaptcha.mjs https://example.com/login 0x4AAAAAAAB1cD2eF3gH4iJ5 login session-7f3a9c2e
//
// The arguments are the page with the widget, its site key (data-sitekey), and its action and
// cData when it sets them (data-action and data-cdata, or the action and cData options of
// turnstile.render()): many sites check both when they verify the token. It creates a Turnstile
// task with POST /v1/tasks, polls GET /v1/tasks/{id} every 2 seconds and prints the token.
// Import solve() to use it from your own code, with the same fields, plus proxy and callbackUrl
// if you want them. The key comes from the environment, never from the source.

import { pathToFileURL } from "node:url";

const API = (process.env.ZEROCAPTCHA_API ?? "https://api.zerocaptcha.io").replace(/\/+$/, "");
const POLL_MS = 2_000; // between reads of one task
const DEADLINE_MS = 180_000; // the whole solve; a task expires unsolved after its own deadline
const REQUEST_MS = 15_000; // one HTTP request
const RETRYABLE = new Set([429, 500, 502, 503, 504]);

export class ZeroCaptchaError extends Error {
  /** @param {string} code @param {string} message @param {string | undefined} requestId */
  constructor(code, message, requestId) {
    super(`${code}: ${message}`);
    this.code = code;
    this.requestId = requestId;
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/** Seconds from a Retry-After header, as milliseconds, or undefined. */
function retryAfter(response) {
  const value = response.headers.get("retry-after");
  return value !== null && /^\d+$/.test(value.trim()) ? Number(value) * 1000 : undefined;
}

/**
 * One API call, retried while the API says a retry can help: a lost connection, 429, 5xx, and
 * 409 idempotency_key_in_use. Waits as Retry-After asks, else 1, 2, 4, 8, then 16 s with jitter.
 */
async function call(method, path, { body, idempotencyKey, deadline }) {
  const key = process.env.ZEROCAPTCHA_KEY;
  if (!key) throw new ZeroCaptchaError("missing_key", "Set ZEROCAPTCHA_KEY to your API key.");
  const headers = { authorization: `Bearer ${key}`, accept: "application/json" };
  const init = { method, headers };
  if (body !== undefined) {
    headers["content-type"] = "application/json";
    init.body = JSON.stringify(body);
  }
  if (idempotencyKey !== undefined) headers["idempotency-key"] = idempotencyKey;
  for (let attempt = 0; ; attempt += 1) {
    let response;
    let text;
    let failure;
    try {
      response = await fetch(`${API}${path}`, { ...init, signal: AbortSignal.timeout(REQUEST_MS) });
      // The body is part of the answer: one cut short is retried as no answer is, with the same
      // Idempotency-Key, so a task the API made is returned rather than made again.
      text = await response.text();
    } catch {
      response = undefined;
      failure = new ZeroCaptchaError("network", `${method} ${path} got no answer`);
    }
    let json;
    if (response !== undefined) {
      try {
        json = JSON.parse(text);
      } catch {
        json = undefined;
      }
      if (response.ok && json !== undefined) return json;
    }
    if (response?.ok) {
      failure = new ZeroCaptchaError("network", `${method} ${path} got its answer cut short`);
    } else if (response !== undefined) {
      const code = json?.code ?? `http_${response.status}`;
      failure = new ZeroCaptchaError(
        code,
        json?.detail ?? json?.title ?? `HTTP ${response.status}`,
        json?.request_id ?? response.headers.get("x-request-id") ?? undefined,
      );
      const again =
        RETRYABLE.has(response.status) ||
        (response.status === 409 && code === "idempotency_key_in_use");
      if (!again) throw failure;
    }
    const backoff = Math.min(1000 * 2 ** attempt, 16_000) * (0.5 + Math.random() / 2);
    const wait = (response && retryAfter(response)) ?? backoff;
    if (Date.now() + wait > deadline) throw failure;
    await sleep(wait);
  }
}

/**
 * Solves one task and returns its solution: `{ token }` for Turnstile, plus `userAgent` and
 * `cookie` for a Cloudflare challenge page. Throws ZeroCaptchaError with the API's code, such as
 * insufficient_funds, or the task's own, such as ERROR_CAPTCHA_UNSOLVABLE (never charged).
 */
export async function solve(task) {
  const deadline = Date.now() + DEADLINE_MS;
  // One key per task, sent on every retry of the create, so a lost reply never makes two tasks.
  const idempotencyKey = crypto.randomUUID();
  const created = await call("POST", "/v1/tasks", { body: task, idempotencyKey, deadline });
  for (;;) {
    if (Date.now() + POLL_MS > deadline) {
      throw new ZeroCaptchaError("wait_timeout", `task ${created.id} is still running`);
    }
    await sleep(POLL_MS);
    const current = await call("GET", `/v1/tasks/${created.id}`, { deadline });
    if (current.status === "succeeded" && current.solution) return current.solution;
    if (["succeeded", "failed", "expired"].includes(current.status)) {
      throw new ZeroCaptchaError(
        current.errorCode ?? "ERROR_TOKEN_EXPIRED",
        current.errorDescription ?? `the task ${current.status} without a usable token`,
      );
    }
  }
}

// Run as a program, not imported: solve the page named on the command line.
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [websiteURL, websiteKey, action, cdata] = process.argv.slice(2);
  if (!websiteURL || !websiteKey) {
    console.error("usage: node zerocaptcha.mjs <websiteURL> <websiteKey> [action] [cdata]");
    process.exitCode = 2;
  } else {
    try {
      const task = { type: "TurnstileTaskProxyless", websiteURL, websiteKey };
      // The widget's action and cData, sent only when it sets them.
      if (action) task.action = action;
      if (cdata) task.cdata = cdata;
      const solution = await solve(task);
      console.log(solution.token);
    } catch (error) {
      console.error(error instanceof Error ? error.message : String(error));
      // Set, not process.exit(): the process ends once its open connections close.
      process.exitCode = 1;
    }
  }
}
```

### zerocaptcha.py: Python 3.9+ (standard library)

Run: `python zerocaptcha.py <websiteURL> <websiteKey> [action] [cdata]`

```python
# ZeroCaptcha reference client for Python 3.9 or later: standard library only.
#
#   ZEROCAPTCHA_KEY=zc_live_... python zerocaptcha.py https://example.com/login 0x4AAAAAAAB1cD2eF3gH4iJ5 login session-7f3a9c2e
#
# The arguments are the page with the widget, its site key (data-sitekey), and its action and
# cData when it sets them (data-action and data-cdata, or the action and cData options of
# turnstile.render()): many sites check both when they verify the token. It creates a Turnstile
# task with POST /v1/tasks, polls GET /v1/tasks/{id} every 2 seconds and prints the token.
# Import solve() to use it from your own code, with the same fields, plus proxy and callbackUrl
# if you want them. The key comes from the environment, never from the source.
import http.client
import json
import os
import random
import sys
import time
import urllib.error
import urllib.request
import uuid

API = os.environ.get("ZEROCAPTCHA_API", "https://api.zerocaptcha.io").rstrip("/")
POLL_SECONDS = 2  # between reads of one task
DEADLINE_SECONDS = 180  # the whole solve; a task expires unsolved after its own deadline
REQUEST_SECONDS = 15  # one HTTP request
RETRYABLE = {429, 500, 502, 503, 504}


class ZeroCaptchaError(Exception):
    def __init__(self, code, message, request_id=None):
        super().__init__(f"{code}: {message}")
        self.code = code
        self.request_id = request_id


def _retry_after(headers):
    value = (headers.get("Retry-After") or "").strip() if headers else ""
    return int(value) if value.isdigit() else None


def _call(method, path, deadline, body=None, idempotency_key=None):
    """One API call, retried while the API says a retry can help: a lost connection, 429, 5xx
    and 409 idempotency_key_in_use. Waits as Retry-After asks, else 1, 2, 4, 8, then 16 s."""
    key = os.environ.get("ZEROCAPTCHA_KEY")
    if not key:
        raise ZeroCaptchaError("missing_key", "Set ZEROCAPTCHA_KEY to your API key.")
    headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
    data = None
    if body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(body).encode()
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key
    attempt = 0
    while True:
        wait = None
        request = urllib.request.Request(API + path, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(request, timeout=REQUEST_SECONDS) as response:
                return json.loads(response.read())
        except urllib.error.HTTPError as error:
            try:
                problem = json.loads(error.read())
            except ValueError:
                problem = {}
            if not isinstance(problem, dict):
                problem = {}
            code = problem.get("code") or f"http_{error.code}"
            failure = ZeroCaptchaError(
                code,
                problem.get("detail") or problem.get("title") or f"HTTP {error.code}",
                problem.get("request_id") or error.headers.get("X-Request-Id"),
            )
            if not (error.code in RETRYABLE or (error.code == 409 and code == "idempotency_key_in_use")):
                raise failure from None
            wait = _retry_after(error.headers)
        except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException):
            failure = ZeroCaptchaError("network", f"{method} {path} got no answer")
        except ValueError:
            # The body is part of the answer: one cut short is retried as no answer is, with the
            # same Idempotency-Key, so a task the API made is returned rather than made again.
            failure = ZeroCaptchaError("network", f"{method} {path} got its answer cut short")
        if wait is None:
            wait = min(2**attempt, 16) * random.uniform(0.5, 1.0)
        if time.monotonic() + wait > deadline:
            raise failure
        time.sleep(wait)
        attempt += 1


def solve(task):
    """Solves one task and returns its solution: {"token"} for Turnstile, plus "userAgent" and
    "cookie" for a Cloudflare challenge page. Raises ZeroCaptchaError with the API's code, such
    as insufficient_funds, or the task's own, such as ERROR_CAPTCHA_UNSOLVABLE (never charged)."""
    deadline = time.monotonic() + DEADLINE_SECONDS
    # One key per task, sent on every retry of the create, so a lost reply never makes two tasks.
    idempotency_key = str(uuid.uuid4())
    created = _call("POST", "/v1/tasks", deadline, body=task, idempotency_key=idempotency_key)
    while True:
        if time.monotonic() + POLL_SECONDS > deadline:
            raise ZeroCaptchaError("wait_timeout", f"task {created['id']} is still running")
        time.sleep(POLL_SECONDS)
        current = _call("GET", f"/v1/tasks/{created['id']}", deadline)
        if current["status"] == "succeeded" and current.get("solution"):
            return current["solution"]
        if current["status"] in ("succeeded", "failed", "expired"):
            raise ZeroCaptchaError(
                current.get("errorCode") or "ERROR_TOKEN_EXPIRED",
                current.get("errorDescription")
                or f"the task {current['status']} without a usable token",
            )


if __name__ == "__main__":
    if not 3 <= len(sys.argv) <= 5:
        sys.exit("usage: python zerocaptcha.py <websiteURL> <websiteKey> [action] [cdata]")
    task = {"type": "TurnstileTaskProxyless", "websiteURL": sys.argv[1], "websiteKey": sys.argv[2]}
    # The widget's action and cData, sent only when it sets them.
    if len(sys.argv) > 3 and sys.argv[3]:
        task["action"] = sys.argv[3]
    if len(sys.argv) > 4 and sys.argv[4]:
        task["cdata"] = sys.argv[4]
    try:
        solution = solve(task)
    except ZeroCaptchaError as failed:
        sys.exit(str(failed))
    print(solution["token"])
```

### zerocaptcha.go: Go 1.22+ (standard library)

Run: `go run zerocaptcha.go <websiteURL> <websiteKey> [action] [cdata]`

```go
// ZeroCaptcha reference client for Go 1.22 or later: standard library only.
//
//	ZEROCAPTCHA_KEY=zc_live_... go run zerocaptcha.go https://example.com/login 0x4AAAAAAAB1cD2eF3gH4iJ5 login session-7f3a9c2e
//
// The arguments are the page with the widget, its site key (data-sitekey), and its action and
// cData when it sets them (data-action and data-cdata, or the action and cData options of
// turnstile.render()): many sites check both when they verify the token. It creates a Turnstile
// task with POST /v1/tasks, polls GET /v1/tasks/{id} every 2 seconds and prints the token. Copy
// Solve into your own code to use it there, with the same fields, plus proxy and callbackUrl if
// you want them. The key comes from the environment, never from the source.
package main

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	mathrand "math/rand/v2"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const (
	pollInterval   = 2 * time.Second   // between reads of one task
	solveDeadline  = 180 * time.Second // the whole solve; a task expires unsolved after its own deadline
	requestTimeout = 15 * time.Second  // one HTTP request
)

var apiURL = strings.TrimRight(envOr("ZEROCAPTCHA_API", "https://api.zerocaptcha.io"), "/")

// Error is a refusal from the API, such as insufficient_funds, or how a task ended, such as
// ERROR_CAPTCHA_UNSOLVABLE (never charged).
type Error struct {
	Code, Message, RequestID string
}

func (e *Error) Error() string { return e.Code + ": " + e.Message }

// Solution is what a solved task yields: the token, and for a Cloudflare challenge page the
// user agent its cf_clearance cookie is bound to.
type Solution struct {
	Token     string  `json:"token"`
	UserAgent *string `json:"userAgent"`
	Cookie    *struct {
		Name  string `json:"name"`
		Value string `json:"value"`
	} `json:"cookie"`
}

type task struct {
	ID               string    `json:"id"`
	Status           string    `json:"status"`
	ErrorCode        *string   `json:"errorCode"`
	ErrorDescription *string   `json:"errorDescription"`
	Solution         *Solution `json:"solution"`
}

type problem struct {
	Code      string `json:"code"`
	Title     string `json:"title"`
	Detail    string `json:"detail"`
	RequestID string `json:"request_id"`
}

func envOr(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}

func newKey() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

// call makes one API call, retried while the API says a retry can help: a lost connection, 429,
// 5xx and 409 idempotency_key_in_use. It waits as Retry-After asks, else 1, 2, 4, 8, then 16 s.
func call(ctx context.Context, method, path string, body any, idempotencyKey string, out any) error {
	key := os.Getenv("ZEROCAPTCHA_KEY")
	if key == "" {
		return &Error{Code: "missing_key", Message: "Set ZEROCAPTCHA_KEY to your API key."}
	}
	var payload []byte
	if body != nil {
		payload, _ = json.Marshal(body)
	}
	for attempt := 0; ; attempt++ {
		reqCtx, cancel := context.WithTimeout(ctx, requestTimeout)
		req, err := http.NewRequestWithContext(reqCtx, method, apiURL+path, bytes.NewReader(payload))
		if err != nil {
			cancel()
			return err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		req.Header.Set("Accept", "application/json")
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}
		if idempotencyKey != "" {
			req.Header.Set("Idempotency-Key", idempotencyKey)
		}
		var wait time.Duration
		var failure error
		resp, err := http.DefaultClient.Do(req)
		var data []byte
		if err == nil {
			// The body is part of the answer: one cut short is retried as no answer is, with the
			// same Idempotency-Key, so a task the API made is returned rather than made again.
			data, err = io.ReadAll(resp.Body)
			resp.Body.Close()
		}
		if err == nil && resp.StatusCode < 300 && json.Unmarshal(data, out) != nil {
			err = errors.New("the answer was cut short")
		}
		if err != nil {
			failure = &Error{Code: "network", Message: method + " " + path + " got no answer"}
		} else if resp.StatusCode < 300 {
			cancel()
			return nil
		} else {
			var p problem
			_ = json.Unmarshal(data, &p)
			if p.Code == "" {
				p.Code = "http_" + strconv.Itoa(resp.StatusCode)
			}
			message := p.Detail
			if message == "" {
				message = p.Title
			}
			if p.RequestID == "" {
				p.RequestID = resp.Header.Get("X-Request-Id")
			}
			failure = &Error{Code: p.Code, Message: message, RequestID: p.RequestID}
			retryable := resp.StatusCode == 429 || resp.StatusCode >= 500 ||
				(resp.StatusCode == 409 && p.Code == "idempotency_key_in_use")
			if !retryable {
				cancel()
				return failure
			}
			if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
				wait = time.Duration(seconds) * time.Second
			}
		}
		cancel()
		if wait == 0 {
			backoff := math.Min(math.Pow(2, float64(attempt)), 16)
			wait = time.Duration(backoff * (0.5 + mathrand.Float64()/2) * float64(time.Second))
		}
		if deadline, ok := ctx.Deadline(); ok && time.Now().Add(wait).After(deadline) {
			return failure
		}
		select {
		case <-ctx.Done():
			return failure
		case <-time.After(wait):
		}
	}
}

// Solve solves one task, such as {"type": "TurnstileTaskProxyless", "websiteURL": ...,
// "websiteKey": ...}, and returns its solution.
func Solve(ctx context.Context, newTask map[string]any) (*Solution, error) {
	ctx, cancel := context.WithTimeout(ctx, solveDeadline)
	defer cancel()
	// One key per task, sent on every retry of the create, so a lost reply never makes two tasks.
	var created task
	if err := call(ctx, http.MethodPost, "/v1/tasks", newTask, newKey(), &created); err != nil {
		return nil, err
	}
	for {
		select {
		case <-ctx.Done():
			return nil, &Error{Code: "wait_timeout", Message: "task " + created.ID + " is still running"}
		case <-time.After(pollInterval):
		}
		var current task
		if err := call(ctx, http.MethodGet, "/v1/tasks/"+created.ID, nil, "", &current); err != nil {
			return nil, err
		}
		switch current.Status {
		case "succeeded", "failed", "expired":
			if current.Status == "succeeded" && current.Solution != nil {
				return current.Solution, nil
			}
			failure := &Error{Code: "ERROR_TOKEN_EXPIRED", Message: "the task " + current.Status + " without a usable token"}
			if current.ErrorCode != nil {
				failure.Code = *current.ErrorCode
			}
			if current.ErrorDescription != nil {
				failure.Message = *current.ErrorDescription
			}
			return nil, failure
		}
	}
}

func main() {
	if len(os.Args) < 3 || len(os.Args) > 5 {
		fmt.Fprintln(os.Stderr, "usage: go run zerocaptcha.go <websiteURL> <websiteKey> [action] [cdata]")
		os.Exit(2)
	}
	newTask := map[string]any{
		"type":       "TurnstileTaskProxyless",
		"websiteURL": os.Args[1],
		"websiteKey": os.Args[2],
	}
	// The widget's action and cData, sent only when it sets them.
	if len(os.Args) > 3 && os.Args[3] != "" {
		newTask["action"] = os.Args[3]
	}
	if len(os.Args) > 4 && os.Args[4] != "" {
		newTask["cdata"] = os.Args[4]
	}
	solution, err := Solve(context.Background(), newTask)
	if err != nil {
		// Such as "insufficient_funds: …" or "ERROR_CAPTCHA_UNSOLVABLE: …"; errors.As(err, &e)
		// with e *Error gives the code on its own.
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(solution.Token)
}
```

### zerocaptcha.sh: bash with curl and jq

Run: `bash zerocaptcha.sh <websiteURL> <websiteKey> [action] [cdata]`

```bash
#!/usr/bin/env bash
# ZeroCaptcha reference client for bash, with curl and jq.
#
#   ZEROCAPTCHA_KEY=zc_live_... bash zerocaptcha.sh https://example.com/login 0x4AAAAAAAB1cD2eF3gH4iJ5 login session-7f3a9c2e
#
# The arguments are the page with the widget, its site key (data-sitekey), and its action and
# cData when it sets them (data-action and data-cdata, or the action and cData options of
# turnstile.render()): many sites check both when they verify the token. It creates a Turnstile
# task with POST /v1/tasks, polls GET /v1/tasks/{id} every 2 seconds and prints the token. The key
# comes from the environment, never from the source.
set -euo pipefail

API="${ZEROCAPTCHA_API:-https://api.zerocaptcha.io}"
API="${API%/}"
POLL_SECONDS=2        # between reads of one task
DEADLINE_SECONDS=180  # the whole solve; a task expires unsolved after its own deadline
REQUEST_SECONDS=15    # one HTTP request

fail() { echo "$1" >&2; exit 1; }

[ "$#" -ge 2 ] && [ "$#" -le 4 ] ||
  { echo "usage: bash zerocaptcha.sh <websiteURL> <websiteKey> [action] [cdata]" >&2; exit 2; }
[ -n "${ZEROCAPTCHA_KEY:-}" ] || fail "missing_key: Set ZEROCAPTCHA_KEY to your API key."

deadline=$(( $(date +%s) + DEADLINE_SECONDS ))
body_file=$(mktemp)
header_file=$(mktemp)
trap 'rm -f "$body_file" "$header_file"' EXIT

# call METHOD PATH [BODY] [IDEMPOTENCY_KEY]: prints the reply's JSON. Retries while the API says a
# retry can help (no answer, 429, 5xx, 409 idempotency_key_in_use), waiting as Retry-After asks,
# else 1, 2, 4, 8, then 16 seconds.
call() {
  local method=$1 path=$2 body=${3:-} key=${4:-} attempt=0 status code wait
  local args=(-sS --max-time "$REQUEST_SECONDS" -X "$method" -o "$body_file" -D "$header_file"
    -w '%{http_code}' -H "Authorization: Bearer $ZEROCAPTCHA_KEY" -H "Accept: application/json")
  [ -n "$body" ] && args+=(-H "Content-Type: application/json" --data "$body")
  [ -n "$key" ] && args+=(-H "Idempotency-Key: $key")
  while true; do
    status=$(curl "${args[@]}" "$API$path" 2>/dev/null) || status=000
    if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then cat "$body_file"; return 0; fi
    code=$(jq -r '.code // empty' "$body_file" 2>/dev/null || true)
    code=${code:-http_$status}
    if [ "$status" = 000 ]; then code=network; fi
    if ! { [ "$status" = 000 ] || [ "$status" = 429 ] || [ "$status" -ge 500 ] ||
      { [ "$status" = 409 ] && [ "$code" = idempotency_key_in_use ]; }; }; then
      fail "$code: $(jq -r '.detail // .title // empty' "$body_file" 2>/dev/null || true)"
    fi
    wait=$(tr -d '\r' <"$header_file" | awk 'tolower($1) == "retry-after:" { print $2 }')
    if ! [[ "$wait" =~ ^[0-9]+$ ]]; then wait=$(( attempt < 4 ? 1 << attempt : 16 )); fi
    [ $(( $(date +%s) + wait )) -le "$deadline" ] || fail "$code: $method $path gave up"
    sleep "$wait"
    attempt=$(( attempt + 1 ))
  done
}

# One key per task, sent on every retry of the create, so a lost reply never makes two tasks.
intent="$(date -u +%Y%m%dT%H%M%SZ)-$RANDOM$RANDOM$RANDOM"
# The widget's action and cData, sent only when it sets them.
task=$(jq -cn --arg url "$1" --arg key "$2" --arg action "${3:-}" --arg cdata "${4:-}" \
  '{type: "TurnstileTaskProxyless", websiteURL: $url, websiteKey: $key}
   + (if $action != "" then {action: $action} else {} end)
   + (if $cdata != "" then {cdata: $cdata} else {} end)')
id=$(call POST /v1/tasks "$task" "$intent" | jq -r '.id')

while true; do
  [ $(( $(date +%s) + POLL_SECONDS )) -le "$deadline" ] || fail "wait_timeout: task $id is still running"
  sleep "$POLL_SECONDS"
  current=$(call GET "/v1/tasks/$id")
  case $(jq -r '.status' <<<"$current") in
    succeeded)
      token=$(jq -r '.solution.token // empty' <<<"$current")
      [ -n "$token" ] || fail "ERROR_TOKEN_EXPIRED: the task succeeded, but its token has expired"
      echo "$token"
      exit 0
      ;;
    failed | expired)
      fail "$(jq -r '"\(.errorCode // "ERROR_CAPTCHA_UNSOLVABLE"): \(.errorDescription // "the task ended without a token")"' <<<"$current")"
      ;;
  esac
done
```

## Checklist before you say you are done

- [ ] The base URL is read from `ZEROCAPTCHA_API`, defaulting to `https://api.zerocaptcha.io`; no other host is called.
- [ ] The API key is read from `ZEROCAPTCHA_KEY` (or the project's secret store), never written in source, logs, error messages, URLs or anything sent to a browser.
- [ ] Every create sends an `Idempotency-Key` made once per task and reused only when retrying that same create.
- [ ] Results are read every 2 seconds, or received by a signed callback, and the wait stops at a deadline (the task's own `deadline`, 150 s by default, plus a margin).
- [ ] Both the HTTP status and the body are checked on every reply; a non-JSON reply is an error, not a result.
- [ ] 429, 5xx and `idempotency_key_in_use` are retried after `Retry-After`, else with exponential backoff from 1 s to 16 s with jitter; nothing else is retried as is.
- [ ] `failed` and `expired` tasks are reported with their `errorCode` and never retried in a loop; a new task is created only when the caller still needs a token.
- [ ] A Turnstile token is used at once: it works once, for 300 seconds.
- [ ] A Turnstile task sends the widget's action and cData whenever the widget sets them, exactly as it sets them (`data-action` and `data-cdata`, or the `action` and `cData` options of `turnstile.render()`): `action` and `cdata` on REST, `metadata.action` and `metadata.cdata` in the createTask format, `action` and `data` in 2Captcha's. Many sites refuse a token solved without them.
- [ ] A challenge page's `cf_clearance` cookie is sent with `solution.userAgent` as the User-Agent, through the same proxy the task used.
- [ ] Callbacks, if used, are checked with the HMAC-SHA256 signature over the raw body, in constant time, refusing timestamps more than 300 seconds away, and each task is handled once (by `ZeroCaptcha-Delivery` or task ID).
- [ ] Money is handled as the decimal strings the API sends (`price`, `cost`, `available`), never as floats.
- [ ] Tests run against a stand-in for the API, not the real one: every real task is charged.
