# ZeroCaptcha docs, in full > Every page of the ZeroCaptcha developer docs as Markdown, then the integration brief for AI coding assistants. Generated when the site was built, from the docs and the API's OpenAPI contract (https://zerocaptcha.io/openapi.json). # Introduction > What ZeroCaptcha does, how a task goes from createTask to a token, the three API formats, your key and your prepaid balance, and where to go next. Source: https://zerocaptcha.io/docs ZeroCaptcha solves Cloudflare Turnstile widgets and Cloudflare challenge pages through an HTTP API, for developers who automate, test or collect data on sites they are allowed to access. Sign up with an email and a password, create your key, add funds in crypto, and send your first task: the [quickstart](https://zerocaptcha.io/docs/quickstart) walks through it with one complete program in Python, Node, Go or curl. ## What you can solve | Task type | What you send | What you get | | --- | --- | --- | | `TurnstileTaskProxyless` | The page's URL and its Turnstile site key | A token for the page's form, valid once for 300 seconds | | `TurnstileTask` | The same, and your proxy | A token, solved through your proxy | | `CloudflareChallengeTask` | The page behind a Cloudflare challenge, and your proxy | Its `cf_clearance` cookie and the user agent it is bound to | See [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile) and [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges) for every field. ## How a task works 1. **Create a task.** Send the page and what the task needs. The price is held from your prepaid balance, and you get a task ID back at once. 2. **Get the result.** Ask for the task by ID every 2 seconds until it ends, or name a [callback](https://zerocaptcha.io/docs/callbacks) URL and we call it when the task ends. A solved task returns the token with the time it was issued and when it expires. 3. **Pay only for success.** A solved task turns the hold into a charge. A failed or timed-out task releases the hold, so it costs nothing. [How a task works](https://zerocaptcha.io/docs/how-tasks-work) covers every state, how long each step takes and what is charged when. ## Three formats, one API The API is at `https://api.zerocaptcha.io`. It speaks three formats over the same pipeline, with the same prices and checks: - **REST v1:** resource URLs, bearer keys, an `Idempotency-Key` header for safe retries, and [problem details](https://zerocaptcha.io/docs/reference/errors) for every error. Use it for new code. - **The [createTask format](https://zerocaptcha.io/docs/createtask):** `createTask`, `getTaskResult` and `getBalance`, the shape existing CAPTCHA-solving clients already speak. Change the base URL and the key, and they work unchanged. - **The [2Captcha format](https://zerocaptcha.io/docs/2captcha):** `in.php` and `res.php`, for clients written for 2Captcha's API v1. ZeroCaptcha is not affiliated with 2Captcha, CapSolver or Anti-Captcha; their names appear only to say which API formats it speaks. ## Your API key There is one kind of key, and it starts with `zc_live_`. Every task it creates is real, and charged from your balance only when it is solved: there is no sandbox, test key or free credit. Each key is shown once, when you create it. See [Authentication](https://zerocaptcha.io/docs/authentication) for sending it and keeping it safe, and [API keys](https://zerocaptcha.io/docs/keys) for scopes, allowlists, spend caps, rotation and revocation. ## Your balance ZeroCaptcha is prepaid, in US dollars. You add funds in crypto, from $10 with no maximum, through our payment processor's checkout page, and the balance pays for your tasks. Top-ups are final. See [Billing](https://zerocaptcha.io/docs/funds). What each solved task costs today: | Task | API task type | Price per task | Per 1,000 solved | | --- | --- | --- | --- | | Cloudflare Turnstile, your proxy | `TurnstileTask` | $0.0007 | $0.70 | | Cloudflare Turnstile, proxyless | `TurnstileTaskProxyless` | $0.0008 | $0.80 | | Cloudflare WAF and 5-second challenge, your proxy | `CloudflareChallengeTask` | $0.0012 | $1.20 | ## Hand it to your AI assistant Working with an AI coding assistant? Give it [one file](https://zerocaptcha.io/docs/ai) and it has the whole API: every call, field and error code, the retry rules, tested clients in four languages and a checklist. Every page here also has **Copy as Markdown** and **Open in Claude** or **ChatGPT** at the top. ## Reference - [API reference](https://zerocaptcha.io/docs/reference/api): every operation your code can call, generated from the API's contract, with samples. The contract itself is at [/openapi.json](https://zerocaptcha.io/openapi.json). - [Errors](https://zerocaptcha.io/docs/reference/errors): every error code, whether a retry helps, and what it costs. - [Limits](https://zerocaptcha.io/docs/reference/limits), the [callback payload](https://zerocaptcha.io/docs/reference/callbacks), the [FAQ](https://zerocaptcha.io/docs/reference/faq) and the [changelog](https://zerocaptcha.io/docs/reference/changelog). - The [glossary](https://zerocaptcha.io/glossary): the terms these pages use, such as sitekey, cData and cf_clearance. --- # 2Captcha format > Use a 2Captcha client with ZeroCaptcha for Cloudflare Turnstile. in.php and res.php, their parameters, replies and error codes, and what differs. Source: https://zerocaptcha.io/docs/2captcha A client written for 2Captcha's API v1 solves Cloudflare Turnstile with ZeroCaptcha after two changes: its base URL, to our API's address, and its key, to your ZeroCaptcha key (`zc_live_…`). It calls `in.php` to create a task and `res.php` to read the result or your balance, as 2Captcha documents them. A task made this way is priced, held and charged exactly as one made with REST or the createTask format: the same prices, the same checks, and nothing charged unless it is solved. ZeroCaptcha is not affiliated with 2Captcha. Its name appears here only to say which format this is. ## Create a task: in.php `GET /in.php` with the parameters in the query string, or `POST /in.php` with them as a form (`application/x-www-form-urlencoded` or `multipart/form-data`) or a JSON object. Parameters in the query string win over the same ones in the body. ```sh # action and data are the widget's data-action and data-cdata (or the action and cData options of # turnstile.render()); leave out any the widget does not set. curl -H "Idempotency-Key: login-2026-10-01-0001" \ "$ZEROCAPTCHA_API/in.php?key=$ZEROCAPTCHA_KEY&method=turnstile&sitekey=0x4AAAAAAAB1cD2eF3gH4iJ5&pageurl=https%3A%2F%2Fshop.example.com%2Flogin&action=login&data=session-7f3a9c2e&json=1" ``` ```json { "status": 1, "request": "10000004821" } ``` | Parameter | Required | What it is | | --- | --- | --- | | `key` | Yes | Your API key, `zc_live_…`. | | `method` | Yes | `turnstile`. Any other method is `ERROR_BAD_PARAMETERS`. | | `sitekey` | Yes | The widget's site key. | | `pageurl` | Yes | The full URL of the page with the widget: a public `http` or `https` page. | | `action` | When the widget sets one | The widget's action: its `data-action`, or `action` in `turnstile.render`. | | `data` | When the widget sets one | The widget's cData: its `data-cdata`, or `cData` in `turnstile.render`. Many sites check both when they verify the token; see [action and cData](https://zerocaptcha.io/docs/action-and-cdata). | | `pingback` | No | A URL to call with the result when the task ends. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). | | `json` | No | `1` for JSON replies; `0`, the default, for plain text. | | `proxy` | No | Your proxy as `login:password@host:port` or `host:port`; the task is then solved through it. | | `proxytype` | No | `HTTP`, the default, or `HTTPS`. SOCKS is not supported yet (`ERROR_PROXY_FORMAT`). | | `soft_id`, `header_acao`, `pagedata`, `userAgent` | No | Accepted and ignored. This format does not serve Cloudflare challenge pages; use a [challenge task](https://zerocaptcha.io/docs/challenges) in REST or the createTask format. | The reply is `OK|` in plain text, or `{"status": 1, "request": ""}` with `json=1`. An `Idempotency-Key` header works as it does on `POST /v1/tasks`: sending the same one again returns the first task instead of making another. > **Note** > > Task IDs in this format are numbers, as 2Captcha's are, such as `10000004821`, so a client that > parses them as numbers works unchanged. Each stands for one task, which the dashboard and REST name > by its UUID, such as `0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b`. `res.php` takes either, so a task made > with REST or the createTask format can be read here too. ## Read the result: res.php `GET /res.php?key=…&action=get&id=`, every 2 seconds until it is ready: | Reply (plain text) | Reply with `json=1` | Meaning | | --- | --- | --- | | `CAPCHA_NOT_READY` | `{"status": 0, "request": "CAPCHA_NOT_READY"}` | Still running: ask again shortly. | | `OK\|` | `{"status": 1, "request": ""}` | Solved and charged. The token works once, for 300 seconds. | | `ERROR_CAPTCHA_UNSOLVABLE` | `{"status": 0, "request": "ERROR_CAPTCHA_UNSOLVABLE", …}` | Not solved; nothing is charged. | | `ERROR_TOKEN_EXPIRED` | `{"status": 0, "request": "ERROR_TOKEN_EXPIRED", …}` | Solved and charged, but its token has expired. | `action=get2` answers `OK||` instead, with what the task cost in US dollars, or `"price"` beside the token with `json=1`. `action=get` with `ids` in place of `id` reads up to 100 tasks at once: `ids=10000004821,10000004822` answers each one's token, `CAPCHA_NOT_READY` or error code, in the order asked, joined by `|`, such as `CAPCHA_NOT_READY|0.AbC…|ERROR_CAPTCHA_UNSOLVABLE`, or the same text in `request` with `json=1`. `action=getbalance` answers your available balance in US dollars, such as `12.3456`, or `{"status": 1, "request": "12.3456"}` with `json=1`. ## Report a token: reportbad and reportgood Once a task is solved, `GET /res.php?key=…&action=reportbad&id=` says the site refused its token, and `action=reportgood` says it took it. The reply is `OK_REPORT_RECORDED`, or `{"status": 1, "request": "OK_REPORT_RECORDED"}` with `json=1`. A report is recorded against the task, where our staff read it to find sites and settings that fail. Nothing is refunded: a task is charged only when it is solved, and every charge is final. Each task takes one report: a second is `ERROR_DUPLICATE_REPORT`, and a report of a task that was not solved, which has no token to judge, is `ERROR_REPORT_NOT_RECORDED`. With `json=1`, every failure carries `error_text`, which says what it means and what to do. ## Samples **curl** ```sh # Submit. action and data are the widget's data-action and data-cdata, or the action and cData # options of turnstile.render(): leave out any the widget does not set. Add # -d proxy=user:pass@proxy.example.net:8080 -d proxytype=HTTP to solve through your own proxy, and # --data-urlencode pingback=https://hooks.example.com/zerocaptcha to be called when it ends. reply=$(curl -s "$ZEROCAPTCHA_API/in.php" -H "Idempotency-Key: $(uuidgen)" \ -d key="$ZEROCAPTCHA_KEY" -d method=turnstile -d sitekey=0x4AAAAAAAB1cD2eF3gH4iJ5 \ --data-urlencode pageurl=https://shop.example.com/login -d action=login -d data=session-7f3a9c2e -d json=1) # status 0 is a refusal: request names the code, error_text says what to do. if [ "$(jq -r .status <<<"$reply")" != 1 ]; then echo "$reply" >&2; exit 1; fi TASK_ID=$(jq -r .request <<<"$reply") # Then every 2 seconds, until status is 1 (the token) or request is no longer CAPCHA_NOT_READY: curl -s "$ZEROCAPTCHA_API/res.php?key=$ZEROCAPTCHA_KEY&action=get&id=$TASK_ID&json=1" ``` **Node** ```js const api = process.env.ZEROCAPTCHA_API; const key = process.env.ZEROCAPTCHA_KEY; const submitted = await fetch(`${api}/in.php`, { method: "POST", // One Idempotency-Key per task: sending the submit again with it returns the same task. headers: { "Idempotency-Key": crypto.randomUUID() }, body: new URLSearchParams({ key, method: "turnstile", sitekey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // the widget's data-sitekey pageurl: "https://shop.example.com/login", // the page with the widget // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. action: "login", data: "session-7f3a9c2e", // proxy: "user:pass@proxy.example.net:8080", proxytype: "HTTP", // your own proxy // pingback: "https://hooks.example.com/zerocaptcha", // to be called when it ends json: "1", }), }).then((response) => response.json()); if (submitted.status !== 1) throw new Error(submitted.request); for (;;) { await new Promise((resolve) => setTimeout(resolve, 2000)); const query = new URLSearchParams({ key, action: "get", id: submitted.request, json: "1" }); const result = await fetch(`${api}/res.php?${query}`).then((response) => response.json()); if (result.status === 1) { console.log(result.request); break; } if (result.request !== "CAPCHA_NOT_READY") throw new Error(`${result.request}: ${result.error_text}`); } ``` **Python** ```python import os import time import uuid import requests API = os.environ["ZEROCAPTCHA_API"] KEY = os.environ["ZEROCAPTCHA_KEY"] submitted = requests.post( f"{API}/in.php", # One Idempotency-Key per task: sending the submit again with it returns the same task. headers={"Idempotency-Key": str(uuid.uuid4())}, data={ "key": KEY, "method": "turnstile", "sitekey": "0x4AAAAAAAB1cD2eF3gH4iJ5", # the widget's data-sitekey "pageurl": "https://shop.example.com/login", # the page with the widget # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "action": "login", "data": "session-7f3a9c2e", # "proxy": "user:pass@proxy.example.net:8080", "proxytype": "HTTP", # your own proxy # "pingback": "https://hooks.example.com/zerocaptcha", # to be called when it ends "json": 1, }, timeout=15, ).json() if submitted["status"] != 1: raise RuntimeError(submitted["request"]) while True: time.sleep(2) result = requests.get( f"{API}/res.php", params={"key": KEY, "action": "get", "id": submitted["request"], "json": 1}, timeout=15, ).json() if result["status"] == 1: print(result["request"]) break if result["request"] != "CAPCHA_NOT_READY": raise RuntimeError(f"{result['request']}: {result.get('error_text')}") ``` **Go** ```go package main import ( "crypto/rand" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" ) type answer struct { Status int `json:"status"` Request string `json:"request"` ErrorText string `json:"error_text"` } func decode(resp *http.Response, err error) (answer, error) { var a answer if err != nil { return a, err } defer resp.Body.Close() return a, json.NewDecoder(resp.Body).Decode(&a) } func main() { api, key := os.Getenv("ZEROCAPTCHA_API"), os.Getenv("ZEROCAPTCHA_KEY") form := url.Values{ "key": {key}, "method": {"turnstile"}, "json": {"1"}, "sitekey": {"0x4AAAAAAAB1cD2eF3gH4iJ5"}, // the widget's data-sitekey "pageurl": {"https://shop.example.com/login"}, // the page with the widget // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "action": {"login"}, "data": {"session-7f3a9c2e"}, // "proxy": {"user:pass@proxy.example.net:8080"}, "proxytype": {"HTTP"}, // your own proxy // "pingback": {"https://hooks.example.com/zerocaptcha"}, // to be called when it ends } req, _ := http.NewRequest(http.MethodPost, api+"/in.php", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // One Idempotency-Key per task: sending the submit again with it returns the same task. req.Header.Set("Idempotency-Key", rand.Text()) submitted, err := decode(http.DefaultClient.Do(req)) if err != nil || submitted.Status != 1 { fmt.Fprintln(os.Stderr, "in.php:", err, submitted.Request) os.Exit(1) } for { time.Sleep(2 * time.Second) query := url.Values{"key": {key}, "action": {"get"}, "id": {submitted.Request}, "json": {"1"}} result, err := decode(http.Get(api + "/res.php?" + query.Encode())) switch { case err != nil: fmt.Fprintln(os.Stderr, "res.php:", err) os.Exit(1) case result.Status == 1: fmt.Println(result.Request) return case result.Request != "CAPCHA_NOT_READY": fmt.Fprintln(os.Stderr, result.Request, result.ErrorText) os.Exit(1) } } } ``` **PHP** ```php [ 'method' => 'POST', // One Idempotency-Key per task: sending the submit again with it returns the same task. 'header' => "Content-Type: application/x-www-form-urlencoded\r\nIdempotency-Key: " . bin2hex(random_bytes(16)), 'content' => http_build_query([ 'key' => $key, 'method' => 'turnstile', 'sitekey' => '0x4AAAAAAAB1cD2eF3gH4iJ5', // the widget's data-sitekey 'pageurl' => 'https://shop.example.com/login', // the page with the widget // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. 'action' => 'login', 'data' => 'session-7f3a9c2e', // 'proxy' => 'user:pass@proxy.example.net:8080', 'proxytype' => 'HTTP', // your own proxy // 'pingback' => 'https://hooks.example.com/zerocaptcha', // to be called when it ends 'json' => 1, ]), ]])), true); if ($submitted['status'] !== 1) { throw new RuntimeException($submitted['request']); } while (true) { sleep(2); $query = http_build_query(['key' => $key, 'action' => 'get', 'id' => $submitted['request'], 'json' => 1]); $result = json_decode(file_get_contents("$api/res.php?$query"), true); if ($result['status'] === 1) { echo $result['request'], PHP_EOL; break; } if ($result['request'] !== 'CAPCHA_NOT_READY') { throw new RuntimeException("{$result['request']}: {$result['error_text']}"); } } ``` ## Error codes Every reply is HTTP 200, with the code in place of the result. Only a failure outside the format, such as a body over the size limit or the service shedding load, answers with an HTTP error and a [problem document](https://zerocaptcha.io/docs/reference/errors). | Code | When | What to do | | --- | --- | --- | | `ERROR_WRONG_USER_KEY` | The key is missing or is not a ZeroCaptcha key. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_KEY_DOES_NOT_EXIST` | The key is unknown, revoked or expired. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_IP_NOT_ALLOWED` | The key's allowlist does not include this address. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_ACCESS_DENIED` | The key lacks the scope: `tasks` to create and read tasks, `balance` for `getbalance`. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_ZERO_BALANCE` | Your available balance does not cover the task. Add funds. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_SPEND_CAP_REACHED` | The key's daily spend cap is reached. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_PAGEURL` | `pageurl` is missing, or is not a public `http` or `https` page. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_BAD_PARAMETERS` | 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. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_PROXY_FORMAT` | The proxy is not `login:password@host:port` or `host:port`, is SOCKS, or is not public. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_DOMAIN_BLOCKED` | The site is on our blocklist. | Stop: do not send it again. Tell the user; a person must act (support, or not using this site). | | `ERROR_ACCOUNT_SUSPENDED` | The account is suspended. | Stop: do not send it again. Tell the user; a person must act (support, or not using this site). | | `ERROR_NO_SLOT_AVAILABLE` | The queue is full for a moment: send the task again shortly. | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | | `ERROR_IDEMPOTENCY_KEY_REUSED` | The `Idempotency-Key` was used for a different task. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_IDEMPOTENCY_KEY_IN_USE` | The first request with this `Idempotency-Key` is still being served: send it again shortly. | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | | `ERROR_SERVICE_UNAVAILABLE` | We could not serve the request just now: try again shortly. | Retry the same request with exponential backoff (a create with the same Idempotency-Key); honour Retry-After when present. | | `MAX_USER_TURN` | `in.php` is called too often: wait the seconds in `Retry-After`. | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | | `ERROR_EMPTY_ACTION` | `res.php` was called without `action`. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_WRONG_ID_FORMAT` | `id` is not a task ID. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_WRONG_CAPTCHA_ID` | No task of this account has that ID. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_REPORT_NOT_RECORDED` | `reportbad` or `reportgood` named a task that did not succeed. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_DUPLICATE_REPORT` | `reportbad` or `reportgood` named a task reported already: one report per task. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_CAPTCHA_UNSOLVABLE` | The task was not solved, or not before its deadline; nothing is charged. | The task is over and nothing more will come of it: create a new task if you still need a token. | | `ERROR_BAD_PROXY` | Your proxy pointed at an address tasks cannot use; nothing is charged. | Do not retry as is: fix the request, key, balance or setting the message names, then try again. | | `ERROR_TOKEN_EXPIRED` | The task was solved and charged, but its token has expired. | The task is over and nothing more will come of it: create a new task if you still need a token. | | `ERROR: 1005` | `res.php` is called too often: wait the seconds in `Retry-After`. | Wait for Retry-After (or 2 to 5 seconds when absent), then send the same request again. | `in.php` has no rate budget by default, as task creation has none; `res.php` reads share the read budgets with the other formats. Over one, the reply is `MAX_USER_TURN` or `ERROR: 1005`, with `Retry-After`. ## What differs from 2Captcha - Only `method=turnstile`. Other CAPTCHA types are not offered, and Cloudflare challenge pages need the [createTask format](https://zerocaptcha.io/docs/createtask) or REST, whose replies carry the user agent a clearance needs. - `pingback` needs no registration: any public URL works, and each call is signed so you can check it came from us. - `reportbad` never refunds: it is recorded for our staff, and every charge is final. - There is no free trial or test key: every task is real, and charged only when it is solved. Moving an existing client over? See [Migrate a 2Captcha client](https://zerocaptcha.io/docs/migrate-2captcha). --- # Account security > Protect your ZeroCaptcha sign-in with an authenticator app, recovery codes and passkeys, manage your sessions, and see what needs a recent sign-in. Source: https://zerocaptcha.io/docs/account-security Your dashboard sign-in controls your keys and your balance, so it is worth a second factor. Two-factor is optional: nothing requires it, and the dashboard only suggests it. A confirmed email address is needed before you create an API key or add funds. Everything on this page is under **Settings** in the dashboard. ## Passwords A password needs at least 8 characters, and may not be your email address or a common password. A password manager's generated password passes. Forgot it? **Forgot password** on the sign-in page emails a link that works once, for 30 minutes. A new password, by reset or from Settings, signs out every other session, forgets the devices you signed in on, and is emailed to you. A reset does not sign you in: sign in with the new password, and your second factor if you have one. ## Two-factor authentication Once any second factor is on, signing in with your password asks for one more step. ### Authenticator app Any app that supports time-based codes (TOTP) works: 6 digits, a new code every 30 seconds. 1. On **Settings**, turn on the authenticator app and confirm your password. 2. Scan the QR code, or type the key it shows, into your app. 3. Enter a code from the app within 15 minutes. Two-factor is on only once you confirm. Each code works once. Turning the app off needs a second factor, never your password alone, and turning it on or off is emailed to you. ### Recovery codes When you turn on the authenticator app you get 10 recovery codes: each works once, in place of a code from the app, if you lose your phone. Store them somewhere other than your phone, such as a password manager. You can make a new set at any time, which replaces the old one. Dashes, spaces and capitals do not matter when you type one. ### Passkeys A passkey signs you in with your device's fingerprint, face or PIN, with no password and no second step: it is two factors in one. Add one on **Settings** (up to 20), name it, and remove it there. Adding or removing a passkey asks you to confirm it is you, and is emailed to you. An account always keeps a way in: you cannot remove your last passkey while you have no password. You can also sign up with a passkey instead of a password. ## Sessions You stay signed in for up to 30 days, and are signed out after 24 hours without activity. **Settings** lists every session: the browser it was opened in, its address, and when it was last active. End any one of them, or every session but the one you are using, when a device is lost or shared. ## Changes that need a recent sign-in Changing your email address or your password, and adding or removing a second factor, needs you to have signed in, or confirmed your password, within the last 10 minutes. The dashboard asks when it needs to. Managing API keys never needs it. ## Email verification Confirm your email address by opening the link we send when you sign up. Until you do, creating an API key and adding funds are refused with [`email_unverified`](https://zerocaptcha.io/docs/reference/errors#email_unverified), so receipts and security emails always reach you; everything else in the dashboard works. The link works for 24 hours; you can ask for another after a minute. While you change your address, your confirmed one stays in use until the new one is confirmed. ## Security emails We email you when your password changes, when your email address moves (to the old address, if it was verified), when two-factor is turned on or off, when recovery codes are made or one is used, when a passkey is added or removed, and when a new device signs in. If you didn't make the change, reset your password and write to support. ## If your account is suspended A person suspends an account after a report or a check of our logs; nothing does it automatically. Everyone on the account is emailed, with how to appeal. While it lasts, its API keys are refused and it can't make tasks, keys or top-ups, but you can still sign in and read everything. To appeal, open **Support** in the dashboard: the form becomes an appeal, which a person reads and answers by email. If the suspension is lifted, everyone is emailed again and the keys work at once. ## Your data and deleting the account **Settings › Your data** downloads a copy of your data as a JSON file. An owner's covers the account: its people, keys (never the key itself), balance, credits, top-ups, receipts, usage, newest 1,000 tasks, messages to support and activity. A member's covers them. **Settings › Delete account**, for owners, deletes the account at once and for good. It asks for your password or a passkey, and says what is lost first: - Every API key stops working, and tasks still queued are cancelled uncharged. - Everyone on the account is signed out, and their sign-in and personal data are erased: addresses, passwords, sessions, two-factor and passkeys, billing details, messages to support and the activity log. - **Any balance left is lost:** top-ups are final, so it is not refunded. - What the law and our books need stays: charges, credits, top-ups and receipts, under the name "Deleted account", and task records until their retention ends, without their tokens. See the [Privacy Policy](https://zerocaptcha.io/legal/privacy). ## Sign-in protection Sign-in attempts are rate-limited per address and per account, and a device that fails too often is forgotten, so a password cannot be guessed quickly. A code from the authenticator app counts toward the same limits. Your password is never stored, only a slow hash of it. --- # Cloudflare Turnstile action and cData > When a Cloudflare Turnstile task needs the widget's action and cData, where to find them, what happens without them, and how to send them in every format. Source: https://zerocaptcha.io/docs/action-and-cdata A Cloudflare Turnstile widget can carry two values besides its site key: an **action**, a short label such as `login`, and **cData**, a value such as a session ID. When a widget sets them, Cloudflare returns both to the site when it verifies the token, and many sites refuse a token whose action or cData is not the one they expect. So when the widget sets them, send both with the task, exactly as the widget sets them. When it sets neither, leave them out. ## When they're required ZeroCaptcha cannot tell whether a site checks them, so it never requires them: a task without them is solved and charged like any other. The site decides. - **The widget sets an action or cData:** send it. Cloudflare's own advice to sites is to "validate the action and hostname when specified", and its example refuses a token whose action does not match. Treat both values as required for that page. - **The widget sets neither:** leave both out. An action the widget does not have is as wrong as a missing one. - **The cData changes on every visit,** as a session ID does: read it from the page you will submit, just before you create the task, and solve once per visit. Cloudflare allows an action of up to 32 characters and cData of up to 255, each letters, digits, `_` and `-` ([widget configurations](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/)). ZeroCaptcha checks the same limits when you create the task. ## Where to find them Open the page with the widget, then its source or the developer tools' **Elements** panel: - **In the HTML:** the element with the class `cf-turnstile` carries them as `data-action` and `data-cdata`, beside `data-sitekey`: ```html
``` - **In a script:** the page passes them to `turnstile.render()` as the `action` and `cData` options: ```js turnstile.render("#captcha", { sitekey: "0x4AAAAAAAB1cD2eF3gH4iJ5", action: "login", cData: "session-7f3a9c2e", }); ``` Search the page's scripts for `turnstile.render` when the HTML has no `data-sitekey`. In a browser you drive, read them from the live page: the [browser automation](https://zerocaptcha.io/docs/browser-automation) samples do, for both kinds of widget. The [Cloudflare Turnstile sitekey finder](https://zerocaptcha.io/tools/cloudflare-turnstile-sitekey-finder) reads all three values from HTML you paste. ## What happens without them The task still succeeds: the token is real, and it is charged. What changes is the site's answer when it checks the token with Cloudflare's siteverify. The reply names the action and cData the token was solved with ([server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)): ```json { "success": true, "challenge_ts": "2026-10-01T09:30:00.000Z", "hostname": "shop.example.com", "error-codes": [], "action": "login", "cdata": "session-7f3a9c2e" } ``` A site that compares them with what its widget set refuses a token solved without them, or with other values, usually with the same error it shows for a failed check. Nothing in the token or the task tells you that this is why. If a site keeps refusing tokens that arrive in time, compare the action and cData you send with the ones in the live page first. A value outside Cloudflare's limits is refused when you create the task, before anything is held: [`validation_failed`](https://zerocaptcha.io/docs/reference/errors#validation_failed) on REST, `ERROR_INVALID_TASK_DATA` in the createTask format and `ERROR_BAD_PARAMETERS` in the 2Captcha format, each naming the field. ## Send them Each format has its own names for the two fields: | Format | Action | cData | | --- | --- | --- | | REST, `POST /v1/tasks` | `action` | `cdata` | | createTask format | `metadata.action` | `metadata.cdata` | | 2Captcha format, `in.php` | `action` | `data` | | JavaScript and Python clients | `action` | `cdata` | | Go client | `Action` | `CData` | **REST** ```sh # websiteURL is the page with the widget; websiteKey its data-sitekey. action and cdata are the # widget's data-action and data-cdata, or the action and cData options of turnstile.render(): # leave out any the widget does not set. To solve through your own proxy, make the type # TurnstileTask and add "proxy": "http://user:pass@proxy.example.net:8080"; to be called when # the task ends, add "callbackUrl": "https://hooks.example.com/zerocaptcha". The Idempotency-Key is # your ID for this task: sending the create again with it returns the same task. reply=$(curl -sS --fail-with-body "$ZEROCAPTCHA_API/v1/tasks" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: login-2026-10-01-0001" \ -d '{ "type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "action": "login", "cdata": "session-7f3a9c2e" }') || { echo "refused: $reply" >&2; exit 1; } # a problem document; its code says why task_id=$(jq -r .id <<<"$reply") # Read the task every 2 seconds until it ends. while :; do task=$(curl -sS --fail-with-body "$ZEROCAPTCHA_API/v1/tasks/$task_id" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY") || { echo "$task" >&2; exit 1; } case $(jq -r .status <<<"$task") in succeeded) jq -r .solution.token <<<"$task"; break ;; failed | expired) jq -r '"\(.errorCode): \(.errorDescription)"' <<<"$task" >&2; exit 1 ;; esac sleep 2 done ``` **createTask format** ```sh # The createTask format nests the widget's action and cData in the task's metadata: copy them from # its data-action and data-cdata, or the action and cData options of turnstile.render(), and leave # out any the widget does not set. For your own proxy, make the type TurnstileTask and add # "proxy": "http://user:pass@proxy.example.net:8080" to the task; to be called when it ends, add # "callbackUrl": "https://hooks.example.com/zerocaptcha" beside it. The Idempotency-Key is your # ID for this task: sending the create again with it returns the same task. reply=$(curl -sS "$ZEROCAPTCHA_API/createTask" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: login-2026-10-01-0001" \ -d '{ "clientKey": "'"$ZEROCAPTCHA_KEY"'", "task": { "type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "metadata": {"action": "login", "cdata": "session-7f3a9c2e"} } }') if [ "$(jq -r .errorId <<<"$reply")" != 0 ]; then echo "refused: $reply" >&2; exit 1; fi task_id=$(jq -r .taskId <<<"$reply") # Ask getTaskResult every 2 seconds until the task is ready, or failed (errorId 1). while :; do sleep 2 result=$(curl -sS "$ZEROCAPTCHA_API/getTaskResult" -H "Content-Type: application/json" \ -d '{"clientKey": "'"$ZEROCAPTCHA_KEY"'", "taskId": "'"$task_id"'"}') if [ "$(jq -r .errorId <<<"$result")" != 0 ]; then echo "$result" >&2; exit 1; fi if [ "$(jq -r .status <<<"$result")" = ready ]; then jq -r .solution.token <<<"$result"; break; fi done ``` **2Captcha format** ```sh # 2Captcha's in.php takes the widget's action as action and its cData as data: copy them from its # data-action and data-cdata, or the action and cData options of turnstile.render(), and leave out # any the widget does not set. Add proxy=user:pass@proxy.example.net:8080 with proxytype=HTTP to # solve through your own proxy, and pingback=https://hooks.example.com/zerocaptcha to be called # when it ends. The Idempotency-Key is your ID for this task, as on REST. reply=$(curl -sS "$ZEROCAPTCHA_API/in.php" \ -H "Idempotency-Key: login-2026-10-01-0001" \ --data-urlencode "key=$ZEROCAPTCHA_KEY" \ --data-urlencode "method=turnstile" \ --data-urlencode "pageurl=https://shop.example.com/login" \ --data-urlencode "sitekey=0x4AAAAAAAB1cD2eF3gH4iJ5" \ --data-urlencode "action=login" \ --data-urlencode "data=session-7f3a9c2e" \ --data-urlencode "json=1") if [ "$(jq -r .status <<<"$reply")" != 1 ]; then echo "refused: $reply" >&2; exit 1; fi id=$(jq -r .request <<<"$reply") # res.php answers CAPCHA_NOT_READY until the task ends: ask every 2 seconds. while :; do sleep 2 result=$(curl -sS "$ZEROCAPTCHA_API/res.php?key=$ZEROCAPTCHA_KEY&action=get&id=$id&json=1") if [ "$(jq -r .status <<<"$result")" = 1 ]; then jq -r .request <<<"$result"; break; fi if [ "$(jq -r .request <<<"$result")" != CAPCHA_NOT_READY ]; then echo "$result" >&2; exit 1; fi done ``` **Clients** ```ts // JavaScript: the client sends an Idempotency-Key with the create, and throws a TaskFailedError // when the task fails or expires (which costs nothing). const token = await client.solve({ websiteURL: "https://shop.example.com/login", // the page with the widget websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey action: "login", // its data-action, or turnstile.render()'s action option cdata: "session-7f3a9c2e", // its data-cdata, or turnstile.render()'s cData option // proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // callbackUrl: "https://hooks.example.com/zerocaptcha", // to be called when it ends }); ``` ```python # Python: the same names in snake case; TaskFailedError when the task fails or expires. token = client.solve( website_url="https://shop.example.com/login", # the page with the widget website_key="0x4AAAAAAAB1cD2eF3gH4iJ5", # its data-sitekey action="login", # its data-action, or turnstile.render()'s action option cdata="session-7f3a9c2e", # its data-cdata, or turnstile.render()'s cData option # proxy="http://user:pass@proxy.example.net:8080", # to solve through your own proxy # callback_url="https://hooks.example.com/zerocaptcha", # to be called when it ends ) ``` ```go // Go: Action and CData; a *zerocaptcha.TaskFailedError when the task fails or expires. token, err := client.Solve(ctx, zerocaptcha.NewTask{ WebsiteURL: "https://shop.example.com/login", // the page with the widget WebsiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey Action: "login", // its data-action, or turnstile.render()'s action option CData: "session-7f3a9c2e", // its data-cdata, or turnstile.render()'s cData option // Proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // CallbackURL: "https://hooks.example.com/zerocaptcha", // to be called when it ends }) if err != nil { log.Fatal(err) } fmt.Println(token) ``` The clients are not in their registries yet: see [SDKs](https://zerocaptcha.io/docs/sdks). > **Note** > > The createTask format also reads the spellings clients written for other services send: the > action as `action` or CapMonster Cloud's `pageAction` at the top level of the task, and cData as > `cdata`, Anti-Captcha's `cData`, `data` or `turnstileCData` there, or as `metadata.cData`. > `metadata.action` and `metadata.cdata` are what most createTask clients send. See > [the createTask format](https://zerocaptcha.io/docs/createtask#cloudflare-turnstile-task). ## Try it The [Cloudflare Turnstile action and cData demo](https://zerocaptcha.io/captcha-test/cloudflare-turnstile-action-cdata) carries a widget that sets both. Solve it with and without them, and its check shows the action and cData siteverify returns for each token. The [Cloudflare Turnstile action and cData guide](https://zerocaptcha.io/guides/cloudflare-turnstile-action-and-cdata) goes further into what sites use them for. --- # Hand off to AI > Hand your AI coding assistant one file to integrate ZeroCaptcha: the brief, its JSON, Claude Code, Cursor, AGENTS.md and Copilot files, and an MCP server. Source: https://zerocaptcha.io/docs/ai Working with an AI coding assistant? Hand it one file and it has everything it needs to integrate ZeroCaptcha on the first try: the API's address, how to authenticate with a key read from the environment, every task type's request and reply, polling and callbacks, every error code and what to do about it, the retry rules, a tested reference client in Node, Python, Go and bash, and a checklist it works through before it says it is done. Every file is written when this site is built, from the API's own OpenAPI contract and the tested reference clients, and names the API at `https://api.zerocaptcha.io`. None of them contains a key. ## The files - [Integration brief](https://zerocaptcha.io/ai/integration.md): one self-contained file: configuration, every call, polling and callbacks, every error code, retries, reference clients in four languages and a checklist. - [The brief as JSON](https://zerocaptcha.io/ai/zerocaptcha.json): endpoints, fields, enums, errors, rules and examples, for tools. - [Claude Code skill](https://zerocaptcha.io/ai/claude/SKILL.md): save as .claude/skills/zerocaptcha/SKILL.md in your project. - [Cursor rule](https://zerocaptcha.io/ai/cursor/zerocaptcha.mdc): save as .cursor/rules/zerocaptcha.mdc in your project. - [AGENTS.md section](https://zerocaptcha.io/ai/AGENTS.md): paste into your project's AGENTS.md, read by Codex, Jules, Gemini CLI and other agents. - [GitHub Copilot instructions](https://zerocaptcha.io/ai/copilot-instructions.md): save as .github/copilot-instructions.md in your repository. Start with the integration brief. The other files carry the same rules in the form each assistant reads on its own, so it follows them whenever it touches your ZeroCaptcha code: - **Claude Code:** save the skill as `.claude/skills/zerocaptcha/SKILL.md` in your project. - **Cursor:** save the rule as `.cursor/rules/zerocaptcha.mdc`. - **Codex, Jules, Gemini CLI and other agents that read AGENTS.md:** paste the section into your project's `AGENTS.md`. - **GitHub Copilot:** save the instructions as `.github/copilot-instructions.md`, or add them to it. In the dashboard, **Hand off to AI** on the API keys page downloads the integration brief with your account's API address filled in. It never includes your key: give your assistant the key through your environment or secret store, as the brief tells it to, never by pasting it into a chat. > **Your key stays yours** > > The brief tells an assistant to read the key from `ZEROCAPTCHA_KEY` and never to write it into code, > logs or chat. Don't paste a key into an AI chat: anyone who has it can spend your balance. ## A prompt that works ```text Read https://your-docs-site/ai/integration.md and integrate ZeroCaptcha into this project to solve the Turnstile widget on . Read the API key from the ZEROCAPTCHA_KEY environment variable. Work through the brief's checklist before you finish. ``` Replace the address with this site's, as the [integration brief](https://zerocaptcha.io/ai/integration.md) link shows. ## Every page, for assistants - **On every docs page,** the buttons at the top copy the page as Markdown, open its Markdown, or open Claude or ChatGPT with a prompt that carries the page's Markdown address. - **[/llms.txt](https://zerocaptcha.io/llms.txt)** lists every docs page's Markdown and the files above, in the [llms.txt](https://llmstxt.org) format. - **[/llms-full.txt](https://zerocaptcha.io/llms-full.txt)** is every docs page as Markdown in one file, followed by the integration brief. - **[/openapi.json](https://zerocaptcha.io/openapi.json)** is the API's OpenAPI 3.1 contract, for code generators and API tools. ## The MCP server The ZeroCaptcha MCP server lets an assistant that speaks the Model Context Protocol (Claude Code, Claude Desktop, Cursor and others) call ZeroCaptcha itself, over stdio, with your key from its environment: | Tool | What it does | | --- | --- | | `create_task` | Creates a Cloudflare Turnstile task, or a challenge page's task through your proxy, and, unless told not to, waits for its token or clearance. Charged if it succeeds. | | `get_task_result` | Reads a task: its status, cost, and token while it is valid. | | `get_balance` | Reads the available and held balance. | | `search_docs` | Searches these docs, from `/llms-full.txt`, and returns the best sections with their links. | It needs Node.js 20 or later and three settings: `ZEROCAPTCHA_KEY`, your API key; `ZEROCAPTCHA_API`, the API's address, `https://api.zerocaptcha.io` when unset; and `ZEROCAPTCHA_DOCS`, this site's address, `https://zerocaptcha.io` when unset, for `search_docs`. In Claude Code: ```sh claude mcp add zerocaptcha --env ZEROCAPTCHA_KEY=$ZEROCAPTCHA_KEY \ --env ZEROCAPTCHA_API=$ZEROCAPTCHA_API --env ZEROCAPTCHA_DOCS=$ZEROCAPTCHA_DOCS \ -- node /path/to/zerocaptcha-mcp/dist/main.js ``` In Cursor, `.cursor/mcp.json`, and in most other clients the same shape: ```json { "mcpServers": { "zerocaptcha": { "command": "node", "args": ["/path/to/zerocaptcha-mcp/dist/main.js"], "env": { "ZEROCAPTCHA_API": "https://api.zerocaptcha.io", "ZEROCAPTCHA_DOCS": "https://zerocaptcha.io" } } } } ``` The server reads `ZEROCAPTCHA_KEY` from its environment. Start your client with it set; if your client does not pass its environment on, add it to `env` only in a config file you keep out of version control. Every task the server creates is real and charged if it succeeds, so give it a key with a daily [spend cap](https://zerocaptcha.io/docs/keys#cap-a-keys-daily-spend). It is not in a package registry yet (coming). Until it is, give your assistant the [integration brief](https://zerocaptcha.io/ai/integration.md), which covers every call the server makes, or call the API over plain HTTP as the [quickstart](https://zerocaptcha.io/docs/quickstart) does. --- # Authentication > Send your ZeroCaptcha API key in each format, read it from the environment, rotate it without an outage, and keep it out of code, logs and browsers. Source: https://zerocaptcha.io/docs/authentication Every call your code makes carries an API key. The key says which account the call is for and what it may do. You create keys in the dashboard; see [API keys](https://zerocaptcha.io/docs/keys) for scopes, allowed addresses, spend caps and revocation. ## Send the key | Format | Where the key goes | | --- | --- | | REST v1 | `Authorization: Bearer zc_live_…` | | [createTask format](https://zerocaptcha.io/docs/createtask) | `clientKey` in the JSON body | | [2Captcha format](https://zerocaptcha.io/docs/2captcha) | the `key` parameter | The API is at `https://api.zerocaptcha.io`. Read the key and the address from your environment, never from your source code. The samples in these docs use `ZEROCAPTCHA_KEY` and `ZEROCAPTCHA_API`: **curl** ```sh export ZEROCAPTCHA_KEY="$(cat /run/secrets/zerocaptcha_key)" # or from your secret manager curl "$ZEROCAPTCHA_API/v1/balance" -H "Authorization: Bearer $ZEROCAPTCHA_KEY" ``` **Node** ```js const key = process.env.ZEROCAPTCHA_KEY; if (!key) throw new Error("Set ZEROCAPTCHA_KEY"); const response = await fetch(`${process.env.ZEROCAPTCHA_API}/v1/balance`, { headers: { Authorization: `Bearer ${key}` }, }); console.log(response.status, await response.json()); ``` **Python** ```python import os import requests key = os.environ["ZEROCAPTCHA_KEY"] response = requests.get( f"{os.environ['ZEROCAPTCHA_API']}/v1/balance", headers={"Authorization": f"Bearer {key}"}, timeout=15, ) print(response.status_code, response.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest(http.MethodGet, os.Getenv("ZEROCAPTCHA_API")+"/v1/balance", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("ZEROCAPTCHA_KEY")) resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(resp.StatusCode, string(body)) } ``` A key needs no cookie and no CSRF token: those belong to the dashboard's own session. A key can create and read tasks and read the balance; managing keys, billing and the team takes a signed-in dashboard session, so a leaked key can never create another key. ## What a key looks like A key is 41 characters: `zc_live_`, 27 random characters, then a 6-character checksum. The prefix makes a leaked key easy to spot, and the checksum lets a secret scanner confirm a match without calling us. There is one kind of key: every task it creates is real, and charged if it succeeds. A wrong, mistyped or unknown key is refused with [`unauthorized`](https://zerocaptcha.io/docs/reference/errors#unauthorized) (HTTP 401), or [`ERROR_KEY_DOES_NOT_EXIST`](https://zerocaptcha.io/docs/reference/errors#ERROR_KEY_DOES_NOT_EXIST) in the createTask format. A revoked key is refused with [`key_revoked`](https://zerocaptcha.io/docs/reference/errors#key_revoked). ## Rotate without an outage 1. On the dashboard's **API keys** page, choose **Rotate** on the key and an overlap: 1 hour, 24 hours or 7 days. The new key is shown once; copy it into your secret store. 2. Deploy it. During the overlap both keys work. 3. When every service uses the new key, end the overlap, or let it run out. The old key then answers `key_revoked`. Rotate on a schedule, when someone who had the key leaves, and at once if it may have leaked. [API keys](https://zerocaptcha.io/docs/keys#rotate-a-key) has the details. ## Key hygiene - **Keep keys in a secret store** or the environment, never in source code, a repository, a container image, a screenshot or a support message. Support never needs your key. - **Never send a key to a browser.** Call ZeroCaptcha from your server, and hand the browser only the result it needs. A key in front-end code or a mobile app can be read by anyone. - **Never log it.** Leave the `Authorization` header out of request logs and error reports. - **Use one key per service or environment,** named in the dashboard, so you can rotate or revoke one without touching the others, and see which one made a task. - **Give each key only what it needs.** A reporting job needs only the `balance` scope. - **Hold a key to your servers' addresses** with its IP allowlist, so a leaked key is useless elsewhere. - **Cap a key's daily spend,** so a runaway loop or a leak costs at most the cap. - **Watch it.** The keys page shows when each key was last used and from where. If a key leaks, revoke it on the keys page: it stops at once, and tasks it already created run to their end. Then create a new one. --- # Browser automation > Read a page's Cloudflare Turnstile site key in Playwright, Puppeteer, Selenium or chromedp, solve it with ZeroCaptcha, and put the token in the page. Source: https://zerocaptcha.io/docs/browser-automation When your automation drives a real browser, the flow is the same in every tool: 1. **Open the page** and read the widget's site key (and its action and cData, if set) from it. 2. **Solve it** through ZeroCaptcha from your script, as in [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile). 3. **Put the token into the page**: the `cf-turnstile-response` field, and the widget's callback if the page uses one. 4. **Submit** as a person would, within 300 seconds of the token being issued. The key stays in your script's environment. Never put it into the page: the page, and anything running in it, could read it. ## Read the widget and solve it Send the widget's action and cData with its site key whenever it sets them: many sites check both when they verify the token, and refuse one solved without them. See [action and cData](https://zerocaptcha.io/docs/action-and-cdata). A widget written in the HTML carries all three as `data-sitekey`, `data-action` and `data-cdata`. A widget rendered by script gets them as the `sitekey`, `action` and `cData` options of `turnstile.render()`, which leave no attribute behind, so note each call's options before the page's own scripts run (Playwright's `addInitScript`, Puppeteer's `evaluateOnNewDocument`, or Chrome's `Page.addScriptToEvaluateOnNewDocument` from Selenium or chromedp): ```js // Runs before the page's scripts: keeps the options of every turnstile.render() call. () => { let api; Object.defineProperty(window, "turnstile", { configurable: true, get: () => api, set(value) { const render = value.render; value.render = (container, options = {}) => { window.__turnstileRenders = [...(window.__turnstileRenders ?? []), options]; return render.call(value, container, options); }; api = value; }, }); }; ``` This runs in the page once it has loaded, and returns the widget's values from either place: ```js () => { const widget = document.querySelector("[data-sitekey]"); const rendered = window.__turnstileRenders?.[0] ?? {}; return { websiteKey: widget?.dataset.sitekey ?? rendered.sitekey, action: widget?.dataset.action ?? rendered.action ?? null, cdata: widget?.dataset.cdata ?? rendered.cData ?? null, callback: widget?.dataset.callback ?? null, }; }; ``` Then solve with the values it found and the page's URL. With the [SDKs](https://zerocaptcha.io/docs/sdks), that is one call; with plain HTTP, create the task and poll it as in [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile#solve-it): **curl** ```sh # websiteKey, action and cdata as the page function found them; leave out action or cdata when it # found none. For your own proxy, make the type TurnstileTask and add "proxy"; to be called when # the task ends, add "callbackUrl". The reply is the task, or a problem document saying why not. curl -sS --fail-with-body "$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"}' ``` **Node** ```js import { ZeroCaptcha } from "@zerocaptcha/sdk"; const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API, }); // widget: what the page function returned. The client sends an Idempotency-Key, and throws a // TaskFailedError, with its code, when the task fails or expires (which costs nothing). const token = await zerocaptcha.solve({ websiteURL: "https://example.com/login", websiteKey: widget.websiteKey, action: widget.action ?? undefined, // left out when the widget sets none cdata: widget.cdata ?? undefined, // proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy }); ``` **Python** ```python import os from zerocaptcha import ZeroCaptcha zerocaptcha = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"]) # widget: what the page function returned. The client sends an Idempotency-Key, and raises # TaskFailedError, with its code, when the task fails or expires (which costs nothing). token = zerocaptcha.solve( website_url="https://example.com/login", website_key=widget["websiteKey"], action=widget["action"], # None, and left out, when the widget sets none cdata=widget["cdata"], # proxy="http://user:pass@proxy.example.net:8080", # to solve through your own proxy ) ``` **Go** ```go client, err := zerocaptcha.NewClient(os.Getenv("ZEROCAPTCHA_KEY"), os.Getenv("ZEROCAPTCHA_API")) if err != nil { log.Fatal(err) } // widget: what the page function returned; an empty Action or CData is left out. The client // sends an Idempotency-Key, and returns a *zerocaptcha.TaskFailedError when the task fails. token, err := client.Solve(ctx, zerocaptcha.NewTask{ WebsiteURL: "https://example.com/login", WebsiteKey: widget.WebsiteKey, Action: widget.Action, CData: widget.CData, // Proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy }) ``` ## Put the token into the page This also runs in the page, with the token as its argument. It fills every `cf-turnstile-response` field and calls the widget's callback, if the page named one: ```js (token) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](token); }; ``` ## Playwright **Node** ```js import { chromium } from "playwright"; import { ZeroCaptcha } from "@zerocaptcha/sdk"; const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API }); const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto("https://example.com/login"); const widget = page.locator("[data-sitekey]").first(); await widget.waitFor({ state: "attached" }); const token = await zerocaptcha.solve({ websiteURL: page.url(), websiteKey: await widget.getAttribute("data-sitekey"), action: (await widget.getAttribute("data-action")) ?? undefined, cdata: (await widget.getAttribute("data-cdata")) ?? undefined, }); await page.evaluate((value) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](value); }, token); await page.fill("#email", "you@example.com"); await page.click("button[type=submit]"); await browser.close(); ``` **Python** ```python import os from playwright.sync_api import sync_playwright from zerocaptcha import ZeroCaptcha INJECT = """(value) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](value); }""" zerocaptcha = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"]) with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("https://example.com/login") widget = page.locator("[data-sitekey]").first widget.wait_for(state="attached") token = zerocaptcha.solve( website_url=page.url, website_key=widget.get_attribute("data-sitekey"), action=widget.get_attribute("data-action"), cdata=widget.get_attribute("data-cdata"), ) page.evaluate(INJECT, token) page.fill("#email", "you@example.com") page.click("button[type=submit]") browser.close() ``` ## Puppeteer **Node** ```js import puppeteer from "puppeteer"; import { ZeroCaptcha } from "@zerocaptcha/sdk"; const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API }); const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto("https://example.com/login", { waitUntil: "domcontentloaded" }); await page.waitForSelector("[data-sitekey]"); const widget = await page.$eval("[data-sitekey]", (element) => ({ ...element.dataset })); const token = await zerocaptcha.solve({ websiteURL: page.url(), websiteKey: widget.sitekey, action: widget.action, cdata: widget.cdata, }); await page.evaluate((value) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](value); }, token); await page.type("#email", "you@example.com"); await page.click("button[type=submit]"); await browser.close(); ``` ## Selenium **Python** ```python import os from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.ui import WebDriverWait from zerocaptcha import ZeroCaptcha INJECT = """ const token = arguments[0]; for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](token); """ zerocaptcha = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"]) driver = webdriver.Chrome() try: driver.get("https://example.com/login") widget = WebDriverWait(driver, 15).until( ec.presence_of_element_located((By.CSS_SELECTOR, "[data-sitekey]")) ) token = zerocaptcha.solve( website_url=driver.current_url, website_key=widget.get_attribute("data-sitekey"), action=widget.get_attribute("data-action"), cdata=widget.get_attribute("data-cdata"), ) driver.execute_script(INJECT, token) driver.find_element(By.ID, "email").send_keys("you@example.com") driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() finally: driver.quit() ``` **Node** ```js import { Builder, By, until } from "selenium-webdriver"; import { ZeroCaptcha } from "@zerocaptcha/sdk"; const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API }); const driver = await new Builder().forBrowser("chrome").build(); try { await driver.get("https://example.com/login"); const widget = await driver.wait(until.elementLocated(By.css("[data-sitekey]")), 15_000); const token = await zerocaptcha.solve({ websiteURL: await driver.getCurrentUrl(), websiteKey: await widget.getAttribute("data-sitekey"), action: (await widget.getAttribute("data-action")) || undefined, cdata: (await widget.getAttribute("data-cdata")) || undefined, }); await driver.executeScript( `const token = arguments[0]; for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](token);`, token, ); await driver.findElement(By.id("email")).sendKeys("you@example.com"); await driver.findElement(By.css("button[type=submit]")).click(); } finally { await driver.quit(); } ``` ## chromedp (Go) **Go** ```go package main import ( "context" "encoding/json" "log" "os" "github.com/chromedp/chromedp" zerocaptcha "github.com/zerocaptcha/zerocaptcha-go" ) const inject = `(token) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const name = document.querySelector("[data-callback]")?.dataset.callback; if (name && typeof window[name] === "function") window[name](token); }` func main() { client, err := zerocaptcha.NewClient(os.Getenv("ZEROCAPTCHA_KEY"), os.Getenv("ZEROCAPTCHA_API")) if err != nil { log.Fatal(err) } ctx, cancel := chromedp.NewContext(context.Background()) defer cancel() // The widget's site key, action and cData; an attribute the widget does not set stays empty, // and an empty Action or CData is left out of the task. var siteKey, action, cdata, pageURL string var found bool err = chromedp.Run(ctx, chromedp.Navigate("https://example.com/login"), chromedp.WaitReady("[data-sitekey]"), chromedp.AttributeValue("[data-sitekey]", "data-sitekey", &siteKey, &found), chromedp.AttributeValue("[data-sitekey]", "data-action", &action, &found), chromedp.AttributeValue("[data-sitekey]", "data-cdata", &cdata, &found), chromedp.Location(&pageURL), ) if err != nil { log.Fatal(err) } token, err := client.Solve(ctx, zerocaptcha.NewTask{ WebsiteURL: pageURL, WebsiteKey: siteKey, Action: action, // many sites check both when they verify the token CData: cdata, }) if err != nil { log.Fatal(err) } err = chromedp.Run(ctx, chromedp.Evaluate("("+inject+")("+quote(token)+")", nil), chromedp.SendKeys("#email", "you@example.com"), chromedp.Click("button[type=submit]"), ) if err != nil { log.Fatal(err) } } // quote makes a JavaScript string literal of s. func quote(s string) string { b, _ := json.Marshal(s) return string(b) } ``` ## Things that trip automation up - **Solve late, submit at once.** A token lasts 300 seconds and works once. Fill the rest of the form first, then solve, inject and submit. - **The widget may overwrite the field.** If the page's own widget finishes after you inject, it replaces your token with its own. Inject right before submitting, and submit in the same step. - **Use the page's real URL** as `websiteURL`, after any redirect, and the same `action` and `cdata` the widget has. - **Challenge pages are different.** A "Just a moment…" page before the site loads is a [challenge page](https://zerocaptcha.io/docs/challenges): solve it with a challenge task through the proxy your browser uses, and set its `cf_clearance` cookie and user agent in the browser before you load the page. --- # Polling and callbacks > Wait for a task by polling every 2 seconds, following live updates, or having ZeroCaptcha call your endpoint, and check each call's HMAC-SHA256 signature. Source: https://zerocaptcha.io/docs/callbacks A task takes a few seconds or more to solve, so creating it and getting its result are two steps. There are three ways to learn that a task has ended: | Way | How | Use it when | | --- | --- | --- | | **Poll** | Read the task every 2 seconds until its status is final | You want the simplest code, or cannot receive calls | | **Callback** | Name a URL when you create the task; we POST the result there | You run a server and create many tasks | | **Live updates** | Keep one server-sent events stream open for all your tasks | You show tasks as they change, like a dashboard | Polling always works, so a callback that never arrives never loses a result. ## Poll Read the task every **2 seconds** until its `status` is `succeeded`, `failed` or `expired`: | Format | Read the task with | | --- | --- | | REST | `GET /v1/tasks/{id}`: `status`, and `solution` once it succeeded | | createTask | `POST /getTaskResult` with `taskId`: `status` `processing`, then `ready` | | 2Captcha | `GET /res.php?action=get&id=…`: `CAPCHA_NOT_READY`, then `OK\|` | - **Stop at a deadline.** A task is final within its `deadline` (150 seconds after creation by default); give your wait that long plus a margin, and never loop forever. - **Mind the budget.** Reads have budgets per key and per account (by default 200 every 2 seconds each), shared by every reader. Polling one task every 2 seconds uses a tiny share; polling many tasks in a tight loop does not. Over a budget, reads answer [`rate_limited`](https://zerocaptcha.io/docs/reference/errors#rate_limited) with `Retry-After`; see [Rate limits](https://zerocaptcha.io/docs/rate-limits). - **Retry a failed read.** A 429 or 5xx on a read changes nothing about the task: wait as `Retry-After` says and read it again. The [quickstart](https://zerocaptcha.io/docs/quickstart) and [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile#solve-it) have complete polling loops. ## Live updates `GET /v1/tasks/events` is a server-sent events stream of your account's tasks. Each `task` event carries a task, without its token, each time it changes; a `reset` event says updates were missed, so list your tasks again. Start it from a task list's `liveCursor`, as `since`, so nothing between the list and the stream is lost. Read the token with `GET /v1/tasks/{id}` when a task succeeds. ```sh curl -N "$ZEROCAPTCHA_API/v1/tasks/events?since=$LIVE_CURSOR" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" ``` Opening a stream draws on your read budget once; keeping it open costs nothing more. An account may hold 10 streams open at once by default. ## Callbacks ### Name a callback | Format | Field | | --- | --- | | REST, `POST /v1/tasks` | `callbackUrl` in the body | | createTask, `POST /createTask` | `callbackUrl` beside `task` | | 2Captcha, `in.php` | `pingback` | ```sh # The task as always, with the widget's data-action and data-cdata (or turnstile.render()'s # action and cData options; leave out any it does not set), plus where to call when it ends. # The reply is the task, or a problem document whose code says why it was refused. curl -sS --fail-with-body "$ZEROCAPTCHA_API/v1/tasks" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "action": "login", "cdata": "session-7f3a9c2e", "callbackUrl": "https://example.com/zerocaptcha/callback"}' ``` The URL must be `http` or `https`, at most 2,048 characters, without a username or password, and name a public domain or a public IP address, on a port no other protocol reserves (such as 25). A task with any other URL is refused when you create it, and nothing is held. Nothing needs to be registered first. ### What we send Once the task ends (solved, failed or expired), a `POST` to your URL, in the format the task was created in, from the user agent `ZeroCaptcha-Callbacks/1.0`: - **REST:** the task as `GET /v1/tasks/{id}` shows it, token included while it is valid, as `application/json`. - **createTask:** the reply `getTaskResult` would give, as `application/json`. - **2Captcha:** a form, `id=&code=`, or the error code, such as `code=ERROR_CAPTCHA_UNSOLVABLE`, when the task was not solved. Each call carries two headers: - `ZeroCaptcha-Signature: t=,v1=`: the HMAC-SHA256 of `.`, keyed with your callback secret. - `ZeroCaptcha-Delivery`: the call's ID, the same on every attempt, so you can tell a repeat. The [callback payload reference](https://zerocaptcha.io/docs/reference/callbacks) shows each body in full. ### Check the signature Your callback secret starts with `zcsig_`. Owners of the account see it on the dashboard's API keys page, under **Callback signing secret**. Check every call before you trust it: compute the HMAC-SHA256 of the timestamp, a dot and the raw body, compare it with `v1` in constant time, and refuse a call whose timestamp is more than five minutes from now, so a recorded call cannot be replayed. Use the body exactly as it arrived, before any parsing. **Node** ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function isGenuine(secret, header, rawBody) { const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ""); if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false; const expected = createHmac("sha256", secret).update(`${match[1]}.`).update(rawBody).digest(); return timingSafeEqual(expected, Buffer.from(match[2], "hex")); } ``` **Python** ```python import hashlib, hmac, re, time def is_genuine(secret: str, header: str, raw_body: bytes) -> bool: match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "") if not match or abs(time.time() - int(match[1])) > 300: return False expected = hmac.new(secret.encode(), match[1].encode() + b"." + raw_body, hashlib.sha256) return hmac.compare_digest(expected.hexdigest(), match[2]) ``` **Go** ```go func isGenuine(secret, header string, rawBody []byte) bool { match := regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`).FindStringSubmatch(header) if match == nil { return false } sent, _ := strconv.ParseInt(match[1], 10, 64) if age := time.Since(time.Unix(sent, 0)); age > 5*time.Minute || age < -5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(match[1] + ".")) mac.Write(rawBody) given, _ := hex.DecodeString(match[2]) return hmac.Equal(mac.Sum(nil), given) } ``` **PHP** ```php 300) { return false; } $expected = hash_hmac('sha256', $match[1] . '.' . $rawBody, $secret); return hash_equals($expected, $match[2]); } $raw = file_get_contents('php://input'); if (!is_genuine(getenv('ZEROCAPTCHA_CALLBACK_SECRET'), $_SERVER['HTTP_ZEROCAPTCHA_SIGNATURE'] ?? null, $raw)) { http_response_code(401); exit; } http_response_code(204); ``` The [SDKs](https://zerocaptcha.io/docs/sdks) do this for you. ### Answer, and retries Answer with any 2xx status once you have the call; the body of your answer is ignored. A call that gets any other status, or none within 10 seconds, is retried after 30 seconds, then after waits that double each time, up to 32 minutes before the last, each lengthened by up to half at random: eight attempts in all, over roughly 65 to 95 minutes. Redirects are not followed. After the last attempt we stop; the task and its result stay in your task log and in the API. Each attempt reads the task afresh, so a call retried after a Turnstile token expired carries the task without its token. A call can arrive more than once, such as when your answer was lost: use `ZeroCaptcha-Delivery` or the task ID to handle each task once, and answer quickly, doing slow work after you answer. ### See each attempt, and send it again `GET /v1/tasks/{id}` shows a task's callback under `callback`: the URL, where it stands (`waiting` until the task ends, `pending`, `delivered`, `failed`, or `expired` once its record is deleted, a week after delivery or 30 days after failing), when the next attempt is due, and each attempt with its time, the HTTP status your endpoint answered (`null` for no answer), its outcome and when the next retry was set for. The task's page in the dashboard shows the same. `POST /v1/tasks/{id}/callback/resend` calls it again once it was delivered or given up, with eight more attempts, signed and shaped as the first call was. It answers `202` with the callback as it now stands. It needs a key with `tasks:write`, or an owner's session; a suspended account sends none (`account_suspended`), and a callback still being delivered is `state_conflict`. ```sh curl -X POST "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID/callback/resend" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" ``` > **Private addresses are never called** > > We resolve your URL's name each time we call it, and call only if every address it resolves to is > public. A name that resolves to a private, loopback or link-local address, or such an address > written in the URL, is never called, and the callback stops at once. ### Rotate the secret An owner can rotate the secret on the API keys page. The new secret signs every call from then on, retries of earlier tasks included, and the old one stops matching at once, so have your endpoint accept the new secret as soon as you copy it. Each rotation is listed in the team's activity. --- # Cloudflare WAF and 5-second challenges > Pass a Cloudflare WAF or 5-second challenge page ("Just a moment...") through your proxy, then use its cf_clearance cookie with its user agent. Source: https://zerocaptcha.io/docs/challenges Some sites answer a first visit with a Cloudflare challenge page instead of the page itself. It goes by several names, all one thing to solve: - **A Cloudflare WAF challenge:** a WAF rule (a custom, rate limiting or IP Access rule) whose action is Managed Challenge, Non-Interactive Challenge or Interactive Challenge. Bot Fight Mode and Under Attack mode show the same page. - **"Just a moment..." or "Checking your browser":** the interstitial page itself, answered with HTTP 403 and the header `cf-mitigated: challenge`. - **The 5-second challenge:** the old name for Cloudflare's JavaScript challenge, after the few seconds its page took. Today it is a Managed or Non-Interactive Challenge (`js_challenge`), which Cloudflare says typically takes less than five seconds. A `CloudflareChallengeTask` passes that page for you and returns what a browser gets for passing it: the `cf_clearance` cookie, and the user agent the cookie is bound to. Send both with your requests to the site, through the same proxy, and the site serves its pages. A Cloudflare block, such as error 1020, is a refusal rather than a challenge: no task passes it. A challenge task is priced, held and charged like any other task: the price shows in the [price list](https://zerocaptcha.io/pricing), and nothing is charged unless the task is solved. ## What the clearance is `cf_clearance` is the cookie Cloudflare sets when a visitor passes a challenge. While it is valid, the site lets that visitor through without challenging it again. Cloudflare ties it to "the specific visitor and device it was issued to", so in practice it is accepted only: - **from the same IP address** that earned it: so a challenge task always runs through your proxy, and you use the clearance through that proxy; - **with the same user agent** that earned it: the `User-Agent` header in `solution.userAgent`, exactly; - **for as long as the site allows:** its Challenge Passage setting, 30 minutes by default. We serve the clearance for 30 minutes after it is issued (`tokenExpiresAt`), then delete it 10 minutes later. A client whose TLS handshake does not look like the browser the user agent names may be challenged again, whatever its cookie: Cloudflare's bot detection looks at TLS fingerprints as well as headers. A plain HTTP library's handshake does not look like Chrome's. For sites that check, use a client that impersonates the browser, such as curl-impersonate, or a real browser. ## Why it needs your proxy A cookie earned from our solver's address would be refused from yours, so there is no proxyless challenge task: a `CloudflareChallengeTaskProxyless` is refused, with [`validation_failed`](https://zerocaptcha.io/docs/reference/errors#validation_failed) on REST and `ERROR_TASK_NOT_SUPPORTED` in the createTask format, and nothing is held. Your proxy also looks the page up and connects to it, so the solve comes from the address you will use. ## Create a task `POST /v1/tasks` with the page and your proxy. A challenge page has no widget, so the task takes no `websiteKey`, `action` or `cdata`; sending one is refused. | Field | Required | What it is | | --- | --- | --- | | `type` | Yes | `CloudflareChallengeTask`. CapSolver's name, `AntiCloudflareTask`, works too. | | `websiteURL` | Yes | The page behind the challenge: a public `http` or `https` page. | | `proxy` | Yes | Your proxy, `http` or `https`, with its port, such as `http://user:pass@proxy.example.net:8080`. The same rules apply as for `TurnstileTask`: a public host, and a port no other protocol reserves. | | `callbackUrl` | No | Where to POST the result when the task ends. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). | The same checks as a Turnstile task apply before the task is held and again before each attempt: the page must be on a public domain and not on the blocklist, and your proxy must resolve to public addresses only. Your proxy's password is never logged, and it is deleted when the task ends. **curl** ```sh curl "$ZEROCAPTCHA_API/v1/tasks" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"type": "CloudflareChallengeTask", "websiteURL": "https://shop.example.com/", "proxy": "http://user:pass@proxy.example.net:8080", "callbackUrl": "https://example.com/zerocaptcha/callback"}' ``` **Node** ```js const api = process.env.ZEROCAPTCHA_API; const headers = { Authorization: `Bearer ${process.env.ZEROCAPTCHA_KEY}` }; let task = await fetch(`${api}/v1/tasks`, { method: "POST", headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ type: "CloudflareChallengeTask", websiteURL: "https://shop.example.com/", proxy: process.env.PROXY_URL, // such as http://user:pass@proxy.example.net:8080 callbackUrl: "https://example.com/zerocaptcha/callback", // optional: POSTed when the task ends }), }).then((response) => response.json()); while (task.status === "queued" || task.status === "running") { await new Promise((resolve) => setTimeout(resolve, 2000)); task = await fetch(`${api}/v1/tasks/${task.id}`, { headers }).then((response) => response.json()); } if (!task.solution) throw new Error(`${task.errorCode}: ${task.errorDescription}`); const { userAgent, cookie } = task.solution; // cookie.name is "cf_clearance" ``` **Python** ```python import os import time import uuid import requests api = os.environ["ZEROCAPTCHA_API"] headers = {"Authorization": f"Bearer {os.environ['ZEROCAPTCHA_KEY']}"} task = requests.post( f"{api}/v1/tasks", headers={**headers, "Idempotency-Key": str(uuid.uuid4())}, json={ "type": "CloudflareChallengeTask", "websiteURL": "https://shop.example.com/", "proxy": os.environ["PROXY_URL"], # such as http://user:pass@proxy.example.net:8080 "callbackUrl": "https://example.com/zerocaptcha/callback", # optional: POSTed when the task ends }, timeout=15, ).json() while task["status"] in ("queued", "running"): time.sleep(2) task = requests.get(f"{api}/v1/tasks/{task['id']}", headers=headers, timeout=15).json() if not task.get("solution"): raise SystemExit(f"{task['errorCode']}: {task['errorDescription']}") user_agent = task["solution"]["userAgent"] clearance = task["solution"]["cookie"]["value"] ``` **Go** ```go type challengeTask struct { ID string `json:"id"` Status string `json:"status"` ErrorCode string `json:"errorCode"` ErrorDescription string `json:"errorDescription"` Solution *struct { UserAgent string `json:"userAgent"` Cookie struct { Name string `json:"name"` Value string `json:"value"` } `json:"cookie"` } `json:"solution"` } func solveChallenge(pageURL, proxy string) (*challengeTask, error) { body, _ := json.Marshal(map[string]string{ "type": "CloudflareChallengeTask", "websiteURL": pageURL, "proxy": proxy, // Optional: POSTed when the task ends. "callbackUrl": "https://example.com/zerocaptcha/callback", }) req, _ := http.NewRequest(http.MethodPost, os.Getenv("ZEROCAPTCHA_API")+"/v1/tasks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("ZEROCAPTCHA_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", fmt.Sprint(time.Now().UnixNano())) var task challengeTask for { resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } err = json.NewDecoder(resp.Body).Decode(&task) resp.Body.Close() if err != nil { return nil, err } if task.Status != "queued" && task.Status != "running" { break } time.Sleep(2 * time.Second) req, _ = http.NewRequest(http.MethodGet, os.Getenv("ZEROCAPTCHA_API")+"/v1/tasks/"+task.ID, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("ZEROCAPTCHA_KEY")) } if task.Solution == nil { return nil, fmt.Errorf("%s: %s", task.ErrorCode, task.ErrorDescription) } return &task, nil } ``` **PHP** ```php getenv('ZEROCAPTCHA_KEY'), 'task' => [ 'type' => 'AntiCloudflareTask', 'websiteURL' => 'https://shop.example.com/', 'proxy' => getenv('PROXY_URL'), ], // Optional: POSTed when the task ends. 'callbackUrl' => 'https://example.com/zerocaptcha/callback', ]); // The same Idempotency-Key on a retry returns the same task instead of a second, paid one. $headers = "Content-Type: application/json\r\nIdempotency-Key: " . bin2hex(random_bytes(16)); $context = stream_context_create(['http' => ['method' => 'POST', 'header' => $headers, 'content' => $body]]); $created = json_decode(file_get_contents(getenv('ZEROCAPTCHA_API') . '/createTask', false, $context), true); do { sleep(2); $poll = json_encode(['clientKey' => getenv('ZEROCAPTCHA_KEY'), 'taskId' => $created['taskId']]); $context = stream_context_create(['http' => ['method' => 'POST', 'header' => 'Content-Type: application/json', 'content' => $poll]]); $result = json_decode(file_get_contents(getenv('ZEROCAPTCHA_API') . '/getTaskResult', false, $context), true); } while ($result['errorId'] === 0 && $result['status'] === 'processing'); $userAgent = $result['solution']['userAgent']; $clearance = $result['solution']['cookies']['cf_clearance']; ``` The [SDKs](https://zerocaptcha.io/docs/sdks) do all of this in one call: `solveChallenge` in Node, `solve_challenge` in Python and `SolveChallenge` in Go return the clearance and its user agent. ## Read the result Read the task with `GET /v1/tasks/{id}` until it ends. A solved one carries the clearance: ```json { "id": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "type": "CloudflareChallengeTask", "kind": "cloudflare", "status": "succeeded", "websiteURL": "https://shop.example.com/", "websiteKey": null, "usesProxy": true, "solution": { "token": "Dyw1BhDnEAGRy5fh…", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", "cookie": { "name": "cf_clearance", "value": "Dyw1BhDnEAGRy5fh…", "expiresAt": null } }, "tokenState": "available", "tokenIssuedAt": "2026-09-30T14:02:14Z", "tokenExpiresAt": "2026-09-30T14:32:14Z" } ``` | Field | What it is | | --- | --- | | `solution.cookie.value` | The `cf_clearance` cookie's value. `solution.token` holds the same value. | | `solution.userAgent` | The `User-Agent` to send with the cookie. | | `solution.cookie.expiresAt` | When the site stops accepting the cookie, if it is known; `null` when it is not, as today. The site's own setting decides (its Challenge Passage, 30 minutes by default), and the solver does not learn it. | | `tokenExpiresAt` | Until when the API serves the clearance: 30 minutes after it was issued. It is deleted 10 minutes after that. | In the createTask format, `getTaskResult` answers as CapSolver's `AntiCloudflareTask` does: ```json { "errorId": 0, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "status": "ready", "solution": { "token": "Dyw1BhDnEAGRy5fh…", "type": "cloudflare", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", "cookies": { "cf_clearance": "Dyw1BhDnEAGRy5fh…" } }, "cost": "0.001200", "createTime": 1790776925, "endTime": 1790776934, "solveCount": 1, "expiresAt": "2026-09-30T14:32:14Z" } ``` A `createTask` for a challenge page ignores the fields other providers take for one, such as `userAgent` and `html`, and any `websiteKey`, `action` or `cdata`. > **Caution** > > The [2Captcha format](https://zerocaptcha.io/docs/2captcha) does not serve challenge pages: `in.php` takes > `method=turnstile` only, and `res.php` refuses a challenge task made in another format with > `ERROR_BAD_PARAMETERS`, as its replies have no place for the user agent the cookie needs. ## Use the clearance Send the cookie and the user agent with each request to the site, through the proxy that earned them. Reuse them for every request until the site challenges you again, then create a new task. **curl** ```sh curl "https://shop.example.com/" \ --proxy "$PROXY_URL" \ -H "User-Agent: $USER_AGENT" \ -H "Cookie: cf_clearance=$CF_CLEARANCE" ``` **Node** ```js // got 14, through the same proxy with hpagent. import got from "got"; import { HttpsProxyAgent } from "hpagent"; const site = got.extend({ agent: { https: new HttpsProxyAgent({ proxy: process.env.PROXY_URL }) }, headers: { "user-agent": userAgent, cookie: `cf_clearance=${cookie.value}` }, }); const page = await site("https://shop.example.com/"); console.log(page.statusCode); ``` **Python** ```python import os import httpx import requests proxy = os.environ["PROXY_URL"] # requests with requests.Session() as session: session.proxies = {"http": proxy, "https": proxy} session.headers["User-Agent"] = user_agent session.cookies.set("cf_clearance", clearance, domain="shop.example.com") print(session.get("https://shop.example.com/", timeout=30).status_code) # httpx with httpx.Client(proxy=proxy, headers={"User-Agent": user_agent}, cookies={"cf_clearance": clearance}) as client: print(client.get("https://shop.example.com/", timeout=30).status_code) ``` **Go** ```go // Through the same proxy, with the cookie in a jar and the user agent on every request. proxy, _ := url.Parse(os.Getenv("PROXY_URL")) jar, _ := cookiejar.New(nil) site, _ := url.Parse("https://shop.example.com/") jar.SetCookies(site, []*http.Cookie{{Name: "cf_clearance", Value: task.Solution.Cookie.Value}}) client := &http.Client{ Jar: jar, Transport: &http.Transport{Proxy: http.ProxyURL(proxy)}, Timeout: 30 * time.Second, } req, _ := http.NewRequest(http.MethodGet, site.String(), nil) req.Header.Set("User-Agent", task.Solution.UserAgent) resp, err := client.Do(req) ``` **PHP** ```php getenv('PROXY_URL'), CURLOPT_USERAGENT => $userAgent, CURLOPT_COOKIE => 'cf_clearance=' . $clearance, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $page = curl_exec($curl); echo curl_getinfo($curl, CURLINFO_RESPONSE_CODE), PHP_EOL; ``` A client that impersonates the browser's TLS handshake, such as curl-impersonate, keeps the clearance accepted where plain HTTP libraries may be challenged again. When the site challenges you again, its clearance has ended: create a new task. In a browser you drive, set the cookie for the site's domain and the browser's user agent to `solution.userAgent` before loading the page, and route the browser through the same proxy. See [Browser automation](https://zerocaptcha.io/docs/browser-automation). ## Test against a live challenge page The [Cloudflare WAF managed challenge test page](https://zerocaptcha.io/captcha-test/cloudflare-managed-challenge), the [Cloudflare 5-second JS challenge test page](https://zerocaptcha.io/captcha-test/cloudflare-js-challenge) and the [Cloudflare WAF interactive challenge test page](https://zerocaptcha.io/captcha-test/cloudflare-interactive-challenge) each sit behind a Cloudflare WAF rule of that kind, and show whether a request carried a clearance. Point a challenge task at one to see the whole flow, then load the page with its clearance. Every test page is on the [Cloudflare Turnstile demo and CAPTCHA test pages](https://zerocaptcha.io/captcha-test). ## When it fails A challenge that is not passed after every attempt fails with `ERROR_CAPTCHA_UNSOLVABLE`, and one still unsolved at its deadline expires with `ERROR_TASK_TIMEOUT`; neither is charged. A proxy that resolves to a private address by the time the task runs fails with `ERROR_PROXY_NOT_ALLOWED`. See [error codes](https://zerocaptcha.io/docs/reference/errors) for every code. --- # Solving Cloudflare Turnstile > Every field of a Cloudflare Turnstile task, how to find the site key, action and cData on a page, and how to use the token in a form or a callback. Source: https://zerocaptcha.io/docs/cloudflare-turnstile Cloudflare Turnstile is the widget that shows "Verify you are human", or runs unseen, and puts a token in the page's form. A Turnstile task gets you that token for a page you name, so your code can submit the form as a browser would. Send tasks only for sites you are allowed to automate. ## The task's fields | Field | Required | What it is | | --- | --- | --- | | `type` | Yes | `TurnstileTaskProxyless`, or `TurnstileTask` to solve through your proxy. CapSolver's `AntiTurnstileTaskProxyLess` works too, and `AntiTurnstileTask` for the proxy variant, and case does not matter. | | `websiteURL` | Yes | The full address of the page with the widget, such as `https://example.com/login`: `http` or `https`, at most 2,048 characters, without a username or password, on the scheme's default port, on a public domain name (not an IP address, `localhost` or a `.local` or `.internal` name). | | `websiteKey` | Yes | The widget's site key: 1 to 100 letters, digits, `_` and `-`, such as `0x4AAAAAAAB1cD2eF3gH4iJ5`. | | `action` | When the widget sets one | The widget's action: up to 32 letters, digits, `_` and `-`. See [action and cData](https://zerocaptcha.io/docs/action-and-cdata). | | `cdata` | When the widget sets one | The widget's cData: up to 255 letters, digits, `_` and `-`. | | `proxy` | With `TurnstileTask` | Your proxy as a URL with its port: `http://user:pass@proxy.example.net:8080`. `http` or `https`; SOCKS is not supported yet. Its host must be public, its port not one another protocol reserves (such as 25), and its login and password at most 255 bytes each. `TurnstileTaskProxyless` takes none. | | `callbackUrl` | No | Where to POST the result when the task ends. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). | Spaces around a value are trimmed, and an empty optional field counts as absent. A field outside these, or a value out of bounds, is refused with [`validation_failed`](https://zerocaptcha.io/docs/reference/errors#validation_failed) (HTTP 422), whose `detail` names the field; nothing is held. The same fields have other spellings in the [createTask format](https://zerocaptcha.io/docs/createtask#cloudflare-turnstile-task) and the [2Captcha format](https://zerocaptcha.io/docs/2captcha). ### Proxyless or your proxy A proxyless task is solved from our network. Choose `TurnstileTask` when the site should see the solve come from your own address, such as when it checks that the token's solver and the form's sender match. Your proxy's password is never logged, and is deleted when the task finishes. Prices differ by type: see [pricing](https://zerocaptcha.io/pricing). ## Find the site key, action and cData Open the page in a browser, then its source or the developer tools' **Elements** panel, and look for the widget. It is written one of two ways: - **In the HTML,** as an element with the class `cf-turnstile`: ```html
``` `data-sitekey` is the `websiteKey`; `data-action` and `data-cdata`, when present, are `action` and `cdata`. - **In a script,** as a call to `turnstile.render`: ```js turnstile.render("#captcha", { sitekey: "0x4AAAAAAAB1cD2eF3gH4iJ5", action: "login", cData: "sess_91f2c0", callback: (token) => submitLogin(token), }); ``` Search the page's scripts for `turnstile.render` or `sitekey`. `sitekey`, `action` and `cData` are the three values. A live site key usually starts with `0x4` (Cloudflare's testing site keys start with `1x`, `2x` or `3x`). Send `action` and `cdata` exactly as the page sets them, or leave them out when it sets none: the site sees them in its verification, and may refuse a token whose action or cData does not match. If the page makes the cData new on each visit, read it from the page you will submit, just before creating the task. [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata) says when they are required, where to find them and what happens without them. > **Note** > > A page that shows "Just a moment…" before any content is not a Turnstile widget but a Cloudflare > challenge page. Solve it with a [challenge task](https://zerocaptcha.io/docs/challenges) instead. ## Solve it Create the task with the widget's site key, action and cData, then read it every 2 seconds until it ends: **curl** ```sh # Create the task; the reply is the task, with its id, or a problem document whose code says why # not. action and cdata are the widget's data-action and data-cdata, or the action and cData # options of turnstile.render(): leave out any the widget does not set. For your own proxy, make # the type TurnstileTask and add "proxy": "http://user:pass@proxy.example.net:8080"; to be called # when the task ends, add "callbackUrl": "https://hooks.example.com/zerocaptcha". reply=$(curl -sS --fail-with-body "$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"}') || { echo "refused: $reply" >&2; exit 1; } TASK_ID=$(jq -r .id <<<"$reply") # Then, every 2 seconds, until "status" is succeeded (with solution.token), failed or expired: curl "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ZEROCAPTCHA_KEY" ``` **Node** ```js const api = process.env.ZEROCAPTCHA_API; const headers = { Authorization: `Bearer ${process.env.ZEROCAPTCHA_KEY}` }; const created = await fetch(`${api}/v1/tasks`, { method: "POST", headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ type: "TurnstileTaskProxyless", // or "TurnstileTask", with proxy below websiteURL: "https://example.com/login", // the page with the widget websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. action: "login", cdata: "session-7f3a9c2e", // proxy: "http://user:pass@proxy.example.net:8080", // TurnstileTask only // callbackUrl: "https://hooks.example.com/zerocaptcha", // to be called when it ends }), }); if (!created.ok) throw new Error(`createTask: HTTP ${created.status}: ${await created.text()}`); let task = await created.json(); while (task.status === "queued" || task.status === "running") { await new Promise((resolve) => setTimeout(resolve, 2000)); const read = await fetch(`${api}/v1/tasks/${task.id}`, { headers }); if (!read.ok) throw new Error(`getTask: HTTP ${read.status}: ${await read.text()}`); task = await read.json(); } if (task.status !== "succeeded" || !task.solution) throw new Error(`${task.errorCode}: ${task.errorDescription}`); console.log(task.solution.token); ``` **Python** ```python import os import time import uuid import requests api = os.environ["ZEROCAPTCHA_API"] headers = {"Authorization": f"Bearer {os.environ['ZEROCAPTCHA_KEY']}"} created = requests.post( f"{api}/v1/tasks", headers={**headers, "Idempotency-Key": str(uuid.uuid4())}, json={ "type": "TurnstileTaskProxyless", # or "TurnstileTask", with proxy below "websiteURL": "https://example.com/login", # the page with the widget "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", # its data-sitekey # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "action": "login", "cdata": "session-7f3a9c2e", # "proxy": "http://user:pass@proxy.example.net:8080", # TurnstileTask only # "callbackUrl": "https://hooks.example.com/zerocaptcha", # to be called when it ends }, timeout=15, ) created.raise_for_status() task = created.json() while task["status"] in ("queued", "running"): time.sleep(2) read = requests.get(f"{api}/v1/tasks/{task['id']}", headers=headers, timeout=15) read.raise_for_status() task = read.json() if task["status"] != "succeeded" or not task.get("solution"): raise SystemExit(f"{task['errorCode']}: {task['errorDescription']}") print(task["solution"]["token"]) ``` **Go** ```go package main import ( "bytes" "crypto/rand" "encoding/json" "fmt" "io" "net/http" "os" "time" ) type task struct { ID string `json:"id"` Status string `json:"status"` ErrorCode *string `json:"errorCode"` Solution *struct { Token string `json:"token"` } `json:"solution"` } func call(method, path string, body any, idempotencyKey string) (task, error) { var t task var content io.Reader if body != nil { payload, err := json.Marshal(body) if err != nil { return t, err } content = bytes.NewReader(payload) } req, err := http.NewRequest(method, os.Getenv("ZEROCAPTCHA_API")+path, content) if err != nil { return t, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("ZEROCAPTCHA_KEY")) if body != nil { req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) } resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) if err != nil { return t, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return t, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode) } return t, json.NewDecoder(resp.Body).Decode(&t) } func main() { key := make([]byte, 16) _, _ = rand.Read(key) t, err := call(http.MethodPost, "/v1/tasks", map[string]string{ "type": "TurnstileTaskProxyless", // or "TurnstileTask", with proxy below "websiteURL": "https://example.com/login", // the page with the widget "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "action": "login", "cdata": "session-7f3a9c2e", // "proxy": "http://user:pass@proxy.example.net:8080", // TurnstileTask only // "callbackUrl": "https://hooks.example.com/zerocaptcha", // to be called when it ends }, fmt.Sprintf("%x", key)) for err == nil && (t.Status == "queued" || t.Status == "running") { time.Sleep(2 * time.Second) t, err = call(http.MethodGet, "/v1/tasks/"+t.ID, nil, "") } if err != nil || t.Solution == nil { code := "" if t.ErrorCode != nil { code = *t.ErrorCode } fmt.Fprintln(os.Stderr, "no token:", err, t.Status, code) os.Exit(1) } fmt.Println(t.Solution.Token) } ``` These are short on purpose. Production code also retries a 429 or 5xx after `Retry-After`, sends the same `Idempotency-Key` when it retries a create, and stops waiting at a deadline: the [quickstart](https://zerocaptcha.io/docs/quickstart)'s samples, the [SDKs](https://zerocaptcha.io/docs/sdks) and the [AI brief's reference clients](https://zerocaptcha.io/docs/ai) do all of it. See [Errors and retries](https://zerocaptcha.io/docs/errors-and-retries). ## Use the token A token works **once**, for **300 seconds** from `tokenIssuedAt`. Submit it straight away. ### In the form The widget puts its token in a hidden field named `cf-turnstile-response` in the form around it. Send the token in that field with the rest of the form, as the browser would: **curl** ```sh curl https://example.com/login \ --data-urlencode "email=you@example.com" \ --data-urlencode "password=$PASSWORD" \ --data-urlencode "cf-turnstile-response=$TOKEN" ``` **Node** ```js const form = new URLSearchParams({ email: "you@example.com", password: process.env.PASSWORD, "cf-turnstile-response": token, }); const response = await fetch("https://example.com/login", { method: "POST", body: form }); console.log(response.status); ``` **Python** ```python response = requests.post( "https://example.com/login", data={ "email": "you@example.com", "password": os.environ["PASSWORD"], "cf-turnstile-response": token, }, timeout=15, ) print(response.status_code) ``` **Go** ```go form := url.Values{ "email": {"you@example.com"}, "password": {os.Getenv("PASSWORD")}, "cf-turnstile-response": {token}, } resp, err := http.PostForm("https://example.com/login", form) ``` Some sites name the field differently with the widget's `data-response-field-name`, or send the token in a JSON body or a header from their own script. Look at the request the page makes when you submit it by hand (the developer tools' **Network** panel) and send the token the same way. ### Through the widget's callback When the page reacts to the token in JavaScript, through the widget's `data-callback` attribute or the `callback` option of `turnstile.render`, put the token where the widget would, then call that function with it. In a browser you control, as in [browser automation](https://zerocaptcha.io/docs/browser-automation): ```js // Run in the page: fill the hidden field, then hand the token to the page's own callback. (token) => { for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token; const widget = document.querySelector(".cf-turnstile[data-callback]"); const callback = widget && window[widget.dataset.callback]; if (typeof callback === "function") callback(token); }; ``` If the callback was passed to `turnstile.render` as an inline function, find what it does in the page's script, such as `submitLogin(token)`, and call that. ## Test against a live widget Every kind of Cloudflare Turnstile widget has a live demo page to point a task at: the [managed widget](https://zerocaptcha.io/captcha-test/cloudflare-turnstile-managed), the [invisible widget](https://zerocaptcha.io/captcha-test/cloudflare-turnstile-invisible), the [widget with action and cData](https://zerocaptcha.io/captcha-test/cloudflare-turnstile-action-cdata) and more, on the [Cloudflare Turnstile demo and CAPTCHA test pages](https://zerocaptcha.io/captcha-test). Each shows its sitekey and the exact request, and checks the token you bring with Cloudflare's siteverify; the [Cloudflare Turnstile token checker](https://zerocaptcha.io/captcha-test/cloudflare-turnstile-token-checker) checks one on its own. ## When it fails A task that is not solved after every attempt fails with [`ERROR_CAPTCHA_UNSOLVABLE`](https://zerocaptcha.io/docs/reference/errors#ERROR_CAPTCHA_UNSOLVABLE), and one not solved by its deadline expires with [`ERROR_TASK_TIMEOUT`](https://zerocaptcha.io/docs/reference/errors#ERROR_TASK_TIMEOUT). Neither is charged. If one keeps failing, check the `websiteURL` (the page with the widget, not the form's target), the `websiteKey`, and, with `TurnstileTask`, that your proxy works. A site on our blocklist is refused with [`domain_blocked`](https://zerocaptcha.io/docs/reference/errors#domain_blocked) and costs nothing. --- # createTask format > ZeroCaptcha's createTask, getTaskResult and getBalance, the JSON format other CAPTCHA APIs use: every field and spelling, reply, code and sample. Source: https://zerocaptcha.io/docs/createtask ZeroCaptcha speaks the `createTask` format that CapSolver, Anti-Captcha and 2Captcha's JSON API use, so a client written for one of them solves Turnstile and Cloudflare challenge pages here after two changes: its base URL, to our API's address, and its key, to your ZeroCaptcha key. A task made this way is priced, held and charged exactly as one made with REST. ZeroCaptcha is not affiliated with CapSolver, Anti-Captcha or 2Captcha. Their names appear here only to say which format this is. ## The calls | Call | Body | Reply | | --- | --- | --- | | `POST /createTask` | `{"clientKey": "…", "task": {…}}`, and optionally `callbackUrl` | `{"errorId": 0, "taskId": "…"}` | | `POST /getTaskResult` | `{"clientKey": "…", "taskId": "…"}` | `processing`, then `ready` with the solution | | `POST /getBalance` | `{"clientKey": "…"}` | `{"errorId": 0, "balance": 12.3456}` | | `POST /reportIncorrect`, `POST /reportCorrect` | `{"clientKey": "…", "taskId": "…"}` | `{"errorId": 0, "status": "success"}` | | `POST /reportIncorrectRecaptcha`, `POST /reportCorrectRecaptcha` | The same, as Anti-Captcha's clients send it | The same | | `POST /feedbackTask` | `{"clientKey": "…", "taskId": "…", "result": {"invalid": true}}`, as CapSolver's clients send it | The same | - **Every reply is HTTP 200.** Success is `errorId: 0`; a failure is `errorId: 1` with `errorCode` and `errorDescription`. Only a failure outside the format, such as a body over 64 KiB, a server timeout or the service shedding load, answers with an HTTP error and a [problem document](https://zerocaptcha.io/docs/reference/errors), so check the status too. - **The body is read leniently,** as these clients send it: JSON whatever the `Content-Type` (some send `text/plain`), a number wherever text is expected, `null` for an absent field, and fields it does not use are ignored. - **`Idempotency-Key`** works on `createTask` as on REST: send the same header again within 24 hours and you get the first task back instead of a second one. - **`clientKey`** is your API key, `zc_live_…`, 41 characters. ## Cloudflare Turnstile task | Field | Required | Other spellings | What it is | | --- | --- | --- | --- | | `type` | Yes | | `TurnstileTaskProxyless`, or `TurnstileTask` through your proxy; CapSolver's `AntiTurnstileTaskProxyLess` too, and `AntiTurnstileTask` for the proxy variant. Case does not matter. | | `websiteURL` | Yes | `websiteUrl` | The page with the widget. | | `websiteKey` | Yes | | The widget's site key. | | `action` | When the widget sets one | `pageAction` (CapMonster Cloud's), `metadata.action` | The widget's action: its `data-action`, or the `action` option of `turnstile.render()`. | | `cdata` | When the widget sets one | `cData`, `data`, `turnstileCData`, `metadata.cdata`, `metadata.cData` | The widget's cData: its `data-cdata`, or the `cData` option of `turnstile.render()`. | | `proxy` | With `TurnstileTask` | the `proxyAddress` fields | Your proxy as a URL: `http://user:pass@proxy.example.net:8080`. | | `proxyType`, `proxyAddress`, `proxyPort`, `proxyLogin`, `proxyPassword` | Instead of `proxy` | | Your proxy in parts: `proxyType` `http` (the default) or `https`, a public host, a port (a number or a string of digits), and an optional login and password of at most 255 bytes each. | | `cloudflareTaskType` | No | | CapMonster Cloud's mode: `token`, or absent, for the widget's token. Its `cf_clearance` and `wait_room` modes are refused with `ERROR_TASK_NOT_SUPPORTED`, before anything is made or charged: for a challenge page's clearance, use the [challenge page task](#challenge-page-task). | Most createTask clients send the action and cData nested in the task's `metadata`, as `"metadata": {"action": "login", "cdata": "session-7f3a9c2e"}`, and the samples below do too. Many sites check both when they verify the token, so send them whenever the widget sets them: see [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata). Where a field has more than one spelling, the first one sent wins, in the order listed. The limits are REST's: see [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile#the-tasks-fields). SOCKS proxies are not supported yet. ## Challenge page task | Field | Required | What it is | | --- | --- | --- | | `type` | Yes | `CloudflareChallengeTask`, or CapSolver's `AntiCloudflareTask`. | | `websiteURL` or `websiteUrl` | Yes | The page behind the challenge. | | `proxy`, or the `proxyAddress` fields | Yes | Your proxy: the clearance works only from its address. | A `websiteKey`, `action` or `cdata` is ignored, as are fields other providers take for a challenge page, such as `userAgent` and `html`. A proxyless challenge task, such as `CloudflareChallengeTaskProxyless` or `AntiCloudflareTaskProxyLess`, is refused with `ERROR_TASK_NOT_SUPPORTED`. See [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges). ## Replies of getTaskResult While the task runs: ```json { "errorId": 0, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "status": "processing" } ``` Solved, a Turnstile task: ```json { "errorId": 0, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "status": "ready", "solution": { "token": "0.Zm9vYmFy…", "type": "turnstile" }, "cost": "0.000800", "createTime": 1790776925, "endTime": 1790776934, "solveCount": 1, "expiresAt": "2026-09-30T14:07:14Z" } ``` Solved, a challenge page: `solution` has `"type": "cloudflare"`, `userAgent`, and `"cookies": {"cf_clearance": "…"}` beside `token`, as CapSolver's `AntiCloudflareTask` answers. Failed, or not solved in time (nothing is charged): ```json { "errorId": 1, "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", "errorDescription": "Every attempt to solve the challenge failed. Nothing was charged.", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "status": "failed", "cost": "0.000000" } ``` Solved, but read after its token expired: `errorId: 1`, `ERROR_TOKEN_EXPIRED`, `"status": "ready"` and the `cost` it was charged. Use tokens as soon as they are ready. | Field | Meaning | | --- | --- | | `status` | `processing` until the task ends, then `ready`; `failed` on an error reply for a task that did not succeed | | `solution.token` | The Turnstile token, or the `cf_clearance` cookie's value | | `expiresAt` | When the token stops being accepted, in UTC | | `cost` | What the task cost, in US dollars with six decimals | | `createTime`, `endTime` | When it was created and solved, in Unix seconds | | `solveCount` | The solve attempts it took | ## Samples **curl** ```sh # metadata holds the widget's data-action and data-cdata, or the action and cData options of # turnstile.render(): leave out any the widget does not set. For your own proxy, make the type # TurnstileTask and add "proxy": "http://user:pass@proxy.example.net:8080" to the task; to be # called when it ends, add "callbackUrl": "https://hooks.example.com/zerocaptcha" beside it. reply=$(curl -sS "$ZEROCAPTCHA_API/createTask" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d "{\"clientKey\": \"$ZEROCAPTCHA_KEY\", \"task\": {\"type\": \"TurnstileTaskProxyless\", \"websiteURL\": \"https://example.com/login\", \"websiteKey\": \"0x4AAAAAAAB1cD2eF3gH4iJ5\", \"metadata\": {\"action\": \"login\", \"cdata\": \"session-7f3a9c2e\"}}}") # errorId 1 is a refusal; its errorCode and errorDescription say why. if [ "$(jq -r .errorId <<<"$reply")" != 0 ]; then echo "$reply" >&2; exit 1; fi TASK_ID=$(jq -r .taskId <<<"$reply") # Every 2 seconds, until "status" is ready, or errorId 1 says the task failed: curl "$ZEROCAPTCHA_API/getTaskResult" -H "Content-Type: application/json" \ -d "{\"clientKey\": \"$ZEROCAPTCHA_KEY\", \"taskId\": \"$TASK_ID\"}" ``` **Node** ```js const api = process.env.ZEROCAPTCHA_API; const clientKey = process.env.ZEROCAPTCHA_KEY; const post = async (path, body, headers = {}) => { const response = await fetch(`${api}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ clientKey, ...body }), }); if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); const reply = await response.json(); if (reply.errorId !== 0) throw new Error(`${reply.errorCode}: ${reply.errorDescription}`); return reply; }; const { taskId } = await post( "/createTask", { task: { type: "TurnstileTaskProxyless", // or "TurnstileTask", with proxy below websiteURL: "https://example.com/login", // the page with the widget websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. metadata: { action: "login", cdata: "session-7f3a9c2e" }, // proxy: "http://user:pass@proxy.example.net:8080", // TurnstileTask only }, // callbackUrl: "https://hooks.example.com/zerocaptcha", // to be called when it ends }, { "Idempotency-Key": crypto.randomUUID() }, ); let result; do { await new Promise((resolve) => setTimeout(resolve, 2000)); result = await post("/getTaskResult", { taskId }); } while (result.status === "processing"); console.log(result.solution.token); ``` **Python** ```python import os import time import uuid import requests API = os.environ["ZEROCAPTCHA_API"] KEY = os.environ["ZEROCAPTCHA_KEY"] def post(path, body, headers=None): response = requests.post(f"{API}{path}", json={"clientKey": KEY, **body}, headers=headers, timeout=15) response.raise_for_status() reply = response.json() if reply["errorId"] != 0: raise RuntimeError(f"{reply['errorCode']}: {reply['errorDescription']}") return reply task_id = post( "/createTask", { "task": { "type": "TurnstileTaskProxyless", # or "TurnstileTask", with proxy below "websiteURL": "https://example.com/login", # the page with the widget "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", # its data-sitekey # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "metadata": {"action": "login", "cdata": "session-7f3a9c2e"}, # "proxy": "http://user:pass@proxy.example.net:8080", # TurnstileTask only }, # "callbackUrl": "https://hooks.example.com/zerocaptcha", # to be called when it ends }, headers={"Idempotency-Key": str(uuid.uuid4())}, )["taskId"] while True: time.sleep(2) result = post("/getTaskResult", {"taskId": task_id}) if result["status"] != "processing": break print(result["solution"]["token"]) ``` **Go** ```go package main import ( "bytes" "crypto/rand" "encoding/json" "fmt" "net/http" "os" "time" ) type reply struct { ErrorID int `json:"errorId"` ErrorCode string `json:"errorCode"` ErrorDescription string `json:"errorDescription"` TaskID string `json:"taskId"` Status string `json:"status"` Solution struct { Token string `json:"token"` } `json:"solution"` } func post(path string, body map[string]any, idempotencyKey string) (reply, error) { var r reply body["clientKey"] = os.Getenv("ZEROCAPTCHA_KEY") payload, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, os.Getenv("ZEROCAPTCHA_API")+path, bytes.NewReader(payload)) if err != nil { return r, err } req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) if err != nil { return r, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return r, fmt.Errorf("%s: HTTP %d", path, resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return r, err } if r.ErrorID != 0 { return r, fmt.Errorf("%s: %s", r.ErrorCode, r.ErrorDescription) } return r, nil } func main() { created, err := post("/createTask", map[string]any{ "task": map[string]any{ "type": "TurnstileTaskProxyless", // or "TurnstileTask", with proxy below "websiteURL": "https://example.com/login", // the page with the widget "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "metadata": map[string]string{"action": "login", "cdata": "session-7f3a9c2e"}, // "proxy": "http://user:pass@proxy.example.net:8080", // TurnstileTask only }, // "callbackUrl": "https://hooks.example.com/zerocaptcha", // to be called when it ends }, rand.Text()) result := created for err == nil && (result.Status == "" || result.Status == "processing") { time.Sleep(2 * time.Second) result, err = post("/getTaskResult", map[string]any{"taskId": created.TaskID}, "") } if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } fmt.Println(result.Solution.Token) } ``` **PHP** ```php true, CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers), CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, ]); $raw = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); if ($raw === false || $status !== 200) { throw new RuntimeException("$path: HTTP $status"); } $reply = json_decode($raw, true); if ($reply['errorId'] !== 0) { throw new RuntimeException("{$reply['errorCode']}: {$reply['errorDescription']}"); } return $reply; } $created = zerocaptcha('/createTask', [ 'task' => [ 'type' => 'TurnstileTaskProxyless', // or 'TurnstileTask', with 'proxy' below 'websiteURL' => 'https://example.com/login', // the page with the widget 'websiteKey' => '0x4AAAAAAAB1cD2eF3gH4iJ5', // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. 'metadata' => ['action' => 'login', 'cdata' => 'session-7f3a9c2e'], // 'proxy' => 'http://user:pass@proxy.example.net:8080', // TurnstileTask only ], // 'callbackUrl' => 'https://hooks.example.com/zerocaptcha', // to be called when it ends ], ['Idempotency-Key: ' . bin2hex(random_bytes(16))]); do { sleep(2); $result = zerocaptcha('/getTaskResult', ['taskId' => $created['taskId']]); } while ($result['status'] === 'processing'); echo $result['solution']['token'], PHP_EOL; ``` > **Note** > > Poll every 2 seconds. `getTaskResult` and `getBalance` share the read budgets with REST: over one, > the reply is `ERROR_RATE_LIMIT` with `Retry-After`. `createTask` has no rate budget; your balance and > your account's share of the queue bound it (`ERROR_NO_SLOT_AVAILABLE`). See > [Rate limits](https://zerocaptcha.io/docs/rate-limits). ## Reports A report says whether the site took a solved task's token: `reportIncorrect` (or Anti-Captcha's `reportIncorrectRecaptcha`, or CapSolver's `feedbackTask` with `"invalid": true`) that it refused it, and `reportCorrect` (or `reportCorrectRecaptcha`, or `"invalid": false`) that it took it. Each is recorded against the task, where our staff read it to find sites and settings that fail. Nothing is refunded: a task is charged only when it is solved, and every charge is final. A task takes one report: a second is `ERROR_DUPLICATE_REPORT`, and a report of a task that was not solved is `ERROR_REPORT_NOT_RECORDED`. A key needs `tasks:write` to report. ## Error codes Every `errorCode` of this format, with whether a retry helps and what it costs, is in the [errors reference](https://zerocaptcha.io/docs/reference/errors#compatible-codes), and a task's own failures under [task outcomes](https://zerocaptcha.io/docs/reference/errors#task-outcomes). The ones clients meet most: | Code | What to do | | --- | --- | | `ERROR_KEY_DOES_NOT_EXIST`, `ERROR_KEY_REVOKED` | Use a working key from the dashboard. | | `ERROR_ZERO_BALANCE` | Add funds, then create the task again. | | `ERROR_TASK_NOT_SUPPORTED`, `ERROR_INVALID_TASK_DATA` | Fix the task as `errorDescription` says. | | `ERROR_NO_SLOT_AVAILABLE`, `ERROR_RATE_LIMIT` | Wait a few seconds (or `Retry-After`), then retry. | | `ERROR_CAPTCHA_UNSOLVABLE`, `ERROR_TASK_TIMEOUT` | The task failed and cost nothing: create a new one. | | `ERROR_TOKEN_EXPIRED` | The task was solved and charged; create a new one and use its token at once. | | `ERROR_NO_SUCH_CAPCHA_ID` | Poll with the `taskId` `createTask` gave, with a key of the same account. | ## What differs from other providers - Task IDs are ZeroCaptcha's own, UUIDs such as `0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b`, not numbers. A client that keeps the ID as text passes it back unchanged. - Only Turnstile and Cloudflare challenge pages are offered; other task types are refused with `ERROR_TASK_NOT_SUPPORTED`. - `callbackUrl` beside `task` names a [callback](https://zerocaptcha.io/docs/callbacks), signed so you can check it. - There is no free trial or test key: every task is real, and charged only when it is solved. --- # Errors and retries > Which ZeroCaptcha errors a retry can fix, how long to wait, how to back off, and how an Idempotency-Key makes retrying a create safe. Source: https://zerocaptcha.io/docs/errors-and-retries Most errors say something is wrong with the request, the key or the balance, and sending the same request again fails the same way. A few say only "not now". Retry those, and nothing else, and your integration neither gives up too early nor hammers the API. ## How an error looks | Format | A failure is | | --- | --- | | REST | A 4xx or 5xx status with an RFC 9457 problem document (`application/problem+json`): branch on its `code` | | createTask | HTTP 200 with `errorId: 1`, `errorCode` and `errorDescription` | | 2Captcha | HTTP 200 with the code in place of the result, such as `ERROR_ZERO_BALANCE` | A request can also fail before it reaches the API's own format, at a proxy, on a body that is too large, or on a server timeout. That comes back as a 4xx or 5xx status, so check the HTTP status as well as the body in every format. Every reply carries `X-Request-Id`; quote it when you write to support. The [errors reference](https://zerocaptcha.io/docs/reference/errors) explains every code. ## What to do about each code | Policy | What your code 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`, `MAX_USER_TURN`, `ERROR: 1005` | | `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`, `ERROR_WRONG_USER_KEY`, `ERROR_PAGEURL`, `ERROR_BAD_PARAMETERS`, `ERROR_PROXY_FORMAT`, `ERROR_EMPTY_ACTION`, `ERROR_WRONG_ID_FORMAT`, `ERROR_WRONG_CAPTCHA_ID`, `ERROR_BAD_PROXY` | | `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` | In short: retry **429**, **5xx**, a lost connection or a timeout, and **409 `idempotency_key_in_use`**. Everything else needs a change first, or a new task. A task that failed or expired is final: reading it again will not change it. Create a new task if you still need a token; the failed one cost nothing. ## How long to wait 1. **When the reply has `Retry-After`,** wait that many seconds. `rate_limited` names the time until your budget has room; `queue_full` and `idempotency_key_in_use` say 2 seconds. 2. **Otherwise,** back off: wait 1 second, then 2, 4, 8, and 16 seconds at most, each multiplied by a random factor between 0.5 and 1, so many clients that failed together do not retry together. 3. **Give up at a deadline** you choose for the whole operation, such as 3 minutes for one solve, and give each request its own timeout, such as 15 seconds. **curl** ```sh # Retries a request on 429, 5xx or no answer, waiting as Retry-After says, else 1, 2, 4, 8, 16 s. attempt=0 while :; do status=$(curl -s -o reply.json -D headers.txt -w '%{http_code}' --max-time 15 \ "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID" -H "Authorization: Bearer $ZEROCAPTCHA_KEY") || status=000 case $status in 2??) break ;; 429 | 5?? | 000) ;; *) cat reply.json; exit 1 ;; esac wait=$(tr -d '\r' < headers.txt | awk 'tolower($1) == "retry-after:" { print $2 }') sleep "${wait:-$(( attempt < 4 ? 1 << attempt : 16 ))}" attempt=$(( attempt + 1 )) done cat reply.json ``` **Node** ```js const RETRYABLE = new Set([429, 500, 502, 503, 504]); /** Sends a request until it succeeds, a retry cannot help, or the deadline passes. */ export async function withRetries(send, deadline = Date.now() + 180_000) { for (let attempt = 0; ; attempt += 1) { let response; try { response = await send(); } catch (error) { if (Date.now() >= deadline) throw error; } if (response?.ok) return response; const body = response ? await response.clone().json().catch(() => ({})) : {}; const again = response === undefined || RETRYABLE.has(response.status) || (response.status === 409 && body.code === "idempotency_key_in_use"); if (!again) throw new Error(`${body.code ?? `HTTP ${response.status}`}: ${body.detail ?? ""}`); const retryAfter = Number(response?.headers.get("retry-after")); const wait = Number.isInteger(retryAfter) && retryAfter >= 0 ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 16_000) * (0.5 + Math.random() / 2); if (Date.now() + wait > deadline) throw new Error("gave up: the deadline passed"); await new Promise((resolve) => setTimeout(resolve, wait)); } } ``` **Python** ```python import random import time import requests RETRYABLE = {429, 500, 502, 503, 504} def with_retries(send, deadline_seconds=180): """Sends a request until it succeeds, a retry cannot help, or the deadline passes.""" deadline = time.monotonic() + deadline_seconds attempt = 0 while True: try: response = send() except (requests.ConnectionError, requests.Timeout): response = None if response is not None and response.ok: return response code = None if response is not None: try: code = response.json().get("code") except ValueError: pass if not (response.status_code in RETRYABLE or (response.status_code == 409 and code == "idempotency_key_in_use")): raise RuntimeError(f"{code or response.status_code}: {response.text}") retry_after = response.headers.get("Retry-After", "") if response is not None else "" wait = int(retry_after) if retry_after.isdigit() else min(2**attempt, 16) * random.uniform(0.5, 1) if time.monotonic() + wait > deadline: raise TimeoutError("gave up: the deadline passed") time.sleep(wait) attempt += 1 ``` **Go** ```go // withRetries sends a request until it succeeds, a retry cannot help, or ctx ends. newRequest // must build a fresh request each time, with the same Idempotency-Key for a create. func withRetries(ctx context.Context, newRequest func() (*http.Request, error)) (*http.Response, error) { for attempt := 0; ; attempt++ { req, err := newRequest() if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req.WithContext(ctx)) wait := time.Duration(0) if err == nil { if resp.StatusCode < 300 { return resp, nil } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var problem struct{ Code string } _ = json.Unmarshal(body, &problem) retryable := resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode == 409 && problem.Code == "idempotency_key_in_use") if !retryable { return nil, fmt.Errorf("%s: HTTP %d: %s", problem.Code, resp.StatusCode, body) } if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { wait = time.Duration(seconds) * time.Second } } if wait == 0 { backoff := time.Duration(1< Your prepaid USD balance, top-ups in crypto through NOWPayments from $10, how under- and over-payments are credited, receipts, caps and task costs. Source: https://zerocaptcha.io/docs/funds ZeroCaptcha is prepaid. You add funds to a balance in US dollars, and every solved task is paid from it. There is no subscription, and no free credit or trial: every task is real and paid. ## Your balance Your balance has two parts: - **`available`:** what new tasks can be held against. - **`held`:** held for tasks that are queued or running. Each is charged or released when its task ends. Amounts are exact decimal strings with six decimals, such as `"12.345600"`: handle them as decimals, never as floating-point numbers. Read the balance from your code with a key that has the `balance` scope: **curl** ```sh curl "$ZEROCAPTCHA_API/v1/balance" -H "Authorization: Bearer $ZEROCAPTCHA_KEY" # {"available":"12.345600","held":"0.001600","currency":"USD"} ``` **Node** ```js const response = await fetch(`${process.env.ZEROCAPTCHA_API}/v1/balance`, { headers: { Authorization: `Bearer ${process.env.ZEROCAPTCHA_KEY}` }, }); const { available, held } = await response.json(); console.log(`available ${available} USD, held ${held} USD`); ``` **Python** ```python import os from decimal import Decimal import requests response = requests.get( f"{os.environ['ZEROCAPTCHA_API']}/v1/balance", headers={"Authorization": f"Bearer {os.environ['ZEROCAPTCHA_KEY']}"}, timeout=15, ) balance = response.json() print(Decimal(balance["available"]), Decimal(balance["held"])) ``` **Go** ```go var balance struct { Available string `json:"available"` Held string `json:"held"` Currency string `json:"currency"` } req, _ := http.NewRequest(http.MethodGet, os.Getenv("ZEROCAPTCHA_API")+"/v1/balance", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("ZEROCAPTCHA_KEY")) resp, err := http.DefaultClient.Do(req) if err == nil { defer resp.Body.Close() err = json.NewDecoder(resp.Body).Decode(&balance) } ``` In the other formats: `getBalance` answers `{"errorId": 0, "balance": 12.3456}`, the available balance as a JSON number, and 2Captcha's `res.php?action=getbalance` answers `12.3456`. ## What tasks cost | Task | API task type | Price per task | Per 1,000 solved | | --- | --- | --- | --- | | Cloudflare Turnstile, your proxy | `TurnstileTask` | $0.0007 | $0.70 | | Cloudflare Turnstile, proxyless | `TurnstileTaskProxyless` | $0.0008 | $0.80 | | Cloudflare WAF and 5-second challenge, your proxy | `CloudflareChallengeTask` | $0.0012 | $1.20 | A task's price is held when you create it, charged once if it succeeds, and released in full if it fails or expires. A refused request costs nothing. A task is charged the price in effect when it was created, and the task shows it as `price`; the price list is public at [`GET /v1/prices`](https://zerocaptcha.io/docs/reference/api/prices) and on the [pricing page](https://zerocaptcha.io/pricing). See [How a task works](https://zerocaptcha.io/docs/how-tasks-work#what-is-charged-and-when). ## Top up 1. **Choose an amount.** On Billing in the dashboard, pick an amount or type one: $10 or more, with no maximum, in whole dollars or dollars and cents. 2. **Pay in crypto.** Continue to the payment page of our payment processor, NOWPayments. Pick the coin there, from the ones it offers, and send the exact amount it shows to the address it gives, on the network it names. You pay the network's fee. 3. **Get credited.** Back on Billing, the top-up shows as pending until NOWPayments reports the payment final, then as paid. Your balance is credited at once, the top-up gets a numbered receipt, and we email you that it was credited. Only an owner of the account can top up, once their email address is confirmed: before, a top-up is refused with [`email_unverified`](https://zerocaptcha.io/docs/reference/errors#email_unverified) and no invoice is made, so your receipts and payment emails always reach you. A top-up is an invoice in US dollars, and NOWPayments quotes each coin for it at its own rate. How many network confirmations a payment needs is up to NOWPayments and the coin; we credit a payment once NOWPayments reports it `finished`, or `partially_paid` when less arrived than was asked. We learn this from NOWPayments' signed notification, and check pending top-ups with NOWPayments every minute in case a notification is lost. What is credited: | The top-up shows | What happened | What is credited | | --- | --- | --- | | Pending | The invoice is open, or a payment is on its way | Nothing yet | | Paid | The invoice was paid | Its full amount | | Underpaid | Less than the invoice arrived | The share that arrived, at the processor's quote | | Overpaid | More than the invoice arrived | All of it, at the processor's quote | | Expired | Nothing arrived before the invoice expired | Nothing; a payment that still arrives is credited | Each payment is credited once, however often NOWPayments reports it. Coins sent again to the same address are a payment of their own, and credited as well. Send coins only on the network the payment page names. Coins sent on another network, or of another coin, are not credited automatically and may be lost; [write to us](https://zerocaptcha.io/contact) with the transaction hash. ## Top-ups are final We do not refund top-ups, in whole or in part: see the [refund policy](https://zerocaptcha.io/legal/refunds). What protects you instead: - **You pay only for solved tasks.** A task that fails or expires costs nothing, and its hold goes back to your balance at once. A task refused when you create it holds nothing. - **Start small.** A top-up can be as small as $10. - **Cap each key.** A key can have a daily spend cap; see [API keys](https://zerocaptcha.io/docs/keys#cap-a-keys-daily-spend). - **Hear when it runs low.** On Billing, turn on the low-balance email and set an amount. When your available balance falls below it, you get one email, and the next only after a top-up lifts the balance back above it. ## Spend caps A key's daily spend cap, in US dollars, is the most its tasks may hold or be charged in one UTC day. A task that would pass it is refused before any money moves, with [`spend_cap_reached`](https://zerocaptcha.io/docs/reference/errors#spend_cap_reached) (HTTP 402), or `ERROR_SPEND_CAP_REACHED`. A task that fails or expires stops counting. The count starts again at 00:00 UTC. Owners set, change or remove a cap on the keys page. ## When the balance runs out A task your balance cannot cover is refused before it starts, and costs nothing: [`insufficient_funds`](https://zerocaptcha.io/docs/reference/errors#insufficient_funds) in the REST API, or [`ERROR_ZERO_BALANCE`](https://zerocaptcha.io/docs/reference/errors#ERROR_ZERO_BALANCE) in the other formats. Prices held for tasks still running count against your balance, so the same task may pass once they end. Add funds, then send it again. ## Receipts and billing details Every credit gets a receipt, numbered 1, 2, 3 across the service without gaps, with the amount, the coin and network, and the time. Open one on Billing to read or print it. If you need a company name, a postal address or a tax ID on your receipts, add them under billing details; nobody needs them to pay, and a receipt keeps the details as they were when it was issued. --- # How a task works > A task's states from queued to succeeded, failed or expired, how long each step takes, how long a token lasts, and what is charged and when. Source: https://zerocaptcha.io/docs/how-tasks-work A task is one request to solve one challenge: a Turnstile widget, or a Cloudflare challenge page. This page follows a task from the moment you create it to the moment its token is deleted, and says what your balance does at each step. It is the same whichever [format](https://zerocaptcha.io/docs#three-formats-one-api) you create the task in. ## The life of a task 1. **Created:** the task is `queued`, its price held. 2. **Taken on:** a solver node starts an attempt, and the task is `running`. 3. **Ended:** solved, it is `succeeded`; not solved with attempts and time left, it goes back to `queued` for another attempt; not solved after its last attempt, it is `failed`; still unsolved at its deadline, queued or running, it is `expired`. | `status` | What it means | Final | | --- | --- | --- | | `queued` | Waiting for a solver with room. A task being retried is back here. | No | | `running` | A solver node is working on it. | No | | `succeeded` | Solved. The token is in the task while it is valid, and the price is charged. | Yes | | `failed` | Not solved: every attempt failed, or a check before an attempt refused it. Nothing is charged. | Yes | | `expired` | Not solved before its deadline. Nothing is charged. | Yes | Only these moves happen: `queued` to `running`, `running` back to `queued` for a retry, and `queued` or `running` to one of the three final states. A final state never changes. ## Timing - **The deadline.** Every task has one, in its `deadline` field: 150 seconds after it was created, by default. A task still unsolved then expires with `ERROR_TASK_TIMEOUT`. - **Attempts.** A task gets up to 3 solve attempts by default (its `maxAttempts`), and `attempts` says how many it has had. An attempt counts once a solver node took the work on; time spent waiting for a node with room counts toward the deadline, not the attempts. A task is not retried with less than 5 seconds left before its deadline. - **Checks before each attempt.** Before every attempt the page's domain is checked against the blocklist, the account against suspension, and a proxy's name is resolved again and must still be public. A task that fails one of these ends `failed` with `ERROR_DOMAIN_BLOCKED`, `ERROR_ACCOUNT_SUSPENDED` or `ERROR_PROXY_NOT_ALLOWED`. An owner who deletes the account cancels its queued tasks at once, with `ERROR_ACCOUNT_DELETED`. - **How long it takes.** It depends on the site and the solvers' load; the [status page](https://zerocaptcha.io/status) shows the median time to a token over the last 24 hours. Read the task every 2 seconds, or have us [call you back](https://zerocaptcha.io/docs/callbacks). ## The token A solved task carries its result in `solution`: | Task | `solution.token` | Valid for | | --- | --- | --- | | Turnstile | The token for the page's `cf-turnstile-response` field or the widget's callback | 300 seconds from `tokenIssuedAt`, once: Cloudflare accepts each token one time | | Challenge page | The `cf_clearance` cookie's value, with `solution.userAgent` and `solution.cookie` | As long as the site's Challenge Passage allows (30 minutes by default); we serve it for 30 minutes | `tokenExpiresAt` says until when the token is served. `tokenState` says where it stands: | `tokenState` | Meaning | | --- | --- | | `pending` | The task has not finished yet. | | `available` | Solved, and the token is valid: reading the task returns it. | | `expired` | Solved, but its lifetime has passed. The task stays charged. | | `deleted` | Solved, and the token was deleted, 10 minutes after it expired. | | `none` | The task failed or expired, so there is no token. | Use a token as soon as you have it. A Turnstile token read after it expired cannot be renewed: the REST API shows `tokenState: "expired"` and no `solution`, and `getTaskResult` answers `ERROR_TOKEN_EXPIRED`. Either way, the task succeeded and stays charged. ## What is charged, and when | When | Your balance | | --- | --- | | You create a task | Its price is **held**: `available` goes down by the price, `held` goes up by it. | | It succeeds | The hold becomes a **charge**, once. `cost` on the task becomes its price. | | It fails or expires | The hold is **released** in full. `cost` stays `0.000000`. | | A request is refused | Nothing is held or charged, whatever the code. | - A task is charged the price in effect **when it was created**, which the task shows as `price`. Prices are public at [`GET /v1/prices`](https://zerocaptcha.io/docs/reference/api/prices) and on the [pricing page](https://zerocaptcha.io/pricing). - A task your balance cannot cover is refused before it starts, with [`insufficient_funds`](https://zerocaptcha.io/docs/reference/errors#insufficient_funds). Prices held for running tasks count against your balance. - A key with a [daily spend cap](https://zerocaptcha.io/docs/keys#cap-a-keys-daily-spend) counts what its tasks hold or were charged that UTC day. - Each task settles exactly once: a solver that answers twice, or a worker that restarts, can never charge a task twice or release it after it was charged. > **Nothing free, nothing wasted** > > There is no sandbox or test key: every task solves a real challenge and is charged if it succeeds. > To try the API cheaply, send one task; it costs one task's price, and only if it is solved. ## Creating the same task twice Send an `Idempotency-Key` header with every create, one new value per task. If a reply is lost and you send the same request again with the same key within 24 hours, you get the first reply, with the same task, instead of a second task and a second charge. See [Errors and retries](https://zerocaptcha.io/docs/errors-and-retries#idempotency). ## How long tasks are kept Tasks stay in your task log and in the API for about 90 days: records are deleted a month at a time, once the month they were created in ended more than 90 days ago. A proxy's password is deleted as soon as its task finishes, and a token 10 minutes after it expires. Your monthly usage totals stay. --- # API keys > How ZeroCaptcha API keys work, from their scopes to allowlists, rotation with an overlap and revocation. Source: https://zerocaptcha.io/docs/keys Every call to the API carries an API key, and the key decides what the call may do: which scopes it has and which addresses it may come from. You manage your keys in the dashboard. ## Create a key An owner of the account creates keys on the dashboard's API keys page, or the first one on the Overview in one click. Creating a key needs your email address confirmed: until you open the link we emailed you, creating one is refused with [`email_unverified`](https://zerocaptcha.io/docs/reference/errors#email_unverified), and the dashboard offers to send the link again. Keys made before keep working, and you can still rename, rotate and revoke them. ## Send the key The REST API takes the key as a bearer token: ```sh curl "$ZEROCAPTCHA_API/v1/tasks" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" ``` The compatible calls, `createTask`, `getTaskResult` and `getBalance`, take it as `clientKey` in the JSON body instead, as other providers' clients already send it, and the 2Captcha format as the `key` parameter. See [Authentication](https://zerocaptcha.io/docs/authentication) for keeping keys safe. ## What a key looks like A key is 41 characters: `zc_live_`, 27 random characters, then a 6-character checksum. The prefix makes a leaked key easy to spot, and the checksum lets a secret scanner confirm a match without calling us. A key is shown once, when it is created, and only its hash is stored. Store it in a secret manager at once. Afterwards the dashboard shows each key by its first and last characters, such as `zc_live_8K2p…f41c`, which is enough to tell your keys apart and too little to use. ## One kind of key Every key works the same way: its tasks solve real challenges, and each solved task is charged from your balance. A key sees every task of its account, whichever key created it. ## Scopes Each key has one or both of two scopes: | Scope | What the key may do | | --- | --- | | `tasks` | Create tasks and read them, their results and their live updates | | `balance` | Read the balance | A call outside a key's scopes is refused with [`insufficient_scope`](https://zerocaptcha.io/docs/reference/errors#insufficient_scope), or [`ERROR_ACCESS_DENIED`](https://zerocaptcha.io/docs/reference/errors#ERROR_ACCESS_DENIED) in the compatible dialect. ## Allowed IP addresses A key can be held to up to 100 IP addresses or CIDR networks, IPv4 or IPv6, such as `203.0.113.24` or `198.51.100.0/24`. Write a network with no bits set past its prefix: `198.51.100.0/24`, not `198.51.100.7/24`. A key with an empty list works from any address. A call from any other address is refused with [`ip_not_allowed`](https://zerocaptcha.io/docs/reference/errors#ip_not_allowed), or [`ERROR_IP_NOT_ALLOWED`](https://zerocaptcha.io/docs/reference/errors#ERROR_IP_NOT_ALLOWED). A change to the list, or to the key's name, applies to the key's next call. ## Rotate a key Rotate a key to replace it without an outage, on a schedule or as soon as it may have leaked. 1. **Choose an overlap.** Rotating makes a new key with the old one's name, scopes and allowed addresses, and shows it once. The old key keeps working for the overlap you choose: 1 hour, 24 hours or 7 days. 2. **Deploy the new key.** During the overlap both keys work, so your running code never stops. 3. **Let the old key go.** At the end of the overlap the old key stops working. If you finish sooner, end the overlap from the keys page and it stops at once. After its overlap, a call with the old key is refused as revoked: [`key_revoked`](https://zerocaptcha.io/docs/reference/errors#key_revoked), or [`ERROR_KEY_REVOKED`](https://zerocaptcha.io/docs/reference/errors#ERROR_KEY_REVOKED), with a description that says the key was rotated. A key is rotated once: to rotate again, rotate the key that replaced it. ## Revoke a key Revoking a key stops it at once: every later call with it is refused with `key_revoked`, or `ERROR_KEY_REVOKED`. Tasks it already created run to their end, charged only if they succeed. A revoked key stays on the keys page, so older tasks can still be traced to it, and it can never work again: to replace it, create a new key. ## Cap a key's daily spend A key can have a daily spend cap, in US dollars: the most its tasks may hold or be charged in one UTC day. A task that would pass it is refused before any money moves, with [`spend_cap_reached`](https://zerocaptcha.io/docs/reference/errors#spend_cap_reached), or [`ERROR_SPEND_CAP_REACHED`](https://zerocaptcha.io/docs/reference/errors#ERROR_SPEND_CAP_REACHED), and HTTP 402. A task that fails or expires stops counting. A key has no cap until you set one on the keys page, which lists each key's cap beside it, and the API shows it as `dailyCap` on the key. ## When a key was last used The keys page shows when each key last made a call it was allowed to make, and the address that call came from. They are recorded in batches, so they can trail the latest call by up to half a minute. A refused call does not count as a use. ## Limits - An account may have 20 active keys. A key being replaced by a rotation, and a revoked key, does not count, so rotating a key never needs a free place. Beyond the limit, creating a key is refused with [`key_limit_reached`](https://zerocaptcha.io/docs/reference/errors#key_limit_reached). - Keys are managed from a signed-in dashboard session only: no API key may create, change or revoke keys, its own included. ## Errors | Code | Dialect | When | | --- | --- | --- | | [`unauthorized`](https://zerocaptcha.io/docs/reference/errors#unauthorized), [`ERROR_KEY_DOES_NOT_EXIST`](https://zerocaptcha.io/docs/reference/errors#ERROR_KEY_DOES_NOT_EXIST) | REST, compatible | The key is missing, mistyped or unknown | | [`key_revoked`](https://zerocaptcha.io/docs/reference/errors#key_revoked), [`ERROR_KEY_REVOKED`](https://zerocaptcha.io/docs/reference/errors#ERROR_KEY_REVOKED) | REST, compatible | The key was revoked, or its rotation's overlap ended | | [`ip_not_allowed`](https://zerocaptcha.io/docs/reference/errors#ip_not_allowed), [`ERROR_IP_NOT_ALLOWED`](https://zerocaptcha.io/docs/reference/errors#ERROR_IP_NOT_ALLOWED) | REST, compatible | The call came from an address the key does not allow | | [`insufficient_scope`](https://zerocaptcha.io/docs/reference/errors#insufficient_scope), [`ERROR_ACCESS_DENIED`](https://zerocaptcha.io/docs/reference/errors#ERROR_ACCESS_DENIED) | REST, compatible | The key lacks the scope the call needs | | [`email_unverified`](https://zerocaptcha.io/docs/reference/errors#email_unverified) | Dashboard | A new key, before your email address is confirmed | | [`key_limit_reached`](https://zerocaptcha.io/docs/reference/errors#key_limit_reached) | Dashboard | The account has as many active keys as it may | | [`key_state_conflict`](https://zerocaptcha.io/docs/reference/errors#key_state_conflict) | Dashboard | The key cannot change that way, such as a revoked key being rotated | None of these costs anything. Every code, with what to do about it, is in the [errors reference](https://zerocaptcha.io/docs/reference/errors). --- # Migrate a 2Captcha client > Move Cloudflare Turnstile solving from 2Captcha to ZeroCaptcha with in.php and res.php or the createTask format: change the host and key, then check. Source: https://zerocaptcha.io/docs/migrate-2captcha Code that solves Cloudflare Turnstile through 2Captcha moves to ZeroCaptcha without a rewrite: ZeroCaptcha serves both of the formats 2Captcha documents for Turnstile, `in.php` and `res.php`, and the JSON `createTask` format. In most cases the change is the host and the key. ZeroCaptcha is not affiliated with 2Captcha. This guide names it only to show what to change. ## Before you start 1. [Sign up](https://zerocaptcha.io/docs/quickstart), create a key and add funds. Keep the key in your environment, as `ZEROCAPTCHA_KEY`, and the API's address as `ZEROCAPTCHA_API`. 2. Check how your code keeps task IDs. `in.php` answers numbers, as 2Captcha does, such as `10000004821`. The createTask format answers UUIDs, such as `0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b`, so code that uses it must keep the ID as text. 3. Check what your code asks for: ZeroCaptcha serves `method=turnstile` only, and challenge pages through the [createTask format](https://zerocaptcha.io/docs/createtask) or REST. Your 2Captcha balance does not move: ZeroCaptcha is prepaid separately, in US dollars. ## in.php and res.php The host and the key change; the parameters and replies stay. Keep sending the widget's action and cData as `action` and `data`, as 2Captcha documents them: many sites check both when they verify the token. See [action and cData](https://zerocaptcha.io/docs/action-and-cdata). **curl** ```sh # action and data are the widget's data-action and data-cdata, or the action and cData options of # turnstile.render(); leave out any the widget does not set. # Before curl "https://2captcha.com/in.php?key=$TWOCAPTCHA_KEY&method=turnstile&sitekey=0x4AAAAAAAB1cD2eF3gH4iJ5&pageurl=https://shop.example.com/login&action=login&data=session-7f3a9c2e&json=1" # After, with an Idempotency-Key so a retried submit returns the same task: curl -H "Idempotency-Key: $(uuidgen)" \ "$ZEROCAPTCHA_API/in.php?key=$ZEROCAPTCHA_KEY&method=turnstile&sitekey=0x4AAAAAAAB1cD2eF3gH4iJ5&pageurl=https://shop.example.com/login&action=login&data=session-7f3a9c2e&json=1" ``` **Node** ```js // Before // const BASE = "https://2captcha.com"; // const KEY = process.env.TWOCAPTCHA_KEY; // After const BASE = process.env.ZEROCAPTCHA_API; const KEY = process.env.ZEROCAPTCHA_KEY; const params = new URLSearchParams({ key: KEY, method: "turnstile", sitekey: "0x4AAAAAAAB1cD2eF3gH4iJ5", pageurl: "https://shop.example.com/login", // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. action: "login", data: "session-7f3a9c2e", json: "1", }); // One Idempotency-Key per task: a retried submit with it returns the same task. const headers = { "Idempotency-Key": crypto.randomUUID() }; const submitted = await fetch(`${BASE}/in.php?${params}`, { headers }).then((response) => response.json()); if (submitted.status !== 1) throw new Error(`${submitted.request}: ${submitted.error_text}`); console.log(submitted.request); // the task ID, as text ``` **Python** ```python import os import uuid import requests # Before # BASE = "https://2captcha.com" # KEY = os.environ["TWOCAPTCHA_KEY"] # After BASE = os.environ["ZEROCAPTCHA_API"] KEY = os.environ["ZEROCAPTCHA_KEY"] submitted = requests.get( f"{BASE}/in.php", params={ "key": KEY, "method": "turnstile", "sitekey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "pageurl": "https://shop.example.com/login", # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "action": "login", "data": "session-7f3a9c2e", "json": 1, }, # One Idempotency-Key per task: a retried submit with it returns the same task. headers={"Idempotency-Key": str(uuid.uuid4())}, timeout=15, ).json() if submitted["status"] != 1: raise RuntimeError(f"{submitted['request']}: {submitted.get('error_text')}") print(submitted["request"]) # the task ID, as text ``` **Go** ```go // Before: // base, key := "https://2captcha.com", os.Getenv("TWOCAPTCHA_KEY") // After: base, key := os.Getenv("ZEROCAPTCHA_API"), os.Getenv("ZEROCAPTCHA_KEY") query := url.Values{ "key": {key}, "method": {"turnstile"}, "sitekey": {"0x4AAAAAAAB1cD2eF3gH4iJ5"}, "pageurl": {"https://shop.example.com/login"}, // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "action": {"login"}, "data": {"session-7f3a9c2e"}, "json": {"1"}, } req, _ := http.NewRequest(http.MethodGet, base+"/in.php?"+query.Encode(), nil) // One Idempotency-Key per task: a retried submit with it returns the same task. req.Header.Set("Idempotency-Key", rand.Text()) resp, err := http.DefaultClient.Do(req) ``` **PHP** ```php $key, 'method' => 'turnstile', 'sitekey' => '0x4AAAAAAAB1cD2eF3gH4iJ5', 'pageurl' => 'https://shop.example.com/login', // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. 'action' => 'login', 'data' => 'session-7f3a9c2e', 'json' => 1, ]); // One Idempotency-Key per task: a retried submit with it returns the same task. $context = stream_context_create(['http' => ['header' => 'Idempotency-Key: ' . bin2hex(random_bytes(16))]]); $submitted = json_decode(file_get_contents("$base/in.php?$query", false, $context), true); if ($submitted['status'] !== 1) { throw new RuntimeException("{$submitted['request']}: {$submitted['error_text']}"); } echo $submitted['request'], PHP_EOL; // the task ID, as text ``` Polling `res.php?action=get&id=…` is the same: `CAPCHA_NOT_READY`, then `OK|` or an error code. [2Captcha format](https://zerocaptcha.io/docs/2captcha) lists every parameter and code. If you use a client library, point it at the API's address if it lets you set the host; if you are not sure it does, call the API over HTTP as above. ## The createTask format 2Captcha's JSON API moves the same way: post the same body to `/createTask` and `/getTaskResult` on ZeroCaptcha's host, with your ZeroCaptcha key as `clientKey`. The samples send the widget's action and cData in the task's `metadata`, as most createTask clients do; a 2Captcha client that sends them as `action` and `data` works unchanged. **curl** ```sh # Before: https://api.2captcha.com/createTask with your 2Captcha key as clientKey. # After, with the widget's data-action and data-cdata (or turnstile.render()'s action and cData) in # metadata, and an Idempotency-Key so a retried create returns the same task: curl "$ZEROCAPTCHA_API/createTask" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d "{\"clientKey\": \"$ZEROCAPTCHA_KEY\", \"task\": {\"type\": \"TurnstileTaskProxyless\", \"websiteURL\": \"https://shop.example.com/login\", \"websiteKey\": \"0x4AAAAAAAB1cD2eF3gH4iJ5\", \"metadata\": {\"action\": \"login\", \"cdata\": \"session-7f3a9c2e\"}}}" ``` **Node** ```js // Before: const BASE = "https://api.2captcha.com"; const BASE = process.env.ZEROCAPTCHA_API; const created = await fetch(`${BASE}/createTask`, { method: "POST", // One Idempotency-Key per task: a retried create with it returns the same task. headers: { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ clientKey: process.env.ZEROCAPTCHA_KEY, task: { type: "TurnstileTaskProxyless", websiteURL: "https://shop.example.com/login", websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. metadata: { action: "login", cdata: "session-7f3a9c2e" }, }, }), }).then((response) => response.json()); if (created.errorId !== 0) throw new Error(`${created.errorCode}: ${created.errorDescription}`); console.log(created.taskId); ``` **Python** ```python import os import uuid import requests # Before: BASE = "https://api.2captcha.com" BASE = os.environ["ZEROCAPTCHA_API"] created = requests.post( f"{BASE}/createTask", json={ "clientKey": os.environ["ZEROCAPTCHA_KEY"], "task": { "type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "metadata": {"action": "login", "cdata": "session-7f3a9c2e"}, }, }, # One Idempotency-Key per task: a retried create with it returns the same task. headers={"Idempotency-Key": str(uuid.uuid4())}, timeout=15, ).json() if created["errorId"] != 0: raise RuntimeError(f"{created['errorCode']}: {created['errorDescription']}") print(created["taskId"]) ``` **Go** ```go // Before: base := "https://api.2captcha.com" base := os.Getenv("ZEROCAPTCHA_API") body, _ := json.Marshal(map[string]any{ "clientKey": os.Getenv("ZEROCAPTCHA_KEY"), "task": map[string]any{ "type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "metadata": map[string]string{"action": "login", "cdata": "session-7f3a9c2e"}, }, }) req, _ := http.NewRequest(http.MethodPost, base+"/createTask", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // One Idempotency-Key per task: a retried create with it returns the same task. req.Header.Set("Idempotency-Key", rand.Text()) resp, err := http.DefaultClient.Do(req) ``` **PHP** ```php [ 'method' => 'POST', // One Idempotency-Key per task: a retried create with it returns the same task. 'header' => "Content-Type: application/json\r\nIdempotency-Key: " . bin2hex(random_bytes(16)), 'content' => json_encode([ 'clientKey' => getenv('ZEROCAPTCHA_KEY'), 'task' => [ 'type' => 'TurnstileTaskProxyless', 'websiteURL' => 'https://shop.example.com/login', 'websiteKey' => '0x4AAAAAAAB1cD2eF3gH4iJ5', // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. 'metadata' => ['action' => 'login', 'cdata' => 'session-7f3a9c2e'], ], ]), ]])), true); if ($created['errorId'] !== 0) { throw new RuntimeException("{$created['errorCode']}: {$created['errorDescription']}"); } echo $created['taskId'], PHP_EOL; ``` The widget's cData goes in `data`, `cdata`, `cData`, `turnstileCData`, `metadata.cdata` or `metadata.cData`, and its action in `action`, `pageAction` or `metadata.action`. A field under any other name is ignored, so check the names your code sends. See [createTask format](https://zerocaptcha.io/docs/createtask). ## What to check after you switch - **Task IDs** are numbers in `in.php` and `res.php`, and UUIDs, kept as text, in the createTask format. - **Pingbacks** need no registration: any public URL works. Each call is signed; see [Polling and callbacks](https://zerocaptcha.io/docs/callbacks#check-the-signature) to check it. - **Reports:** `reportbad`, `reportgood` and the createTask format's `reportIncorrect` and `reportCorrect` are recorded for our staff and never refunded: every charge is final. - **Not offered:** CAPTCHA types other than Cloudflare Turnstile. - **Money:** balances and prices are in US dollars; `getbalance` answers the available balance, and a task is charged only when it is solved. See [pricing](https://zerocaptcha.io/pricing). - **Errors:** most codes are 2Captcha's own; a few are ZeroCaptcha's, such as `ERROR_SPEND_CAP_REACHED` and `ERROR_IDEMPOTENCY_KEY_REUSED`. The [2Captcha format](https://zerocaptcha.io/docs/2captcha#error-codes) lists them all. ## Roll it out safely 1. Point one service, or a share of your traffic, at ZeroCaptcha with a key of its own and a daily [spend cap](https://zerocaptcha.io/docs/keys#cap-a-keys-daily-spend). 2. Watch its tasks in the dashboard's task log and on the [status page](https://zerocaptcha.io/status). 3. Move the rest once it behaves, then retire the old keys. --- # Migrate a createTask client > Move a CapSolver or Anti-Captcha style createTask client to ZeroCaptcha: change the host and key, then check its field names and replies. Source: https://zerocaptcha.io/docs/migrate-createtask Clients written for CapSolver's or Anti-Captcha's JSON API call `createTask`, then `getTaskResult` until the task is ready. ZeroCaptcha answers the same calls, with the same shapes, for Cloudflare Turnstile and Cloudflare challenge pages. The change is the host and the key, plus a look at the field names your client sends. ZeroCaptcha is not affiliated with CapSolver or Anti-Captcha. This guide names them only to show what to change. ## Before you start 1. [Sign up](https://zerocaptcha.io/docs/quickstart), create a key and add funds. Keep the key in your environment, as `ZEROCAPTCHA_KEY`, and the API's address as `ZEROCAPTCHA_API`. 2. Check that your code keeps task IDs as text: ZeroCaptcha's are UUIDs. 3. Check the task types you send. ZeroCaptcha takes `TurnstileTaskProxyless`, `TurnstileTask`, `CloudflareChallengeTask`, and the same names with CapSolver's `Anti` prefix (`AntiTurnstileTaskProxyLess`, `AntiTurnstileTask` and `AntiCloudflareTask`), in any case. Any other type is `ERROR_TASK_NOT_SUPPORTED`. ## Change the host and the key The task stays as your client sends it, with the widget's action and cData in `metadata`: many sites check both when they verify the token, so keep sending them. See [action and cData](https://zerocaptcha.io/docs/action-and-cdata). An `Idempotency-Key` header, new to most of these clients, makes a retried create return the same task. **curl** ```sh # Before: https://api.capsolver.com/createTask or https://api.anti-captcha.com/createTask # After. metadata holds the widget's data-action and data-cdata, or the action and cData options # of turnstile.render(); leave out any the widget does not set. curl "$ZEROCAPTCHA_API/createTask" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d "{\"clientKey\": \"$ZEROCAPTCHA_KEY\", \"task\": {\"type\": \"AntiTurnstileTaskProxyLess\", \"websiteURL\": \"https://shop.example.com/login\", \"websiteKey\": \"0x4AAAAAAAB1cD2eF3gH4iJ5\", \"metadata\": {\"action\": \"login\", \"cdata\": \"session-7f3a9c2e\"}}}" ``` **Node** ```js // Before // const BASE = "https://api.capsolver.com"; // const clientKey = process.env.CAPSOLVER_KEY; // After const BASE = process.env.ZEROCAPTCHA_API; const clientKey = process.env.ZEROCAPTCHA_KEY; const created = await fetch(`${BASE}/createTask`, { method: "POST", // One Idempotency-Key per task: a retried create with it returns the same task. headers: { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ clientKey, task: { type: "AntiTurnstileTaskProxyLess", websiteURL: "https://shop.example.com/login", websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. metadata: { action: "login", cdata: "session-7f3a9c2e" }, }, }), }).then((response) => response.json()); console.log(created.errorId === 0 ? created.taskId : created.errorCode); ``` **Python** ```python import os import uuid import requests # Before # BASE = "https://api.anti-captcha.com" # CLIENT_KEY = os.environ["ANTICAPTCHA_KEY"] # After BASE = os.environ["ZEROCAPTCHA_API"] CLIENT_KEY = os.environ["ZEROCAPTCHA_KEY"] created = requests.post( f"{BASE}/createTask", json={ "clientKey": CLIENT_KEY, "task": { "type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. "metadata": {"action": "login", "cdata": "session-7f3a9c2e"}, }, }, # One Idempotency-Key per task: a retried create with it returns the same task. headers={"Idempotency-Key": str(uuid.uuid4())}, timeout=15, ).json() print(created["taskId"] if created["errorId"] == 0 else created["errorCode"]) ``` **Go** ```go // Before: // base, clientKey := "https://api.capsolver.com", os.Getenv("CAPSOLVER_KEY") // After: base, clientKey := os.Getenv("ZEROCAPTCHA_API"), os.Getenv("ZEROCAPTCHA_KEY") body, _ := json.Marshal(map[string]any{ "clientKey": clientKey, "task": map[string]any{ "type": "AntiTurnstileTaskProxyLess", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. "metadata": map[string]string{"action": "login", "cdata": "session-7f3a9c2e"}, }, }) req, _ := http.NewRequest(http.MethodPost, base+"/createTask", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // One Idempotency-Key per task: a retried create with it returns the same task. req.Header.Set("Idempotency-Key", rand.Text()) resp, err := http.DefaultClient.Do(req) ``` **PHP** ```php [ 'method' => 'POST', // One Idempotency-Key per task: a retried create with it returns the same task. 'header' => "Content-Type: application/json\r\nIdempotency-Key: " . bin2hex(random_bytes(16)), 'content' => json_encode([ 'clientKey' => $clientKey, 'task' => [ 'type' => 'TurnstileTaskProxyless', 'websiteURL' => 'https://shop.example.com/login', 'websiteKey' => '0x4AAAAAAAB1cD2eF3gH4iJ5', // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. 'metadata' => ['action' => 'login', 'cdata' => 'session-7f3a9c2e'], ], ]), ]])), true); echo $created['errorId'] === 0 ? $created['taskId'] : $created['errorCode'], PHP_EOL; ``` `getTaskResult` and `getBalance` move the same way: the same bodies, on ZeroCaptcha's host. If you use a client library, point it at the API's address if it lets you set the host; if you are not sure it does, call the API over HTTP as above. ## Check the field names ZeroCaptcha reads the spellings these clients send, and ignores fields it does not use, so a field under a name it does not know is silently left out. Check yours against these: | What | Names ZeroCaptcha reads | | --- | --- | | The page | `websiteURL`, `websiteUrl` | | The site key | `websiteKey` | | The action | `action`, `pageAction`, `metadata.action` | | The cData | `cdata`, `cData`, `data`, `turnstileCData`, `metadata.cdata` (or `metadata.cData`) | | A proxy | `proxy` as a URL, or `proxyType`, `proxyAddress`, `proxyPort`, `proxyLogin`, `proxyPassword` | | A callback | `callbackUrl`, beside `task` | Each of these is read as the clients send it, Anti-Captcha's `cData` included. A cData under any other name is left out, and the site may then refuse the token. CapMonster Cloud's clients send `pageAction` and `data`, both read. Its `cloudflareTaskType` may be `token` or left out; its `cf_clearance` mode is refused with `ERROR_TASK_NOT_SUPPORTED`, as the clearance would be tied to a user agent the client did not choose. Use `CloudflareChallengeTask` with your proxy instead: its reply gives the clearance and the user agent to send it with. ## Replies - A created task: `{"errorId": 0, "taskId": "…"}`. - While it runs: `{"errorId": 0, "status": "processing"}`. - Solved: `status: "ready"`, `solution.token`, and `cost`, `createTime`, `endTime`, `solveCount` and `expiresAt`. A challenge page's solution also has `userAgent` and `cookies.cf_clearance`, as CapSolver's `AntiCloudflareTask` answers. - Failed: `errorId: 1` with `errorCode`, such as `ERROR_CAPTCHA_UNSOLVABLE`, and nothing charged. - Balance: `{"errorId": 0, "balance": 12.3456}`, in US dollars. - Reports: `reportIncorrect`, `reportCorrect`, Anti-Captcha's `reportIncorrectRecaptcha` and `reportCorrectRecaptcha`, and CapSolver's `feedbackTask` answer `{"errorId": 0, "status": "success"}`. Each is recorded for our staff and refunds nothing, as every charge is final. Every reply is HTTP 200; a failure is `errorId: 1`. See [createTask format](https://zerocaptcha.io/docs/createtask) for every field and the [errors reference](https://zerocaptcha.io/docs/reference/errors#compatible-codes) for every code. ## What to check after you switch - **Proxies:** `http` and `https` only; SOCKS is not supported yet. A challenge page always needs your proxy: there is no proxyless challenge task. - **Errors:** `ERROR_RATE_LIMIT` asks you to slow your polling; `ERROR_NO_SLOT_AVAILABLE` means your account's share of the queue is full for a moment. Both come with a wait: see [Errors and retries](https://zerocaptcha.io/docs/errors-and-retries). - **Money:** prepaid US dollars, charged only when a task is solved. See [pricing](https://zerocaptcha.io/pricing). - **Idempotency:** send an `Idempotency-Key` header with each `createTask`, so a retry after a lost reply returns the first task instead of creating a second. Roll it out one service at a time, with a key of its own and a daily [spend cap](https://zerocaptcha.io/docs/keys#cap-a-keys-daily-spend), and watch its tasks in the dashboard. --- # Quickstart > Create a Cloudflare Turnstile task, poll for its token with a deadline and handle every failure, in Python, Node, Go or curl. Source: https://zerocaptcha.io/docs/quickstart Solve one Turnstile challenge from your own code: create a task, poll until its token is ready, and stop with a clear message if anything goes wrong. Each sample on this page is a complete program, and the same file runs in our tests against every failure described below. ## Get an API key and add funds 1. **Sign up.** Open the dashboard's sign-up page and enter your email address and a password of at least 8 characters. You go straight to the dashboard. 2. **Confirm your email.** Open the link we email you. Creating an API key and adding funds need a confirmed email address; everything else in the dashboard works before. No email? Send the link again from the dashboard, or change a mistyped address in Settings. Two-factor authentication stays optional, and the dashboard suggests it once your first task is solved. 3. **Create your key.** On the dashboard's first page, create your API key in one click. It is shown once, so store it somewhere safe, such as a secret manager. 4. **Add funds.** On Billing, choose an amount of $10 or more, with no maximum, and continue to the payment page, where you pick the coin and pay in crypto. Your balance is credited once the payment is confirmed on its network, and the top-up gets a numbered receipt. Top-ups are final. See [Adding funds](https://zerocaptcha.io/docs/funds). A key starts with `zc_live_`, and there is only one kind: every task it creates solves a real challenge. There is no sandbox, test key or free credit. Each solved task is charged from your balance at the price in force when it was created, and a task that fails costs nothing. See [pricing](https://zerocaptcha.io/pricing), and [API keys](https://zerocaptcha.io/docs/keys) for scopes, allowlists, spend caps and rotating a key. ## Find the widget's site key, action and cData A task needs these details of the page that shows the challenge: - `websiteURL`: the full address of the page, such as `https://example.com/login`. - `websiteKey`: its Turnstile site key, which looks like `0x4AAAAAAA…`. Find it in the page's HTML, in the widget's `data-sitekey` attribute or in the `sitekey` option passed to `turnstile.render()`. - `action` and `cdata`: the widget's action and cData, if it sets them, in its `data-action` and `data-cdata` attributes or the `action` and `cData` options of `turnstile.render()`. Many sites check both when they verify the token and refuse one solved without them, so send them exactly as the widget sets them, and leave out any it does not set. The sample sends them as the createTask format names them, `metadata.action` and `metadata.cdata`. See [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata). Send tasks only for sites you are allowed to automate. A task for a blocked site is refused and costs nothing: see [`ERROR_DOMAIN_BLOCKED`](https://zerocaptcha.io/docs/reference/errors#ERROR_DOMAIN_BLOCKED). ## Run the sample Put your page's `websiteURL`, `websiteKey`, action and cData in the sample, and delete the action and cData if the widget sets none. The sample's comments show where a proxy (`TurnstileTask` with `proxy`) and a `callbackUrl` go, should you want them. It reads your key from the environment variable `ZEROCAPTCHA_KEY`, the name the dashboard uses when it shows your key, so set it before you run the sample: ```sh export ZEROCAPTCHA_KEY=zc_live_… # your key, from the dashboard ``` The sample already calls this site's API, `https://api.zerocaptcha.io`; `ZEROCAPTCHA_API` points it at another one. **Python** (needs Python 3.10 or later and the requests package; save as `quickstart.py`, run `python quickstart.py`) ```python # Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token. # # Needs Python 3.10 or later and requests (pip install requests). # Put your page's details in main(), then run it with your API key in the # environment: # ZEROCAPTCHA_KEY=zc_live_... python quickstart.py # ZEROCAPTCHA_API, if set, points it at another API host. import calendar import json import os import sys import threading import time import uuid from email.utils import parsedate_to_datetime import requests API_URL = os.environ.get("ZEROCAPTCHA_API", "https://api.zerocaptcha.io") API_KEY = os.environ.get("ZEROCAPTCHA_KEY", "") POLL_SECONDS = 2 # between getTaskResult calls REQUEST_SECONDS = 15 # the longest one HTTP request may take MAX_REPLY = 1 << 20 # the most of a reply it reads, in bytes UTC = "%Y-%m-%dT%H:%M:%SZ" # how it writes times: UTC, to the second # The longest the whole run may take: DEADLINE_SECONDS = int(os.environ.get("ZEROCAPTCHA_DEADLINE_SECONDS", "180")) # This task's Idempotency-Key: the UTC time it was made, then random. Run again # with the same key and createTask returns the same task, so a lost reply never # costs a second task. The API keeps a key for 24 hours from its first # createTask, so it surely knows it until 24 hours after the time it starts with. KEY_HOURS = 24 RESUMED_KEY = os.environ.get("ZEROCAPTCHA_INTENT_KEY", "") INTENT_KEY = RESUMED_KEY or f"{time.strftime(UTC, time.gmtime())}-{uuid.uuid4()}" try: KEY_EXPIRES = calendar.timegm(time.strptime(INTENT_KEY[:20], UTC)) + KEY_HOURS * 3600 except ValueError: KEY_EXPIRES = 0 # not a key this sample made # The task's ID, once createTask has given it: from then on, it resumes the task. task_id = os.environ.get("ZEROCAPTCHA_TASK_ID", "") # The API's "try again later", like HTTP 429 and 5xx: call() sends the same # request again, as long as the deadline allows. RETRYABLE = ("ERROR_RATE_LIMIT", "ERROR_SERVICE_UNAVAILABLE", "ERROR_NO_SLOT_AVAILABLE", "ERROR_IDEMPOTENCY_KEY_IN_USE") def main(): global task_id if not API_KEY: fail("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard.") deadline = time.monotonic() + DEADLINE_SECONDS if not task_id: # createTask goes with the key only while one request still fits before # the API may forget it, and could make a second task. key_left = KEY_EXPIRES - time.time() - REQUEST_SECONDS if key_left <= 0: fail("createTask: the intent key is too old, or not from this sample: " f"the API keeps a key for {KEY_HOURS} hours, then createTask could " "start another task. Look for its task with GET " f"/v1/tasks?idempotencyKey={INTENT_KEY}, or run without " "ZEROCAPTCHA_INTENT_KEY to start a new one.") print(f"Creating the task with intent key {INTENT_KEY}, " f"valid until {time.strftime(UTC, time.gmtime(KEY_EXPIRES))}.", file=sys.stderr) task = call("createTask", { "task": { # Or "TurnstileTask", to solve through your own proxy, with "proxy" below. "type": "TurnstileTaskProxyless", "websiteURL": "https://example.com/login", # the page with the widget "websiteKey": "0x4AAAAAAA...", # the widget's data-sitekey # The widget's action and cData, which many sites check when they verify # the token: copy them from its data-action and data-cdata attributes, or # the action and cData options of turnstile.render(). Leave out any the # widget does not set. "metadata": {"action": "login", "cdata": "session-7f3a9c2e"}, # "proxy": "http://user:pass@proxy.example.net:8080", # TurnstileTask only }, # Optional: where to POST the result when the task ends, instead of polling. # "callbackUrl": "https://hooks.example.com/zerocaptcha", }, min(deadline, time.monotonic() + key_left), {"Idempotency-Key": INTENT_KEY}) if not isinstance(task.get("taskId"), str) or not task["taskId"]: unsure(f"createTask: unexpected reply: {json.dumps(task)[:200]}") task_id = task["taskId"] print(f"Waiting for task {task_id}.", file=sys.stderr) for _ in range(DEADLINE_SECONDS // POLL_SECONDS): if time.monotonic() + POLL_SECONDS > deadline: break time.sleep(POLL_SECONDS) if time.monotonic() >= deadline: # the wait itself ran late break result = call("getTaskResult", {"taskId": task_id}, deadline) if result.get("status") == "processing": continue solution = result.get("solution") token = solution.get("token") if isinstance(solution, dict) else None if result.get("status") == "ready" and isinstance(token, str) and token: print(token) return unsure(f"getTaskResult: unexpected reply: {json.dumps(result)[:200]}") unsure(f"No token within {DEADLINE_SECONDS} seconds: " f"task {task_id} is still processing.") # POSTs one call, with any extra headers, and returns its reply. It stops on # an HTTP error, a reply that isn't JSON, and errorId 1, whose errorCode and # errorDescription say what went wrong. A reply that only says to try again # later is sent again after its Retry-After, or a pause that doubles each # time, until the deadline; no request starts once the deadline has passed. def call(method, body, deadline, headers=None): pause, tries = 1, 0 failure = "no reply in time" # what went wrong last, should time run out while True: tries += 1 left = deadline - time.monotonic() if left <= 0: unsure(f"{method}: {failure}") try: status, asked, data = post(method, body, headers, min(REQUEST_SECONDS, left)) except TimeoutError: unsure(f"{method}: no reply in time") except requests.RequestException as error: unsure(f"{method}: {error}") if len(data) > MAX_REPLY: unsure(f"{method}: the reply is too long") text = data.decode("utf-8", "replace") failure = f"HTTP {status}: {text[:200]}" if status == 200: try: reply = json.loads(text) except ValueError: unsure(f"{method}: the reply is not JSON: {text[:200]}") if not isinstance(reply, dict) or "errorId" not in reply: unsure(f"{method}: unexpected reply: {text[:200]}") if reply["errorId"] == 0: return reply code, description = reply.get("errorCode"), reply.get("errorDescription") failure = f"{code}: {description}" if code not in RETRYABLE: # The API's own "no": a failed task's reply says how it ended, # and a refused create made no task, unless an earlier # createTask with this key, in this run or one before, went # through unanswered. Any other refused poll leaves how the task # ended unknown. if "status" in reply or (method == "createTask" and tries == 1 and not RESUMED_KEY): fail(f"{method}: {failure}") unsure(f"{method}: {failure}") elif status != 429 and status < 500: unsure(f"{method}: {failure}") # Only "try again later" is left: wait as the reply asks, or pause. wait = retry_after(asked) if wait <= 0: wait = pause if time.monotonic() + wait >= deadline: unsure(f"{method}: {failure}") time.sleep(wait) pause = min(pause * 2, 16) # POSTs once, and returns the reply's status, its Retry-After and up to # MAX_REPLY + 1 bytes of its body. requests's own timeout limits only each # wait for more bytes, so a reply that keeps trickling in could outlast any # deadline: here a thread makes the request, and after `seconds` it is left # behind with TimeoutError. def post(method, body, headers, seconds): outcome = [] def request(): try: with requests.post(f"{API_URL}/{method}", json={"clientKey": API_KEY, **body}, headers=headers, timeout=seconds + 1, stream=True) as response: data = b"" for chunk in response.iter_content(64 * 1024): data += chunk if len(data) > MAX_REPLY: break outcome.append((response.status_code, response.headers.get("Retry-After", ""), data)) except Exception as error: # raised again below, in the caller's thread outcome.append(error) thread = threading.Thread(target=request, daemon=True) thread.start() thread.join(seconds) if not outcome: raise TimeoutError if isinstance(outcome[0], Exception): raise outcome[0] return outcome[0] # The seconds a Retry-After asks to wait: its number of seconds, or the time # until its HTTP date. 0 for anything else. def retry_after(value): if value.isdecimal(): return float(value) try: return parsedate_to_datetime(value).timestamp() - time.time() except (TypeError, ValueError): return 0 def fail(message): sys.exit(message) # Stops when how the task ended is unknown: it may exist, and running again as # the last line says picks it up instead of starting another. That is by its # ID once createTask has given it, and before that by the intent key, while # the API surely still keeps it. def unsure(message): if task_id: fail(f"{message}\nRun again with ZEROCAPTCHA_TASK_ID={task_id} to keep " "waiting for this task instead of starting another.") fail(f"{message}\nRun again with ZEROCAPTCHA_INTENT_KEY={INTENT_KEY} before " f"{time.strftime(UTC, time.gmtime(KEY_EXPIRES))} to resume this task " "instead of starting another: the API keeps an intent key for " f"{KEY_HOURS} hours.") if __name__ == "__main__": main() ``` **Node** (needs Node.js 22 or later; save as `quickstart.mjs`, run `node quickstart.mjs`) ```js // Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token. // // Needs Node.js 22 or later. Put your page's details in solve(), then run it // with your API key in the environment: // ZEROCAPTCHA_KEY=zc_live_... node quickstart.mjs // ZEROCAPTCHA_API, if set, points it at another API host. import { setTimeout as sleep } from "node:timers/promises"; const API_URL = process.env.ZEROCAPTCHA_API || "https://api.zerocaptcha.io"; const API_KEY = process.env.ZEROCAPTCHA_KEY || ""; const POLL_SECONDS = 2; // between getTaskResult calls const REQUEST_SECONDS = 15; // the longest one HTTP request may take const MAX_REPLY = 1 << 20; // the most of a reply it reads, in bytes // The longest the whole run may take: const DEADLINE_SECONDS = Number(process.env.ZEROCAPTCHA_DEADLINE_SECONDS ?? 180); // This task's Idempotency-Key: the UTC time it was made, then random. Run again // with the same key and createTask returns the same task, so a lost reply never // costs a second task. The API keeps a key for 24 hours from its first // createTask, so it surely knows it until 24 hours after the time it starts with. const KEY_HOURS = 24; const RESUMED_KEY = process.env.ZEROCAPTCHA_INTENT_KEY || ""; const INTENT_KEY = RESUMED_KEY || `${utc(Date.now())}-${crypto.randomUUID()}`; const KEY_EXPIRES = Date.parse(INTENT_KEY.slice(0, 20)) + KEY_HOURS * 3_600_000; // NaN if not ours // The task's ID, once createTask has given it: from then on, it resumes the task. let taskId = process.env.ZEROCAPTCHA_TASK_ID || ""; // The API's "try again later", like HTTP 429 and 5xx: call() sends the same // request again, as long as the deadline allows. const RETRYABLE = new Set([ "ERROR_RATE_LIMIT", "ERROR_SERVICE_UNAVAILABLE", "ERROR_NO_SLOT_AVAILABLE", "ERROR_IDEMPOTENCY_KEY_IN_USE", ]); // The API's own "no": a refused createTask or a failed task, which running // again cannot change. class Refused extends Error {} try { console.log(await solve()); } catch (error) { console.error(error.message); if (!(error instanceof Refused)) console.error(resume()); process.exitCode = 1; } async function solve() { if (!API_KEY) throw new Refused("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard."); const deadline = performance.now() + DEADLINE_SECONDS * 1000; if (!taskId) { // createTask goes with the key only while one request still fits before // the API may forget it, and could make a second task. const keyLeft = KEY_EXPIRES - Date.now() - REQUEST_SECONDS * 1000; if (!(keyLeft > 0)) { throw new Refused( "createTask: the intent key is too old, or not from this sample: the API " + `keeps a key for ${KEY_HOURS} hours, then createTask could start another task. ` + `Look for its task with GET /v1/tasks?idempotencyKey=${INTENT_KEY}, or run ` + "without ZEROCAPTCHA_INTENT_KEY to start a new one.", ); } console.error( `Creating the task with intent key ${INTENT_KEY}, valid until ${utc(KEY_EXPIRES)}.`, ); const task = await call( "createTask", { task: { // Or "TurnstileTask", to solve through your own proxy, with `proxy` below. type: "TurnstileTaskProxyless", websiteURL: "https://example.com/login", // the page with the widget websiteKey: "0x4AAAAAAA...", // the widget's data-sitekey // The widget's action and cData, which many sites check when they verify the token: // copy them from its data-action and data-cdata attributes, or the action and cData // options of turnstile.render(). Leave out any the widget does not set. metadata: { action: "login", cdata: "session-7f3a9c2e" }, // proxy: "http://user:pass@proxy.example.net:8080", // TurnstileTask only }, // Optional: where to POST the result when the task ends, instead of polling. // callbackUrl: "https://hooks.example.com/zerocaptcha", }, Math.min(deadline, performance.now() + keyLeft), { "idempotency-key": INTENT_KEY }, ); if (typeof task.taskId !== "string" || task.taskId === "") { throw new Error(`createTask: unexpected reply: ${JSON.stringify(task)}`); } taskId = task.taskId; } console.error(`Waiting for task ${taskId}.`); for (let poll = 0; poll < Math.floor(DEADLINE_SECONDS / POLL_SECONDS); poll++) { if (performance.now() + POLL_SECONDS * 1000 > deadline) break; await sleep(POLL_SECONDS * 1000); if (performance.now() >= deadline) break; // the wait itself ran late const result = await call("getTaskResult", { taskId }, deadline); if (result.status === "processing") continue; const token = result.solution?.token; if (result.status === "ready" && typeof token === "string" && token) { return token; } throw new Error(`getTaskResult: unexpected reply: ${JSON.stringify(result)}`); } throw new Error( `No token within ${DEADLINE_SECONDS} seconds: task ${taskId} is still processing.`, ); } // POSTs one call, with any extra headers, and returns its reply. It throws on // an HTTP error, a reply that isn't JSON, and errorId 1, whose errorCode and // errorDescription say what went wrong. A reply that only says to try again // later is sent again after its Retry-After, or a pause that doubles each // time, until the deadline; no request starts once the deadline has passed. async function call(method, body, deadline, headers = {}) { let failure = "no reply in time"; // what went wrong last, should time run out for (let tries = 1, pause = 1; ; tries++, pause = Math.min(pause * 2, 16)) { const left = Math.floor(deadline - performance.now()); if (left <= 0) throw new Error(`${method}: ${failure}`); let response; let text; try { response = await fetch(`${API_URL}/${method}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify({ clientKey: API_KEY, ...body }), signal: AbortSignal.timeout(Math.min(REQUEST_SECONDS * 1000, left)), }); // The reply, but never more of it than a reply of the API could be. const chunks = []; let size = 0; for await (const chunk of response.body ?? []) { size += chunk.length; if (size > MAX_REPLY) throw new Error("the reply is too long"); chunks.push(chunk); } text = Buffer.concat(chunks).toString(); } catch (error) { const reason = error.name === "TimeoutError" ? "no reply in time" : (error.cause?.message ?? error.message); throw new Error(`${method}: ${reason}`, { cause: error }); } failure = `HTTP ${response.status}: ${text.slice(0, 200)}`; if (response.status === 200) { let reply; try { reply = JSON.parse(text); } catch { throw new Error(`${method}: the reply is not JSON: ${text.slice(0, 200)}`); } if (typeof reply !== "object" || reply === null || !("errorId" in reply)) { throw new Error(`${method}: unexpected reply: ${text.slice(0, 200)}`); } if (reply.errorId === 0) return reply; failure = `${reply.errorCode}: ${reply.errorDescription}`; if (!RETRYABLE.has(reply.errorCode)) { // A failed task's reply says how it ended, and a refused create made // no task, unless an earlier createTask with this key, in this run or // one before, went through unanswered. Any other refused poll leaves // how the task ended unknown. if ("status" in reply || (method === "createTask" && tries === 1 && !RESUMED_KEY)) { throw new Refused(`${method}: ${failure}`); } throw new Error(`${method}: ${failure}`); } } else if (response.status !== 429 && response.status < 500) { throw new Error(`${method}: ${failure}`); } // Only "try again later" is left: wait as the reply asks, or pause. const asked = retryAfter(response.headers.get("retry-after") ?? ""); const wait = asked > 0 ? asked : pause * 1000; if (performance.now() + wait >= deadline) throw new Error(`${method}: ${failure}`); await sleep(wait); } } // The wait a Retry-After asks for, in milliseconds: its number of seconds, or // the time until its HTTP date. NaN for anything else. function retryAfter(value) { return /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now(); } // How to pick the task up again: by its ID once createTask has given it, and // before that by the intent key, while the API surely still keeps it. function resume() { if (taskId) { return `Run again with ZEROCAPTCHA_TASK_ID=${taskId} to keep waiting for this task instead of starting another.`; } return ( `Run again with ZEROCAPTCHA_INTENT_KEY=${INTENT_KEY} before ${utc(KEY_EXPIRES)} to resume ` + `this task instead of starting another: the API keeps an intent key for ${KEY_HOURS} hours.` ); } // A time as this sample prints it: UTC, to the second. function utc(ms) { return `${new Date(ms).toISOString().slice(0, 19)}Z`; } ``` **Go** (needs Go 1.24 or later; save as `quickstart.go`, run `go run quickstart.go`) ```go // Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token. // // Needs Go 1.24 or later. Put your page's details in solve(), then run it with // your API key in the environment: // // ZEROCAPTCHA_KEY=zc_live_... go run quickstart.go // // ZEROCAPTCHA_API, if set, points it at another API host. package main import ( "bytes" "cmp" "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math" "net/http" "os" "strconv" "time" ) var ( apiURL = cmp.Or(os.Getenv("ZEROCAPTCHA_API"), "https://api.zerocaptcha.io") apiKey = os.Getenv("ZEROCAPTCHA_KEY") pollEvery = 2 * time.Second // between getTaskResult calls requestTimeout = 15 * time.Second // the longest one HTTP request may take maxReply = 1 << 20 // the most of a reply it reads, in bytes // The longest the whole run may take: deadline = envSeconds("ZEROCAPTCHA_DEADLINE_SECONDS", 180) // This task's Idempotency-Key: the UTC time it was made, then random. Run // again with the same key and createTask returns the same task, so a lost // reply never costs a second task. The API keeps a key for 24 hours from // its first createTask, so it surely knows it until 24 hours after the time // it starts with. keyLife = 24 * time.Hour resumedKey = os.Getenv("ZEROCAPTCHA_INTENT_KEY") intentKey = cmp.Or(resumedKey, time.Now().UTC().Format(time.RFC3339)+"-"+rand.Text()) keyExpires = keyTime(intentKey).Add(keyLife) // The task's ID, once createTask has given it: from then on, it resumes the task. taskID = os.Getenv("ZEROCAPTCHA_TASK_ID") // The API's "try again later", like HTTP 429 and 5xx: call sends the same // request again, as long as the deadline allows. retryable = map[string]bool{ "ERROR_RATE_LIMIT": true, "ERROR_SERVICE_UNAVAILABLE": true, "ERROR_NO_SLOT_AVAILABLE": true, "ERROR_IDEMPOTENCY_KEY_IN_USE": true, } ) // refused is the API's own "no": a refused createTask or a failed task, which // running again cannot change. type refused struct{ error } // busy is a reply that only says to try again later, after wait if it said // how long. type busy struct { error wait time.Duration } func main() { token, err := solve() if err != nil { fmt.Fprintln(os.Stderr, err) if !errors.As(err, new(refused)) { fmt.Fprintln(os.Stderr, resume()) } os.Exit(1) } fmt.Println(token) } func solve() (string, error) { if apiKey == "" { return "", refused{errors.New("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard.")} } ctx, cancel := context.WithTimeout(context.Background(), deadline) defer cancel() if taskID == "" { // createTask goes with the key only while one request still fits // before the API may forget it, and could make a second task. last := keyExpires.Add(-requestTimeout) if !time.Now().Before(last) { return "", refused{fmt.Errorf("createTask: the intent key is too old, or not "+ "from this sample: the API keeps a key for %g hours, then createTask could "+ "start another task. Look for its task with GET /v1/tasks?idempotencyKey=%s, "+ "or run without ZEROCAPTCHA_INTENT_KEY to start a new one.", keyLife.Hours(), intentKey)} } fmt.Fprintf(os.Stderr, "Creating the task with intent key %s, valid until %s.\n", intentKey, keyExpires.Format(time.RFC3339)) create, cancelCreate := context.WithDeadline(ctx, last) defer cancelCreate() var task struct { TaskID string `json:"taskId"` } err := call(create, "createTask", map[string]any{ "task": map[string]any{ // Or "TurnstileTask", to solve through your own proxy, with "proxy" below. "type": "TurnstileTaskProxyless", "websiteURL": "https://example.com/login", // the page with the widget "websiteKey": "0x4AAAAAAA...", // the widget's data-sitekey // The widget's action and cData, which many sites check when they verify the // token: copy them from its data-action and data-cdata attributes, or the action // and cData options of turnstile.render(). Leave out any the widget does not set. "metadata": map[string]string{"action": "login", "cdata": "session-7f3a9c2e"}, // "proxy": "http://user:pass@proxy.example.net:8080", // TurnstileTask only }, // Optional: where to POST the result when the task ends, instead of polling. // "callbackUrl": "https://hooks.example.com/zerocaptcha", }, &task) if err != nil { return "", err } if task.TaskID == "" { return "", errors.New("createTask: unexpected reply: no taskId") } taskID = task.TaskID } fmt.Fprintf(os.Stderr, "Waiting for task %s.\n", taskID) end, _ := ctx.Deadline() for range int(deadline / pollEvery) { if time.Until(end) < pollEvery { break } time.Sleep(pollEvery) if ctx.Err() != nil { // the wait itself ran late break } var result struct { Status string `json:"status"` Solution struct { Token string `json:"token"` } `json:"solution"` } poll := map[string]any{"taskId": taskID} if err := call(ctx, "getTaskResult", poll, &result); err != nil { return "", err } if result.Status == "processing" { continue } if result.Status == "ready" && result.Solution.Token != "" { return result.Solution.Token, nil } return "", fmt.Errorf("getTaskResult: unexpected reply: status %q, no token", result.Status) } return "", fmt.Errorf("no token within %g seconds: task %s is still processing", deadline.Seconds(), taskID) } // call POSTs one call and decodes its reply into out. It fails on an HTTP // error, a reply that isn't JSON, and errorId 1, whose errorCode and // errorDescription say what went wrong. A reply that only says to try again // later is sent again after its Retry-After, or a pause that doubles each // time, until the deadline; once ctx is done, no request starts. func call(ctx context.Context, method string, body map[string]any, out any) error { body["clientKey"] = apiKey payload, err := json.Marshal(body) if err != nil { return err } end, _ := ctx.Deadline() for tries, pause := 1, time.Second; ; tries, pause = tries+1, min(2*pause, 16*time.Second) { err = send(ctx, method, payload, out) // A refused create made no task, unless an earlier createTask with this // key, in this run or one before, went through unanswered. var no refused if errors.As(err, &no) && method == "createTask" && (tries > 1 || resumedKey != "") { return no.error } var later busy if !errors.As(err, &later) { return err } // Only "try again later" is left: wait as the reply asks, or pause. wait := later.wait if wait <= 0 { wait = pause } if time.Until(end) <= wait { return later.error } time.Sleep(wait) if ctx.Err() != nil { // the wait itself ran late return later.error } } } // send makes one attempt at call. func send(ctx context.Context, method string, payload []byte, out any) error { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() url := apiURL + "/" + method req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if method == "createTask" { req.Header.Set("Idempotency-Key", intentKey) } res, err := http.DefaultClient.Do(req) if err != nil { return failure(method, err) } defer res.Body.Close() // The reply, but never more of it than a reply of the API could be. text, err := io.ReadAll(io.LimitReader(res.Body, int64(maxReply)+1)) if err != nil { return failure(method, err) } if len(text) > maxReply { return fmt.Errorf("%s: the reply is too long", method) } wait := retryAfter(res.Header.Get("Retry-After")) if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 { return busy{fmt.Errorf("%s: HTTP %d: %.200s", method, res.StatusCode, text), wait} } if res.StatusCode != http.StatusOK { return fmt.Errorf("%s: HTTP %d: %.200s", method, res.StatusCode, text) } if !json.Valid(text) { return fmt.Errorf("%s: the reply is not JSON: %.200s", method, text) } var reply struct { ErrorID *int `json:"errorId"` ErrorCode string `json:"errorCode"` ErrorDescription string `json:"errorDescription"` Status *string `json:"status"` } if json.Unmarshal(text, &reply) != nil || reply.ErrorID == nil { return fmt.Errorf("%s: unexpected reply: %.200s", method, text) } if *reply.ErrorID != 0 { err := fmt.Errorf("%s: %s: %s", method, reply.ErrorCode, reply.ErrorDescription) switch { case retryable[reply.ErrorCode]: return busy{err, wait} // A refused create made no task, as call checks, and a failed task's // reply says how it ended. Any other refused poll leaves that unknown. case method == "createTask" || reply.Status != nil: return refused{err} } return err } if json.Unmarshal(text, out) != nil { return fmt.Errorf("%s: unexpected reply: %.200s", method, text) } return nil } // retryAfter is the wait a Retry-After asks for: its number of seconds, or the // time until its HTTP date. It is 0 for anything else. func retryAfter(value string) time.Duration { seconds, err := strconv.ParseUint(value, 10, 64) if err == nil || errors.Is(err, strconv.ErrRange) { // At most what a time.Duration holds, some 292 years: past any deadline. return time.Duration(min(seconds, math.MaxInt64/uint64(time.Second))) * time.Second } if date, err := http.ParseTime(value); err == nil { return time.Until(date) } return 0 } // failure explains a request that got no usable reply. func failure(method string, err error) error { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%s: no reply in time", method) } return fmt.Errorf("%s: %w", method, err) } // resume says how to pick the task up again: by its ID once createTask has // given it, and before that by the intent key, while the API surely still // keeps it. func resume() string { if taskID != "" { return fmt.Sprintf("Run again with ZEROCAPTCHA_TASK_ID=%s to keep waiting for this "+ "task instead of starting another.", taskID) } return fmt.Sprintf("Run again with ZEROCAPTCHA_INTENT_KEY=%s before %s to resume this "+ "task instead of starting another: the API keeps an intent key for %g hours.", intentKey, keyExpires.Format(time.RFC3339), keyLife.Hours()) } // keyTime is the time a key this sample made starts with, or the zero time, // long past, for any other key. func keyTime(key string) time.Time { made, _ := time.Parse(time.RFC3339, key[:min(len(key), 20)]) return made } // envSeconds reads whole seconds from the environment, or uses fallback. func envSeconds(name string, fallback int) time.Duration { seconds, err := strconv.Atoi(os.Getenv(name)) if err != nil { seconds = fallback } return time.Duration(seconds) * time.Second } ``` **cURL** (needs bash, curl and jq; save as `quickstart.sh`, run `bash quickstart.sh`) ```bash #!/usr/bin/env bash # Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token. # # Needs bash, curl and jq. Put your page's details in main, then run it with # your API key in the environment: # ZEROCAPTCHA_KEY=zc_live_... bash quickstart.sh # ZEROCAPTCHA_API, if set, points it at another API host. set -euo pipefail API_URL="${ZEROCAPTCHA_API:-https://api.zerocaptcha.io}" API_KEY="${ZEROCAPTCHA_KEY:-}" POLL_SECONDS=2 # between getTaskResult calls REQUEST_SECONDS=15 # the longest one HTTP request may take MAX_REPLY=1048576 # the most of a reply it reads, in bytes # The longest the whole run may take: DEADLINE_SECONDS="${ZEROCAPTCHA_DEADLINE_SECONDS:-180}" # This task's Idempotency-Key: the UTC time it was made, then random. Run again # with the same key and createTask returns the same task, so a lost reply never # costs a second task. The API keeps a key for 24 hours from its first # createTask, so it surely knows it until 24 hours after the time it starts with. KEY_HOURS=24 RESUMED_KEY="${ZEROCAPTCHA_INTENT_KEY:-}" INTENT_KEY="${RESUMED_KEY:-$(date -u +%Y-%m-%dT%H:%M:%SZ)-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')}" # That time, in Unix seconds; 0 for a key this sample did not make. KEY_EXPIRES=$(jq -rn --arg key "$INTENT_KEY" --argjson hours "$KEY_HOURS" \ '$key[:20] | fromdate + $hours * 3600' 2>/dev/null) || KEY_EXPIRES=0 # The task's ID, once createTask has given it: from then on, it resumes the task. task_id="${ZEROCAPTCHA_TASK_ID:-}" # The API's "try again later", like HTTP 429 and 5xx: call sends the same # request again, as long as the deadline allows. RETRYABLE='["ERROR_RATE_LIMIT", "ERROR_SERVICE_UNAVAILABLE", "ERROR_NO_SLOT_AVAILABLE", "ERROR_IDEMPOTENCY_KEY_IN_USE"]' # Where curl writes each reply's headers, for its Retry-After. HEADERS=$(mktemp) trap 'rm -f "$HEADERS"' EXIT main() { [ -n "$API_KEY" ] || fail "Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard." deadline=$((SECONDS + DEADLINE_SECONDS)) if [ -z "$task_id" ]; then # createTask goes with the key only while one request still fits before # the API may forget it, and could make a second task. Less 2 seconds, as # both date and $SECONDS count whole ones. key_left=$((KEY_EXPIRES - $(date +%s) - REQUEST_SECONDS - 2)) if ((key_left <= 0)); then fail "createTask: the intent key is too old, or not from this sample: the API keeps a key" \ "for $KEY_HOURS hours, then createTask could start another task. Look for its task with" \ "GET /v1/tasks?idempotencyKey=$INTENT_KEY, or run without ZEROCAPTCHA_INTENT_KEY to" \ "start a new one." fi echo "Creating the task with intent key $INTENT_KEY, valid until $(utc "$KEY_EXPIRES")." >&2 create_by=$((SECONDS + key_left < deadline ? SECONDS + key_left : deadline)) # websiteURL is the page with the widget, websiteKey its data-sitekey. The # widget's action and cData, which many sites check when they verify the # token, go in metadata: copy them from its data-action and data-cdata # attributes, or the action and cData options of turnstile.render(), and # leave out any the widget does not set. To solve through your own proxy, # make the type TurnstileTask and add # "proxy": "http://user:pass@proxy.example.net:8080" # to the task; to be called when it ends instead of polling, add # "callbackUrl": "https://hooks.example.com/zerocaptcha" # beside it. task=$(call createTask "$create_by" '{ "task": { "type": "TurnstileTaskProxyless", "websiteURL": "https://example.com/login", "websiteKey": "0x4AAAAAAA...", "metadata": {"action": "login", "cdata": "session-7f3a9c2e"} } }' "idempotency-key: $INTENT_KEY") task_id=$(jq -er '.taskId | select(type == "string" and . != "")' <<<"$task") || unsure "createTask: unexpected reply: ${task:0:200}" fi echo "Waiting for task $task_id." >&2 for ((poll = 0; poll < DEADLINE_SECONDS / POLL_SECONDS; poll++)); do # $SECONDS counts whole seconds, so the wait must end a second early by it. ((SECONDS + POLL_SECONDS < deadline)) || break sleep "$POLL_SECONDS" ((SECONDS < deadline)) || break result=$(call getTaskResult "$deadline" "$(jq -n --arg id "$task_id" '{taskId: $id}')") status=$(jq -r '.status' <<<"$result") [ "$status" = processing ] && continue if [ "$status" = ready ] && jq -er '.solution.token | select(type == "string" and . != "")' <<<"$result" then return fi unsure "getTaskResult: unexpected reply: ${result:0:200}" done unsure "No token within $DEADLINE_SECONDS seconds: task $task_id is still processing." } # POSTs one call by its deadline, in $SECONDS, with any extra header, and # prints its reply. It stops on an HTTP error, a reply that isn't JSON, and # errorId 1, whose errorCode and errorDescription say what went wrong. A reply # that only says to try again later is sent again after its Retry-After, or a # pause that doubles each time, until the deadline; no request starts once the # deadline has passed. call() { local pause=1 tries=0 failure="no reply in time" timeout reply code body wait while true; do tries=$((tries + 1)) timeout=$(($2 - SECONDS)) ((timeout > 0)) || unsure "$1: $failure" ((timeout < REQUEST_SECONDS)) || timeout=$REQUEST_SECONDS reply=$(curl --silent --show-error --max-time "$timeout" --max-filesize "$MAX_REPLY" \ --dump-header "$HEADERS" --write-out '\n%{http_code}' \ --header 'content-type: application/json' ${4:+--header "$4"} \ --data "$(jq -c --arg key "$API_KEY" '. + {clientKey: $key}' <<<"$3")" \ "$API_URL/$1") || case $? in 28) unsure "$1: no reply in time" ;; 63) unsure "$1: the reply is too long" ;; *) unsure "$1: the request failed" ;; esac code=${reply##*$'\n'} body=${reply%$'\n'*} failure="HTTP $code: ${body:0:200}" if [ "$code" = 200 ]; then jq empty <<<"$body" 2>/dev/null || unsure "$1: the reply is not JSON: ${body:0:200}" jq -e 'type == "object" and has("errorId")' <<<"$body" >/dev/null || unsure "$1: unexpected reply: ${body:0:200}" if jq -e '.errorId == 0' <<<"$body" >/dev/null; then printf '%s\n' "$body" return fi failure=$(jq -r '"\(.errorCode): \(.errorDescription)"' <<<"$body") if ! jq -e --argjson codes "$RETRYABLE" '.errorCode | IN($codes[])' <<<"$body" >/dev/null then # The API's own "no": a failed task's reply says how it ended, and a # refused create made no task, unless an earlier createTask with this # key, in this run or one before, went through unanswered. Any other # refused poll leaves how the task ended unknown. if jq -e 'has("status")' <<<"$body" >/dev/null; then fail "$1: $failure"; fi if [ "$1" = createTask ] && ((tries == 1)) && [ -z "$RESUMED_KEY" ]; then fail "$1: $failure" fi unsure "$1: $failure" fi elif [[ ! $code =~ ^(429|5[0-9][0-9])$ ]]; then unsure "$1: $failure" fi # Only "try again later" is left: wait as the reply asks, or pause. wait=$(retry_after "$(tr -d '\r' <"$HEADERS" | awk 'tolower($0) ~ /^retry-after:/ { sub(/^[^:]*:[ \t]*/, ""); sub(/[ \t]+$/, ""); value = $0 } END { print value }')") || wait=0 ((wait > 0)) || wait=$pause ((SECONDS + wait < $2)) || unsure "$1: $failure" sleep "$wait" pause=$((pause < 8 ? pause * 2 : 16)) done } # The whole seconds a Retry-After asks to wait: its number of seconds, cut to # 999999999, some 31 years, longer than any deadline; or the time until its # HTTP date, like "Wed, 30 Sep 2026 09:36:00 GMT". Nothing for anything else. retry_after() { local months=JanFebMarAprMayJunJulAugSepOctNovDec before date if [[ $1 =~ ^0*([0-9]{1,9})$ ]]; then echo "$((10#${BASH_REMATCH[1]}))" elif [[ $1 =~ ^[0-9]+$ ]]; then echo 999999999 elif [[ $1 =~ ^[A-Z][a-z]{2},\ ([0-9]{2})\ ([A-Z][a-z]{2})\ ([0-9]{4})\ ([0-9:]{8})\ GMT$ ]]; then # The same time in ISO 8601, which jq reads on every platform. before=${months%%"${BASH_REMATCH[2]}"*} printf -v date '%s-%02d-%sT%sZ' "${BASH_REMATCH[3]}" $((${#before} / 3 + 1)) \ "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" jq -n --arg date "$date" '$date | fromdate - now | ceil' 2>/dev/null fi } # A time in Unix seconds as this sample prints it: UTC, to the second. utc() { jq -rn --argjson seconds "$1" '$seconds | todate' } fail() { echo "$*" >&2 exit 1 } # Stops when how the task ended is unknown: it may exist, and running again as # the last line says picks it up instead of starting another. That is by its # ID once createTask has given it, and before that by the intent key, while # the API surely still keeps it. unsure() { if [ -n "$task_id" ]; then fail "$1 Run again with ZEROCAPTCHA_TASK_ID=$task_id to keep waiting for this task instead of starting another." fi fail "$1 Run again with ZEROCAPTCHA_INTENT_KEY=$INTENT_KEY before $(utc "$KEY_EXPIRES") to resume this task\ instead of starting another: the API keeps an intent key for $KEY_HOURS hours." } main ``` On success the sample prints the token and exits with status 0. On any failure it prints a line that says what went wrong, and exits with status 1. When it cannot tell how its task ended, a second line says how to pick that task up again: see [If a reply is lost](#if-a-reply-is-lost). The token is all it prints to standard output. On standard error it notes, as it goes, what you need to pick the task up should the run be cut short, even by a crash: ```text Creating the task with intent key 2026-09-29T10:00:00Z-4f6d0c1e-…, valid until 2026-09-30T10:00:00Z. Waiting for task 0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b. ``` ## What the sample does 1. **Creates a task.** First it makes an intent key, from the time in UTC and random characters, and prints it. It sends the task, with the widget's action and cData in its `metadata`, to `createTask`, with the key as the `Idempotency-Key` header. `createTask` holds the task's price on your balance and answers at once with a `taskId`, which the sample prints too. 2. **Polls for the result.** Every 2 seconds it asks `getTaskResult` about the task. One request may take at most 15 seconds, and the whole run stops after 180 seconds, so it never waits forever: no request starts once that time is up, and a reply still arriving then is dropped, however steadily its bytes come. 3. **Prints the token.** A ready reply carries the token. Use it straight away: a Turnstile token works once, for 300 seconds. Every reply is checked twice: its HTTP status, then its `errorId`. A reply that is not JSON, not in the expected shape, or longer than 1 MiB stops the sample instead of starting another poll. Some replies only say to try again later. These are HTTP 429 and any 5xx, and `errorId` 1 with [`ERROR_RATE_LIMIT`](https://zerocaptcha.io/docs/reference/errors#ERROR_RATE_LIMIT), [`ERROR_SERVICE_UNAVAILABLE`](https://zerocaptcha.io/docs/reference/errors#ERROR_SERVICE_UNAVAILABLE), [`ERROR_NO_SLOT_AVAILABLE`](https://zerocaptcha.io/docs/reference/errors#ERROR_NO_SLOT_AVAILABLE) or [`ERROR_IDEMPOTENCY_KEY_IN_USE`](https://zerocaptcha.io/docs/reference/errors#ERROR_IDEMPOTENCY_KEY_IN_USE). The sample sends the same request again, with the same intent key. It first waits as the reply's `Retry-After` header asks, a number of seconds or until an HTTP date, or else a pause that doubles each time, from 1 second up to 16. When that wait would outlast the deadline, it stops at once and says how to pick the task up. Besides the key, four environment variables change the settings without editing the file: `ZEROCAPTCHA_API` points the sample at another API host, `ZEROCAPTCHA_DEADLINE_SECONDS` sets how long the whole run may take, in whole seconds, and `ZEROCAPTCHA_INTENT_KEY` or `ZEROCAPTCHA_TASK_ID` picks up the task of an earlier run. A ready reply from `getTaskResult` has these fields: | Field | Meaning | | --- | --- | | `status` | `processing` until the task finishes, then `ready` | | `solution.token` | The Turnstile token | | `expiresAt` | When the token expires, as an ISO 8601 time in UTC | | `cost` | What the task cost in US dollars, as a string with six decimals | | `createTime`, `endTime` | When the task was created and when it finished, in Unix seconds | | `solveCount` | How many attempts the solve took | ## If a reply is lost A reply can be lost after the API has acted on the request: the connection drops, or the deadline passes first. Running the sample again with a new key would then create a second task, charged too if it is solved. So the sample makes its intent key before the first request, prints it, and sends it with `createTask` as the `Idempotency-Key` header. For 24 hours from the first `createTask`, the same key and the same request get the first reply, with the same `taskId`, instead of creating another task. When a run stops without knowing how its task ended, its last line says how to pick the task up. Until `createTask` has answered, that is by the key: ```text createTask: no reply in time Run again with ZEROCAPTCHA_INTENT_KEY=2026-09-29T10:00:00Z-4f6d0c1e-… before 2026-09-30T10:00:00Z to resume this task instead of starting another: the API keeps an intent key for 24 hours. ``` Run the same sample again with that variable set, for example `ZEROCAPTCHA_INTENT_KEY=2026-09-29T10:00:00Z-4f6d0c1e-… python quickstart.py`. If the first `createTask` got through, the sample gets that task back and waits for it; if it did not, the task is created now, once. The key names this one attempt and nothing else: it is not a secret, and the sample never prints your API key. The key starts with the time it was made, just before the first `createTask`, so the API surely still keeps it until 24 hours after that time: the time the last line gives. From 15 seconds before then, time enough for one last request, the sample refuses the key instead of sending `createTask`, which could start a second task. Look the task up by its key, as below, and run the sample without the variable only if there is none. Once `createTask` has answered, the last line names the task instead. Running again with it only polls that task, and never creates one, however much later you run it: ```text getTaskResult: no reply in time Run again with ZEROCAPTCHA_TASK_ID=0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b to keep waiting for this task instead of starting another. ``` A `createTask` refused after a retry, or after a run with the key before, still names the key: an earlier attempt may have created the task and lost its reply. If running again is refused the same way, look the task up. Reuse a key only to retry exactly the same task. After a task fails, or once you have its token, run the sample without either variable, so the next task gets a key of its own. The same key with a different request is refused with [`ERROR_IDEMPOTENCY_KEY_REUSED`](https://zerocaptcha.io/docs/reference/errors#ERROR_IDEMPOTENCY_KEY_REUSED). To look the task up without sending `createTask` again, list your tasks through the REST API with the key as a filter: `GET /v1/tasks?idempotencyKey=…` on the same API host, with the header `Authorization: Bearer $ZEROCAPTCHA_KEY`. The list holds the task that key created, with its `id` and `status`, or nothing if the first request never arrived. ## When something goes wrong The sample's last line says what happened. The common ones: - **`Set ZEROCAPTCHA_KEY to your API key, …`** The variable is empty or not set in this shell. Export it as above, then run the sample again. Nothing was sent. - **`createTask: ERROR_KEY_DOES_NOT_EXIST: …`** The key is wrong or incomplete. Copy it again from the dashboard. - **`createTask: ERROR_ZERO_BALANCE: …`** Your balance cannot cover the task. Add funds, then run the sample again. - **`getTaskResult: ERROR_CAPTCHA_UNSOLVABLE: …`** or **`ERROR_TASK_TIMEOUT`** The task failed, and nothing was charged. Check the `websiteURL` and `websiteKey`, then run the sample again. - **`createTask: ERROR_INVALID_TASK_DATA: …`** A field is out of bounds, such as an action longer than 32 characters or one with characters other than letters, digits, `_` and `-`; the description names it. Nothing was held. - **The sample prints a token, but the site refuses it.** Compare the action and cData you sent with the ones in the live page: a site that checks them refuses a token solved with other values, or without them. See [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata). - **`…: HTTP 503: …`**, or another status. The request failed before the API could answer in its own format. The sample already retried a 429 or a 5xx until its deadline: run it again later as its last line says. For a 4xx, check the API host. - **`…: ERROR_RATE_LIMIT: …`** or **`ERROR_SERVICE_UNAVAILABLE`** The API kept asking the sample to try again later until its deadline passed, or asked it to wait longer than that. Your task may still be running: run the sample again as its last line says to pick it up. A rate limit that lasts this long means other calls on the same key or account are using up its budget. - **`…: the reply is not JSON`**, **`…: unexpected reply`** or **`…: the reply is too long`** Something other than the API answered, such as a proxy. Check the API host and any proxy between you and it, then run the sample again as its last line says. - **`…: no reply in time`** A request took longer than 15 seconds, or ran past the deadline. Check your connection, then run the sample again as its last line says: if `createTask` got through, you get that task back instead of paying for a second one. - **`… is still processing`** The task had not finished when the sample stopped waiting. Nothing is charged unless it succeeds. Run the sample again with the task ID it names to keep waiting for the same task; if it succeeds, it is charged once, and `getTaskResult` returns its token while the token is valid. - **`createTask: the intent key is too old, or not from this sample: …`** The key is 24 hours old, or nearly, or was not made by this sample, so the API may no longer know it. Look the task up by its key, as the line says, and run the sample without `ZEROCAPTCHA_INTENT_KEY` only if there is none. Every other code, with whether a retry helps and what it costs, is in the [errors reference](https://zerocaptcha.io/docs/reference/errors). ## Next steps - [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata): when a task needs them, where to find them and what happens without them. - [API reference](https://zerocaptcha.io/docs/reference/api): every operation, with a sample in curl, Node and Python. - [Errors](https://zerocaptcha.io/docs/reference/errors): every code in both dialects, and what to do about each. - [Adding funds](https://zerocaptcha.io/docs/funds): top-ups in crypto, receipts, the low-balance email and spend caps. - [Status](https://zerocaptcha.io/status): how the platform did over the last 24 hours. --- # Rate limits and concurrency > What bounds how many tasks you can run and how often you can read them, the RateLimit headers that say where you stand, and how to run many tasks at once. Source: https://zerocaptcha.io/docs/rate-limits There is no rate limit on creating paid tasks. What bounds your throughput is your balance, your account's share of the queue, and budgets on reads. This page says what each one is, how the API tells you where you stand, and how to run many tasks at once without hitting any of them. ## Creating tasks | Bound | Default | Over it | | --- | --- | --- | | Your available balance | Your balance, less what running tasks hold | [`insufficient_funds`](https://zerocaptcha.io/docs/reference/errors#insufficient_funds) (402), `ERROR_ZERO_BALANCE` | | A key's daily spend cap | None, until you set one | [`spend_cap_reached`](https://zerocaptcha.io/docs/reference/errors#spend_cap_reached) (402), `ERROR_SPEND_CAP_REACHED` | | Tasks queued or running for your account | 50 at once | [`queue_full`](https://zerocaptcha.io/docs/reference/errors#queue_full) (429, `Retry-After: 2`), `ERROR_NO_SLOT_AVAILABLE` | | Tasks queued across the service | 5,000 | `queue_full`, as above | Creating a task draws on no request budget: the bounds above are the only ones. A task finishes (and frees its place in your share) as soon as it succeeds, fails or expires. The share is set per account; if you need more than 50 tasks in flight at once, [write to us](https://zerocaptcha.io/contact). ## Reading tasks and the balance Reads (`GET /v1/tasks`, `GET /v1/tasks/{id}`, `GET /v1/balance`, `getTaskResult`, `getBalance`, `res.php`, and opening a live-update stream) draw on two budgets: one for the key, one for its account. Both refill evenly, not all at once when a window ends. | Budget | Default | | --- | --- | | Reads per key | 200 every 2 seconds | | Reads per account, all its keys together | 200 every 2 seconds | | Live-update streams open per account | 10 | | Public reads (`GET /v1/prices`, `GET /v1/status`) per client address | 60 a minute | Over a budget, a read is refused with [`rate_limited`](https://zerocaptcha.io/docs/reference/errors#rate_limited) (429) and `Retry-After`; in the createTask format with `ERROR_RATE_LIMIT`, and in 2Captcha's with `ERROR: 1005` (and `MAX_USER_TURN` for `in.php`, should creation ever have a budget). Nothing is charged for a refused read. ## Where you stand: the RateLimit headers Replies to calls with a key carry two headers (IETF's RateLimit fields): ```text RateLimit-Policy: "key-read";q=200;w=2, "account-read";q=200;w=2 RateLimit: "key-read";r=187;t=1, "account-read";r=161;t=1 ``` - `RateLimit-Policy` names each budget with its size `q` and the seconds `w` it refills over. - `RateLimit` says how many units `r` are left in each, and `t`, the seconds until one more is back. Read them rather than hard-coding the defaults above, which the service may change. When `r` reaches 0, wait `t` seconds before the next read. ## Other limits - A request body may be at most 64 KiB; a larger one is refused with [`payload_too_large`](https://zerocaptcha.io/docs/reference/errors#payload_too_large) (413). - The API answers every request within 10 seconds, or with [`request_timeout`](https://zerocaptcha.io/docs/reference/errors#request_timeout) (504). When it is overloaded it answers [`service_unavailable`](https://zerocaptcha.io/docs/reference/errors#service_unavailable) (503) with `Retry-After`. Retry both. [Limits](https://zerocaptcha.io/docs/reference/limits) lists every limit in one table, field lengths included. ## Running many tasks at once - **Create in parallel, up to your share.** Keep at most 50 tasks queued or running; when one ends, start the next. A worker pool of that size, or a semaphore, does it. - **Poll each task every 2 seconds,** not faster. 50 tasks polled every 2 seconds is 25 reads a second, well within the read budget. Better still, use [callbacks](https://zerocaptcha.io/docs/callbacks) or one [live-update stream](https://zerocaptcha.io/docs/callbacks#live-updates) instead of polling each task. - **Treat `queue_full` as back-pressure:** wait the 2 seconds it asks, then try again, and lower your concurrency if it keeps happening. - **Keep enough balance** for the tasks you run at once: each holds its price until it ends. - **Spread across keys only for bookkeeping.** Every key of an account shares the account's read budget and queue share, so more keys do not mean more throughput. --- # Callback payload > Exactly what ZeroCaptcha POSTs to your callback URL in each format, its headers, how its signature is computed, and how deliveries are retried. Source: https://zerocaptcha.io/docs/reference/callbacks When a task that named a callback URL ends, we POST its result to that URL, once it is final: succeeded, failed or expired. This page is the reference for that request. For how to name a URL and check a call, see [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). ## The request | Part | Value | | --- | --- | | Method | `POST` | | URL | The task's `callbackUrl`, or `pingback` in the 2Captcha format | | `Content-Type` | `application/json` for REST and createTask tasks; `application/x-www-form-urlencoded` for 2Captcha tasks | | `User-Agent` | `ZeroCaptcha-Callbacks/1.0` | | `ZeroCaptcha-Signature` | `t=,v1=<64 hex digits>` | | `ZeroCaptcha-Delivery` | The delivery's ID, a UUID, the same on every attempt | The body is read from the task when each attempt is made, in the format the task was created in. ## The signature `v1` is the HMAC-SHA256, in lowercase hex, of the timestamp `t`, a full stop, and the raw body, keyed with your account's callback secret (`zcsig_…`): ```text v1 = hex(HMAC-SHA256(key = callback secret, message = t + "." + body)) ``` `t` is when the attempt was made, so it changes on each retry, and so does `v1`. Accept a call only if `v1` matches in a constant-time comparison and `t` is within five minutes of your clock. Code in four languages is in [Polling and callbacks](https://zerocaptcha.io/docs/callbacks#check-the-signature). ## REST tasks The task as `GET /v1/tasks/{id}` shows it, with `solution` while its token is valid: ```json { "id": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "type": "TurnstileTaskProxyless", "kind": "turnstile", "status": "succeeded", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "action": null, "cdata": null, "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", "idempotencyKey": null, "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", "version": 3 } ``` A failed or expired task has `status` `failed` or `expired`, its `errorCode` and `errorDescription`, `cost` `0.000000` and no `solution`. A challenge page's solution has `userAgent` and `cookie` too; see [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges#read-the-result). Every field is in the [API reference](https://zerocaptcha.io/docs/reference/api/tasks). ## createTask tasks The reply `getTaskResult` would give at that moment: ```json { "errorId": 0, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", "status": "ready", "solution": { "token": "0.Zm9vYmFy…", "type": "turnstile" }, "cost": "0.000800", "createTime": 1790776925, "endTime": 1790776934, "solveCount": 1, "expiresAt": "2026-09-30T14:07:14Z" } ``` A task that did not succeed sends `errorId: 1` with `errorCode`, `errorDescription`, `taskId`, `"status": "failed"` and `"cost": "0.000000"`. See [createTask format](https://zerocaptcha.io/docs/createtask). ## 2Captcha tasks A form with the task's ID and its token: ```text id=0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b&code=0.Zm9vYmFy... ``` or, when the task was not solved, the code in place of the token, such as `code=ERROR_CAPTCHA_UNSOLVABLE`. See [2Captcha format](https://zerocaptcha.io/docs/2captcha#error-codes). ## Deliveries and retries | | | | --- | --- | | Success | Any 2xx status. The body of your answer is ignored. | | Time limit | 10 seconds per attempt | | Retries | After 30 seconds, then doubling (1, 2, 4, 8, 16 and 32 minutes), each wait lengthened by up to half at random | | Attempts | 8 in all, over roughly 65 to 95 minutes; then the delivery stops | | Redirects | Not followed: a 3xx is a failed attempt | | Addresses | The URL's name is resolved on every attempt; a private, loopback or link-local address is never called, and the delivery stops at once | A delivery can arrive more than once: use `ZeroCaptcha-Delivery` or the task ID to handle each task once. Because each attempt reads the task afresh, a retry made after a Turnstile token expired carries the task without its token; read failed deliveries' tasks from the API instead. --- # Changelog > What changed in the ZeroCaptcha API, its formats, the dashboard and these docs, newest first, and how changes to the API are announced. Source: https://zerocaptcha.io/docs/reference/changelog What changed, newest first. The API is at version 0.1.0 of its contract and has not launched yet; until it does, entries are grouped by the day they were built. ## How changes are made - **Additions** (a new field in a reply, a new optional field, a new task type or error code) can come at any time. Ignore reply fields you do not know, and treat an unknown error code by its HTTP status. - **A change that could break working code** (a removed or renamed field, a changed meaning) is not made within v1: it would come as v2, with `Deprecation` and `Sunset` headers on v1's replies and an entry here first. - **Prices** change only as the price list says ahead: [`GET /v1/prices`](https://zerocaptcha.io/docs/reference/api/prices) shows the next change and when it takes effect, and a task is always charged the price in effect when it was created. The [OpenAPI contract](https://zerocaptcha.io/openapi.json) always describes the API as it is. ## 1 October 2026 - **Your account.** `GET /v1/account` gives the account's standing and the getting-started checklist, which the dashboard's Overview now shows until its three steps are done. Settings downloads a copy of your data (`GET /v1/account/export`), and an owner deletes the account there (`POST /v1/account/deletion`), confirmed with the password or a passkey: its queued tasks end with the new outcome `ERROR_ACCOUNT_DELETED`, uncharged. See [Account security](https://zerocaptcha.io/docs/account-security#your-data-and-deleting-the-account). - **Suspensions.** Everyone on a suspended account is emailed, with how to appeal, and again when it is lifted. The dashboard shows the suspension on every page, and Support becomes the appeal form. - **Callbacks.** `GET /v1/tasks/{id}` shows a task's `callback`: where it stands and each delivery attempt, with its time, status code and next retry, as the task's page in the dashboard does. `POST /v1/tasks/{id}/callback/resend` sends it again. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks#see-each-attempt-and-send-it-again). - **Reports.** A report says whether the site took a solved task's token: `POST /v1/tasks/{id}/report`, `reportbad` and `reportgood` in the 2Captcha format, and `reportIncorrect`, `reportCorrect`, Anti-Captcha's `reportIncorrectRecaptcha` and `reportCorrectRecaptcha` and CapSolver's `feedbackTask` in the createTask format. Reports are recorded for our staff; nothing is refunded. - **The 2Captcha format.** Task IDs are numbers, as 2Captcha's are, and `res.php` takes a task's UUID too; `action=get` with `ids` reads up to 100 tasks at once. See [2Captcha format](https://zerocaptcha.io/docs/2captcha). - **The createTask format** reads Anti-Captcha's `cData` spelling too, and CapMonster Cloud's `pageAction`. Its `cloudflareTaskType` may be `token`; its `cf_clearance` and `wait_room` modes are refused with `ERROR_TASK_NOT_SUPPORTED`, before anything is charged. See [createTask format](https://zerocaptcha.io/docs/createtask#cloudflare-turnstile-task). - **Support from a task.** A task's page opens Support with the task's ID filled in. ## 30 September 2026 - **Docs.** Guides for solving Turnstile, browser automation, polling and callbacks, errors and retries, rate limits, billing, teams and account security; how a task works and authentication; the createTask format and migration guides; a page for each SDK; the callback payload, limits, this changelog and an FAQ. Every page has Copy as Markdown and Open in Claude or ChatGPT, and a Markdown version at its address with `.md`. - **Hand off to AI.** An [integration brief](https://zerocaptcha.io/docs/ai) for AI coding assistants, generated from the contract, with tested reference clients in Node, Python, Go and bash; the same as JSON; files for Claude Code, Cursor, AGENTS.md and GitHub Copilot; `/llms.txt` and `/llms-full.txt`; an MCP server; and **Hand off to AI** on the dashboard's API keys page. - **Cloudflare challenge pages.** `CloudflareChallengeTask` (and `AntiCloudflareTask`) passes a challenge page through your proxy and returns its `cf_clearance` cookie with the user agent it is bound to, in REST and the createTask format. Tasks gain `kind`, `turnstile` or `cloudflare`, and a solution gains `userAgent` and `cookie`. See [Challenge pages](https://zerocaptcha.io/docs/challenges). - **The 2Captcha format.** `in.php` and `res.php` for Turnstile, with every reply in 2Captcha's shapes. See [2Captcha format](https://zerocaptcha.io/docs/2captcha). - **Callbacks.** `callbackUrl` (`pingback` in the 2Captcha format) posts a task's result when it ends, signed with `ZeroCaptcha-Signature`. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). - **Teams.** Owners and members on one account, with invitations and an activity log. See [Teams and roles](https://zerocaptcha.io/docs/teams). - **SDKs** for Node.js, Python and Go. See [SDKs](https://zerocaptcha.io/docs/sdks). - **The dashboard** gained billing (top-ups, receipts, the low-balance email, spend caps), usage, the Playground and the task log. ## 29 September 2026 - **The task API.** REST v1 (`POST /v1/tasks`, `GET /v1/tasks`, `GET /v1/tasks/{id}`, live updates at `GET /v1/tasks/events`, `GET /v1/balance`) and the createTask format (`createTask`, `getTaskResult`, `getBalance`) for `TurnstileTaskProxyless` and `TurnstileTask`, with `Idempotency-Key` on creates and RFC 9457 problem details. - **Accounts and keys.** Sign-up with an email and a password, keys with scopes, allowed addresses, rotation with an overlap, and revocation; two-factor with an authenticator app, recovery codes and passkeys. - **Money.** A prepaid USD balance, top-ups in crypto through NOWPayments, holds and charges settled once per task, and public prices at `GET /v1/prices`. --- # Errors > Every error code in both API dialects, with what it means, whether a retry helps, what to do next and what it costs. Source: https://zerocaptcha.io/docs/reference/errors ZeroCaptcha reports an error in the shape of the dialect you called. Every code on this page has its own anchor, named after the code, such as [`#insufficient_funds`](#insufficient_funds) or [`#ERROR_ZERO_BALANCE`](#ERROR_ZERO_BALANCE). A problem's `type` links straight to its entry. ## What errors cost - A refused request costs nothing, whatever the code. - A task that fails or expires costs nothing: the price held when it was created goes back to your balance in full. - A task that succeeded is charged, even if you read it after its token expired ([`ERROR_TOKEN_EXPIRED`](#ERROR_TOKEN_EXPIRED)). ## How an error looks The REST API answers every failed call with [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details, served as `application/problem+json` with a 4xx or 5xx status. ```json { "type": "https://zerocaptcha.io/docs/reference/errors/#insufficient_funds", "title": "Insufficient funds", "status": 402, "detail": "Your balance cannot cover this task. Add funds and try again.", "instance": "/v1/tasks", "code": "insufficient_funds", "request_id": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" } ``` | Field | Meaning | | --- | --- | | `type` | A link to this page's entry for the code | | `title` | A short, fixed summary of the problem | | `status` | The HTTP status, repeated for convenience | | `detail` | What happened in this request; never sent with a 5xx | | `instance` | The path that was called | | `code` | A stable, machine-readable code; branch on this, not on the title | | `request_id` | The ID to quote to support; also sent in the `x-request-id` header | The compatible endpoints, `createTask`, `getTaskResult` and `getBalance`, answer in their own shape instead: HTTP 200 with `errorId` 1. ```json { "errorId": 1, "errorCode": "ERROR_ZERO_BALANCE", "errorDescription": "Your balance cannot cover this task. Add funds and try again." } ``` A request can still fail before it reaches either shape, for example at a proxy, on a body that is too large or on a server timeout. Those failures come back as a 4xx or 5xx status, so check the HTTP status as well as `errorId`. The [quickstart samples](https://zerocaptcha.io/docs/quickstart) do both. ## Retrying - **Retry with backoff:** `internal_error`, `service_unavailable`, `request_timeout` and `ERROR_SERVICE_UNAVAILABLE`. - **Retry after a wait:** `queue_full` and `idempotency_key_in_use`, after the time in their `Retry-After` header; `ERROR_NO_SLOT_AVAILABLE` and `ERROR_IDEMPOTENCY_KEY_IN_USE`, after a few seconds. - **Fix the request first:** every other code fails the same way until something changes, such as the key, the balance or the request itself. When you retry a create, send the same `Idempotency-Key` header, on `POST /v1/tasks` or `POST /createTask`. For 24 hours the same key and request return the first reply instead of making a second task. A task that failed will not change either: create a new one. ## Asking for help Quote the `request_id` of a failed REST call, or the `taskId` of a task. Never send your API key: support never needs it, and anyone who has it can spend your balance. ## Problem codes The `code` of a REST problem. ### `bad_request` HTTP 400 · Bad request - Meaning: 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. - Retry: No. The same request fails the same way. - What to do: Compare the method, headers and body with the API reference, and send JSON with Content-Type: application/json. - Cost: Nothing. A refused request is never charged. ### `not_found` HTTP 404 · Not found - Meaning: No route matches the path, or no task or key with this ID belongs to your account. - Retry: No. - What to do: Check the path and the ID. A task or a key is visible only to its own account. - Cost: Nothing. Reads are never charged. - Related: `ERROR_NO_SUCH_CAPCHA_ID` ### `method_not_allowed` HTTP 405 · Method not allowed - Meaning: The path exists, but not with this HTTP method. - Retry: No. - What to do: Use the method the reference gives for the path, such as POST /v1/tasks to create a task. - Cost: Nothing. A refused request is never charged. ### `payload_too_large` HTTP 413 · Request body too large - Meaning: The request body is larger than the API accepts. - Retry: No. - What to do: Send only the documented fields. A task needs the page URL, its site key and, when you use them, action, cdata and a proxy. - Cost: Nothing. A refused request is never charged. ### `internal_error` HTTP 500 · Internal server error - Meaning: Something failed on our side. The reply has no detail; the cause is logged under its request ID. - Retry: Yes, with backoff. Retry a create with the same Idempotency-Key, so one that went through is not made twice. - What to do: If it keeps happening, contact support and quote the request_id. - Cost: Nothing for this reply. If a create went through before it, that task is charged only if it succeeds. ### `service_unavailable` HTTP 503 · Service unavailable - Meaning: A service the API depends on is down or overloaded, so the request could not be served. - Retry: Yes, after a short wait, with backoff. - What to do: Retry. If it lasts, check the status page. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_SERVICE_UNAVAILABLE` ### `request_timeout` HTTP 504 · Request timed out - Meaning: The API did not finish the request in time. The timeout is on our side, not a slow client. - Retry: Yes, with backoff. Retry a create with the same Idempotency-Key: the first attempt may have created the task. - What to do: Retry. To check whether a create went through, list tasks with the idempotencyKey filter. - Cost: Nothing for this reply. If a create went through before it, that task is charged only if it succeeds. ### `unauthorized` HTTP 401 · Authentication required - Meaning: No valid API key or session came with the request: the Authorization header is missing or malformed, or the key does not exist. - Retry: Not with the same key. - What to do: Send Authorization: Bearer followed by a key from the dashboard, and check that the whole key was copied. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_KEY_DOES_NOT_EXIST` ### `invalid_credentials` HTTP 401 · Wrong email or password - Meaning: Dashboard log-in only: the email and password do not match an account. - Retry: Not with the same details. - What to do: Check the email address and the password, then log in again. - Cost: Nothing. A refused request is never charged. ### `key_revoked` HTTP 401 · API key revoked - Meaning: 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. - Retry: No. - What to do: Use the key that replaced it, or create a new key in the dashboard, and replace the old one wherever it is used. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_KEY_REVOKED` ### `key_limit_reached` HTTP 409 · Key limit reached - Meaning: 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. - Retry: Not until a key is revoked. - What to do: 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. - Cost: Nothing. A refused request is never charged. ### `key_state_conflict` HTTP 409 · Key cannot change this way - Meaning: 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. - Retry: No. The same change fails the same way. - What to do: 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. - Cost: Nothing. A refused request is never charged. ### `csrf_rejected` HTTP 403 · Cross-site request refused - Meaning: Dashboard sessions only: a browser request came from another site, or without the session's CSRF token. - Retry: Not as it was sent. - What to do: In the dashboard, reload the page. From code, call the API with an API key instead. - Cost: Nothing. A refused request is never charged. ### `insufficient_scope` HTTP 403 · API key lacks the scope - Meaning: 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. - Retry: Not with this key. - What to do: Use a key with the scope the detail names, or create one that has it. Manage keys from the dashboard. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_ACCESS_DENIED` ### `ip_not_allowed` HTTP 403 · Address not allowed for this key - Meaning: The key works only from the addresses on its allowlist, and this request came from another. - Retry: Not from this address. - What to do: Add the address to the key's allowlist in the dashboard, or call from an allowed address. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_IP_NOT_ALLOWED` ### `account_suspended` HTTP 403 · Account suspended - Meaning: The account is suspended: its keys are refused, and it cannot create tasks, make keys or add funds. - Retry: No. - What to do: Contact support to appeal. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_ACCOUNT_SUSPENDED` ### `insufficient_funds` HTTP 402 · Insufficient funds - Meaning: Your available balance cannot cover the task's price. Prices held for tasks still running count against it. - Retry: Yes, once the balance covers the price: after a top-up, or when running tasks finish and their holds are released or charged. - What to do: Add funds in the dashboard, then create the task again. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_ZERO_BALANCE` ### `domain_blocked` HTTP 403 · Domain blocked - Meaning: 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. - Retry: No. The same site is refused every time. - What to do: Do not send tasks for this site. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_DOMAIN_BLOCKED` ### `validation_failed` HTTP 422 · Invalid request - Meaning: 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. - Retry: Only after fixing the request. - What to do: Fix the field the detail names, then send the request again. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_TASK_ABSENT`, `ERROR_TASK_NOT_SUPPORTED`, `ERROR_INVALID_TASK_DATA` ### `idempotency_key_reused` HTTP 422 · Idempotency key reused - Meaning: This Idempotency-Key was used within the last 24 hours for a different request. - Retry: Not with this key. - What to do: Use a new key for a new task. Reuse a key only to retry exactly the same request. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_IDEMPOTENCY_KEY_REUSED` ### `idempotency_key_in_use` HTTP 409 · Idempotency key in use - Meaning: A request with this Idempotency-Key is still being processed. - Retry: Yes, after the wait in the Retry-After header. The retry gets the first request's reply. - What to do: Wait, then send the same request with the same key. - Cost: Nothing extra. The first request's task, if it made one, is charged only if it succeeds. - Related: `ERROR_IDEMPOTENCY_KEY_IN_USE` ### `queue_full` HTTP 429 · Queue full - Meaning: Your account's share of the queue, or the solver pool, is full. - Retry: Yes, after the wait in the Retry-After header. - What to do: Wait, then retry, and spread tasks out over time. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_NO_SLOT_AVAILABLE` ### `rate_limited` HTTP 429 · Too many requests - Meaning: 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. - Retry: Yes, after the wait in the Retry-After header. - What to do: Wait, then retry, and pace requests by the RateLimit header: poll results less often, or close a live stream you no longer need. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_RATE_LIMIT` ### `reauthentication_required` HTTP 403 · Recent sign-in required - Meaning: 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. - Retry: Yes, after re-authenticating. - What to do: Confirm the password with POST /v1/session/reauthentication, then send the change again. - Cost: Nothing. A refused request is never charged. ### `link_invalid` HTTP 400 · Link not valid - Meaning: 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. - Retry: No. The same link fails the same way. - What to do: Open the link straight from the email, or copy all of it. If it still fails, ask for a new email. - Cost: Nothing. A refused request is never charged. ### `link_expired` HTTP 410 · Link expired - Meaning: 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. - Retry: No. Ask for a new link instead. - What to do: Ask for a new verification email or a new password reset email; the page that opened the link offers it. - Cost: Nothing. A refused request is never charged. ### `link_used` HTTP 409 · Link already used - Meaning: The email link was already used. Each link works once. - Retry: No. - What to do: Nothing, if the address is verified or the password was reset already: sign in. Otherwise ask for a new email. - Cost: Nothing. A refused request is never charged. ### `weak_password` HTTP 422 · Password not allowed - Meaning: 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. - Retry: No. Choose another password. - What to do: Use at least 8 characters, not the email address and not a common password. A password manager's generated password passes. - Cost: Nothing. A refused request is never charged. ### `state_conflict` HTTP 409 · Not possible now - Meaning: 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. - Retry: No. The same request fails until the state changes. - What to do: Read the resource again, such as GET /v1/session, and act on what it shows. - Cost: Nothing. A refused request is never charged. ### `role_required` HTTP 403 · Role required - Meaning: 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. - Retry: Not until an owner or admin gives the person a role that allows it. - What to do: Ask an owner of the account (or, for staff, an admin) for the role, or leave the action to someone who holds it. - Cost: Nothing. A refused request is never charged. ### `spend_cap_reached` HTTP 402 · Spend cap reached - Meaning: 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. - Retry: Not before 00:00 UTC, when the day's count starts again, unless the cap is raised or removed. - What to do: Raise or remove the key's cap in the dashboard, use another key, or wait for the next UTC day. - Cost: Nothing. A refused request is never charged. - Related: `ERROR_SPEND_CAP_REACHED` ### `payments_unavailable` HTTP 503 · Payments unavailable - Meaning: A top-up cannot be started now: no payment processor is set up, or it did not answer. Balances, tasks and receipts are unaffected. - Retry: Yes, after a few minutes. - What to do: Try the top-up again later. If it lasts, contact support. - Cost: Nothing. No invoice was created and nothing was charged. ### `email_taken` HTTP 409 · Email already registered - Meaning: 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. - Retry: No. The same address is refused every time. - What to do: Log in with the address, or reset its password if you have forgotten it. To open another account, use another address. - Cost: Nothing. A refused request is never charged. ### `email_unverified` HTTP 403 · Email not confirmed - Meaning: 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. - Retry: Not until the address is confirmed. Then the same request succeeds at once. - What to do: 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. - Cost: Nothing. A refused request is never charged. ## Compatible codes The `errorCode` of a compatible reply with `errorId` 1. ### `ERROR_TASK_ABSENT` - Meaning: The body has no task object, or the task has no type. - Retry: Only after fixing the request. - What to do: Send a task with a type, such as TurnstileTaskProxyless. - Cost: Nothing. A refused request is never charged. - Related: `validation_failed` ### `ERROR_TASK_NOT_SUPPORTED` - Meaning: The task type is not one ZeroCaptcha solves. - Retry: Only with a supported type. - What to do: 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. - Cost: Nothing. A refused request is never charged. - Related: `validation_failed` ### `ERROR_INVALID_TASK_DATA` - Meaning: 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. - Retry: Only after fixing the task. - What to do: Fix the field the errorDescription names, then create the task again. - Cost: Nothing. A refused request is never charged, and a task that fails this way is released in full. - Related: `validation_failed` ### `ERROR_KEY_DOES_NOT_EXIST` - Meaning: The clientKey is missing, or is not a valid key. - Retry: Not with the same key. - What to do: Send a key from the dashboard as clientKey, and check that the whole key was copied. - Cost: Nothing. A refused request is never charged. - Related: `unauthorized` ### `ERROR_KEY_REVOKED` - Meaning: 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. - Retry: No. - What to do: Use the key that replaced it, or create a new key in the dashboard, and replace the old one wherever it is used. - Cost: Nothing. A refused request is never charged. - Related: `key_revoked` ### `ERROR_ACCOUNT_SUSPENDED` - Meaning: The account is suspended. As a task outcome, it was suspended after the task was created and before it ran. - Retry: No. - What to do: Contact support to appeal. - Cost: Nothing. A refused request is never charged, and a task stopped this way is released in full. - Related: `account_suspended` ### `ERROR_IP_NOT_ALLOWED` - Meaning: The key works only from the addresses on its allowlist, and this request came from another. - Retry: Not from this address. - What to do: Add the address to the key's allowlist in the dashboard, or call from an allowed address. - Cost: Nothing. A refused request is never charged. - Related: `ip_not_allowed` ### `ERROR_ACCESS_DENIED` - Meaning: The key lacks the scope this call needs: tasks:write for createTask, tasks:read for getTaskResult and balance:read for getBalance. - Retry: Not with this key. - What to do: Use a key with the scope the errorDescription names, or create one that has it. - Cost: Nothing. A refused request is never charged. - Related: `insufficient_scope` ### `ERROR_DOMAIN_BLOCKED` - Meaning: 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. - Retry: No. The same site is refused every time. - What to do: Do not send tasks for this site. - Cost: Nothing. A refused request is never charged, and a task stopped this way is released in full. - Related: `domain_blocked` ### `ERROR_ZERO_BALANCE` - Meaning: Your available balance cannot cover the task's price. Prices held for tasks still running count against it. - Retry: Yes, once the balance covers the price. - What to do: Add funds in the dashboard, then create the task again. - Cost: Nothing. A refused request is never charged. - Related: `insufficient_funds` ### `ERROR_NO_SLOT_AVAILABLE` - Meaning: Your account's share of the queue, or the solver pool, is full. - Retry: Yes, after a few seconds, with backoff. - What to do: Wait, then retry, and spread tasks out over time. - Cost: Nothing. A refused request is never charged. - Related: `queue_full` ### `ERROR_RATE_LIMIT` - Meaning: 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. - Retry: Yes, after the wait in the Retry-After header. - What to do: Wait, then retry, and poll getTaskResult less often. - Cost: Nothing. A refused request is never charged. - Related: `rate_limited` ### `ERROR_IDEMPOTENCY_KEY_REUSED` - Meaning: The Idempotency-Key header was used within the last 24 hours for a different createTask request. - Retry: Not with this key. - What to do: Use a new key for a new task. Reuse a key only to retry exactly the same request. - Cost: Nothing. A refused request is never charged. - Related: `idempotency_key_reused` ### `ERROR_IDEMPOTENCY_KEY_IN_USE` - Meaning: A createTask request with this Idempotency-Key is still being processed. - Retry: Yes, shortly. The retry gets the first request's reply. - What to do: Wait a moment, then send the same request with the same key. - Cost: Nothing extra. The first request's task, if it made one, is charged only if it succeeds. - Related: `idempotency_key_in_use` ### `ERROR_NO_SUCH_CAPCHA_ID` - Meaning: 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. - Retry: No. - What to do: Poll with the taskId that createTask returned, using a key of the same account. - Cost: Nothing. Reads are never charged. - Related: `not_found` ### `ERROR_SERVICE_UNAVAILABLE` - Meaning: The API could not serve the request right now. - Retry: Yes, with backoff. Retry createTask with the same Idempotency-Key header, so one that went through is not made twice. - What to do: Retry. If it lasts, check the status page. - Cost: Nothing for this reply. If a create went through before it, that task is charged only if it succeeds. - Related: `service_unavailable` ### `ERROR_REPORT_NOT_RECORDED` - Meaning: A report (reportIncorrect, reportCorrect, their Recaptcha forms, or feedbackTask) named a task that did not succeed, so it has no token to report on. - Retry: No. - What to do: Report only tasks that succeeded. Reports are recorded for our staff and never refund a task. - Cost: Nothing. Reads are never charged. ### `ERROR_DUPLICATE_REPORT` - Meaning: The task has a report already: one per task. - Retry: No. - What to do: Send one report per task. - Cost: Nothing. Reads are never charged. ### `ERROR_INVALID_REQUEST` - Meaning: The body is not valid JSON, or is not a JSON object; or feedbackTask came without result.invalid. - Retry: Only after fixing the body. - What to do: Send a JSON object. The Content-Type does not matter here, so clients that send JSON as text/plain work. - Cost: Nothing. A refused request is never charged. ### `ERROR_TOKEN_EXPIRED` - Meaning: 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. - Retry: No. A token cannot be renewed. - What to do: Create a new task, and use each token as soon as it is ready. - Cost: The task's price. It succeeded, so it was charged, even though its token expired before it was used. ### `ERROR_SPEND_CAP_REACHED` - Meaning: createTask only: the API key's daily spend cap would be passed by this task. The same condition as spend_cap_reached. - Retry: Not before 00:00 UTC, unless the cap is raised or removed. - What to do: Raise or remove the key's cap in the dashboard, or wait for the next UTC day. - Cost: Nothing. A refused request is never charged. - Related: `spend_cap_reached` ## Task outcomes A task that was created can still fail. `getTaskResult` then answers `errorId` 1 with status `failed` and one of these codes; on REST, the task has status `failed` or `expired`, with the code in its `errorCode`. A task can also end with [`ERROR_DOMAIN_BLOCKED`](#ERROR_DOMAIN_BLOCKED), [`ERROR_ACCOUNT_SUSPENDED`](#ERROR_ACCOUNT_SUSPENDED) or [`ERROR_INVALID_TASK_DATA`](#ERROR_INVALID_TASK_DATA) when that check fails after the task was created. None of them is charged. ### `ERROR_CAPTCHA_UNSOLVABLE` - Meaning: Every attempt to solve the challenge failed. - Retry: A new task may succeed. - What to do: If it keeps happening, check the websiteURL and websiteKey and, with TurnstileTask or a challenge page, that your proxy works. - Cost: Nothing. The price held when the task was created goes back to your balance in full. ### `ERROR_TASK_TIMEOUT` - Meaning: The task was not solved before its deadline. - Retry: Yes, with a new task. - What to do: Create a new task. If timeouts keep happening, check the status page. - Cost: Nothing. The price held when the task was created goes back to your balance in full. ### `ERROR_PROXY_NOT_ALLOWED` - Meaning: 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. - Retry: Not with this proxy. - What to do: Use a proxy with a public address. - Cost: Nothing. The price held when the task was created goes back to your balance in full. ### `ERROR_ACCOUNT_DELETED` - Meaning: An owner deleted the account while the task was queued, which cancels every task it had queued. - Retry: No. The account and its keys are gone. - What to do: Nothing to do. To start again, sign up for a new account. - Cost: Nothing. The price held when the task was created goes back to your balance in full. --- # FAQ > Answers to the questions buyers ask about ZeroCaptcha: trying it, what is charged, tokens, proxies, errors, keys, payments, limits and support. Source: https://zerocaptcha.io/docs/reference/faq ## Getting started ### Is there a free trial, a sandbox or a test key? No. There is one kind of key, and every task it creates solves a real challenge. A task is charged only if it succeeds, so trying the API costs one task's price; see [pricing](https://zerocaptcha.io/pricing). Top-ups start at $10. ### Which API format should I use? REST v1 for new code: resource URLs, a bearer key, `Idempotency-Key` for safe retries and a problem document for every error. Use the [createTask format](https://zerocaptcha.io/docs/createtask) or the [2Captcha format](https://zerocaptcha.io/docs/2captcha) when you already have a client written for them. ### Do you have client libraries? For [Node.js](https://zerocaptcha.io/docs/sdks/node), [Python](https://zerocaptcha.io/docs/sdks/python) and [Go](https://zerocaptcha.io/docs/sdks/go), they are coming: none is in its package registry yet. Until they are, and in any other language, call the API over plain HTTP, as the [quickstart](https://zerocaptcha.io/docs/quickstart) does; the [AI brief](https://zerocaptcha.io/docs/ai) has tested reference clients in four languages. ### Can my AI coding assistant write the integration? Yes. Give it the [integration brief](https://zerocaptcha.io/docs/ai): one file with every call, field and error code, the retry rules, tested clients and a checklist. Keep your key in the environment, not in the chat. ## Tasks and tokens ### How long does a task take? It depends on the site and the solvers' load. A task ends by its deadline, 150 seconds after it was created by default; the [status page](https://zerocaptcha.io/status) shows the median time to a token over the last day. ### How often should I poll? Every 2 seconds, until the status is final, and stop at a deadline. Or name a [callback](https://zerocaptcha.io/docs/callbacks) URL and we call you. ### How long is a token valid? A Turnstile token works once, for 300 seconds after it was issued. A challenge page's clearance lasts as long as the site allows, 30 minutes by default; we serve it for 30 minutes. ### I read my task after its token expired. Was I charged? Yes: the task succeeded, so it was charged, even though the token expired before you used it. Use tokens as soon as they are ready. See [How a task works](https://zerocaptcha.io/docs/how-tasks-work#the-token). ### Is a failed task charged? No. A task that fails or expires releases its hold in full, and a refused request is never charged. ### The site rejects the token. What now? Check that `websiteURL` is the page with the widget, that `websiteKey`, `action` and `cdata` match the widget exactly, that you submit the token in the field or callback the page uses, and within 300 seconds. With `TurnstileTask`, the site may also expect the form from the address that solved it. See [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile#use-the-token). You can report it: `POST /v1/tasks/{id}/report` with `{"verdict": "bad"}`, `reportbad` in the 2Captcha format, or `reportIncorrect` in the createTask format. We read reports to find sites and settings that fail. A report refunds nothing, as every charge is final. ### Can it solve Cloudflare WAF and 5-second challenge pages? Yes. When a Cloudflare WAF rule, Bot Fight Mode or Under Attack mode answers with the "Just a moment..." challenge page, once known as the 5-second challenge, a `CloudflareChallengeTask` passes it through your proxy and returns the `cf_clearance` cookie with the user agent it was issued for. A block, such as error 1020, is a refusal rather than a challenge, and no task passes it. See [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges). ### Do I need a proxy? Not for Turnstile: `TurnstileTaskProxyless` needs none. A Cloudflare challenge page always does, because its clearance works only from the address that earned it. See [Cloudflare WAF and 5-second challenges](https://zerocaptcha.io/docs/challenges). ### Which proxies work? HTTP and HTTPS proxies on a public address, with an optional login and password. SOCKS is not supported yet. ### What sites can I send tasks for? Only sites you are allowed to automate, under the [Acceptable Use Policy](https://zerocaptcha.io/legal/acceptable-use). No kind of site is blocked by a rule: staff block a site by hand, after a report or when its owner opts out, and a task for a blocked site is refused with `domain_blocked` and costs nothing. ## Errors and limits ### Which errors should I retry? 429, 5xx, a lost connection, and `idempotency_key_in_use`, after `Retry-After`. Nothing else as is. See [Errors and retries](https://zerocaptcha.io/docs/errors-and-retries). ### Is there a rate limit? Not on creating tasks: your balance and your account's share of the queue (50 tasks at once by default) bound it. Reads have budgets, 200 every 2 seconds per key and per account by default. See [Rate limits](https://zerocaptcha.io/docs/rate-limits). ### A reply was lost. Did I create the task twice? Not if you sent an `Idempotency-Key`: sending the same request with the same key within 24 hours returns the first task. To check, list your tasks with `GET /v1/tasks?idempotencyKey=…`. ## Keys and account ### My key leaked. What do I do? Revoke it on the dashboard's API keys page: it stops at once. Then create a new one. To replace a key without an outage, rotate it instead. See [Authentication](https://zerocaptcha.io/docs/authentication). ### Can my team share an account? Yes. Invite people as owners or members; see [Teams and roles](https://zerocaptcha.io/docs/teams). ### Do I have to verify my email or turn on two-factor? Confirming your email is needed before you create an API key or add funds: open the link we email you when you sign up, or send it again from the dashboard. Everything else works before. Two-factor is optional, and we recommend it: see [Account security](https://zerocaptcha.io/docs/account-security). ### How do I get a copy of my data, or delete my account? In the dashboard's Settings. **Your data** downloads a copy as a JSON file: an owner's covers the account, a member's covers them. **Delete account**, for owners, deletes it at once, confirmed with your password or a passkey: its keys stop working, queued tasks are cancelled without charge, and its people's personal data is erased. A balance left is lost, as top-ups are final, and payment records stay as the law requires. See [Account security](https://zerocaptcha.io/docs/account-security#your-data-and-deleting-the-account). ### My account is suspended. What can I do? Keep reading: you can still sign in and see everything. Its keys are refused and it can't make tasks, keys or top-ups. To appeal, open **Support** in the dashboard: the form becomes an appeal, and a person reads it and answers by email. If the suspension is lifted, everyone on the account is emailed and the keys work again at once. ## Payments ### How do I pay? In crypto, through NOWPayments' checkout page, from $10 with no maximum. You pick the coin there. See [Billing](https://zerocaptcha.io/docs/funds). ### Can I get a refund? No: top-ups are final. You pay only for solved tasks, and you can start with $10. See the [refund policy](https://zerocaptcha.io/legal/refunds). ### I sent less, or more, than the invoice asked. We credit what arrived, at the processor's quote: the share that arrived when you paid less, and all of it when you paid more. See [Billing](https://zerocaptcha.io/docs/funds#top-up). ### Can I get a receipt with my company's details? Yes. Add your company name, address or tax ID under billing details; every receipt after that carries them. ## Support ### How do I reach you? Use the [contact form](https://zerocaptcha.io/contact), or **Support** in the dashboard. Quote the `request_id` of a failed request, or a task's ID. Never send your API key: we never need it. --- # Limits > Every ZeroCaptcha limit in one place: request sizes and timeouts, field lengths, task deadlines, token lifetimes, budgets, keys, callbacks and retention. Source: https://zerocaptcha.io/docs/reference/limits Every limit the API applies, in one place. Values marked "by default" are the service's settings, which can change: the `RateLimit-Policy` header and each task's own fields (`deadline`, `maxAttempts`, `tokenExpiresAt`) always say what applies to you. ## Requests | Limit | Value | | --- | --- | | Request body | 64 KiB by default; larger is [`payload_too_large`](https://zerocaptcha.io/docs/reference/errors#payload_too_large) (413) | | Server time per request | 10 seconds by default; longer is [`request_timeout`](https://zerocaptcha.io/docs/reference/errors#request_timeout) (504) | | `Idempotency-Key` | 1 to 255 visible ASCII characters; kept for 24 hours | | Tasks per list page | 1 to 100; 50 by default | ## Task fields | Field | Limit | | --- | --- | | `websiteURL` | `http` or `https`, at most 2,048 characters, no credentials, the scheme's default port, a public domain name | | `websiteKey` | 1 to 100 letters, digits, `_` and `-` | | `action` | Up to 32 letters, digits, `_` and `-` | | `cdata` | Up to 255 letters, digits, `_` and `-` | | `proxy` | `http` or `https` with a port; a public host; not a port another protocol reserves; login and password at most 255 bytes each; host name at most 253 characters | | `callbackUrl` | `http` or `https`, at most 2,048 characters, no credentials, a public domain or IP address, not a port another protocol reserves | ## Tasks | Limit | Value | | --- | --- | | Deadline | 150 seconds after creation by default (the task's `deadline`) | | Solve attempts | 3 by default (the task's `maxAttempts`); none started with under 5 seconds left | | Tasks queued or running per account | 50 by default, beyond which `queue_full` (429, `Retry-After: 2`) | | Tasks queued across the service | 5,000, beyond which `queue_full` | | Creation rate | No budget by default: your balance and queue share bound it | | Turnstile token | Valid once, for 300 seconds from `tokenIssuedAt` | | Challenge clearance | Served for 30 minutes from `tokenIssuedAt`; the site's own setting decides how long it accepts it | | Tokens and clearances deleted | 10 minutes after they expire | | Proxy credentials deleted | When the task finishes | | Task records kept | About 90 days: deleted a month at a time, once their month ended more than 90 days ago | ## Reads | Budget | Value | | --- | --- | | Reads per key | 200 every 2 seconds by default | | Reads per account | 200 every 2 seconds by default, all keys together | | Live-update streams per account | 10 at once by default | | `GET /v1/prices` and `GET /v1/status` | 60 a minute per client address by default | Over a budget: [`rate_limited`](https://zerocaptcha.io/docs/reference/errors#rate_limited) (429) with `Retry-After`; `ERROR_RATE_LIMIT` in the createTask format; `ERROR: 1005` in 2Captcha's. ## Keys and accounts | Limit | Value | | --- | --- | | Active API keys per account | 20; a key being replaced by a rotation, and a revoked key, do not count | | Allowed addresses per key | 100 IP addresses or CIDR networks | | Rotation overlap | 1 hour, 24 hours or 7 days | | Daily spend cap | Any amount in whole cents, per key, per UTC day; none by default | | Open team invitations | 20 per account; each link works once, for 7 days | | Passkeys | 20 per person | | Recovery codes | 10, each once | | Dashboard session | 24 hours idle, 30 days at most | | Recent sign-in, for sensitive changes | 10 minutes | | Password | At least 8 characters | ## Money | Limit | Value | | --- | --- | | Smallest top-up | $10 | | Largest top-up | No maximum | | Amounts | US dollars; top-ups in whole cents, balances and prices to six decimals | | Refunds | None: top-ups are final | ## Callbacks | Limit | Value | | --- | --- | | Time to answer | 10 seconds per attempt | | Attempts | 8, from 30 seconds apart doubling to 32 minutes apart, each wait lengthened by up to half at random | | Signature tolerance | 5 minutes between `t` and your clock | --- # SDKs > The official ZeroCaptcha clients for JavaScript, Python and Go. Solve Cloudflare Turnstile and WAF challenge pages, read your balance and check callbacks. Source: https://zerocaptcha.io/docs/sdks Our clients for JavaScript and TypeScript, Python and Go do the same things: create a Cloudflare Turnstile task or a Cloudflare [challenge page](https://zerocaptcha.io/docs/challenges)'s task, wait for its result, read your balance, and check a [callback](https://zerocaptcha.io/docs/callbacks)'s signature. None has dependencies beyond its language's standard library, and each is MIT-licensed. | Language | Package | Needs | Page | | --- | --- | --- | --- | | JavaScript, TypeScript | `@zerocaptcha/sdk` on npm (coming) | Node.js 20+, Deno, Bun or a browser | [Node.js](https://zerocaptcha.io/docs/sdks/node) | | Python | `zerocaptcha` on PyPI (coming) | Python 3.9+ | [Python](https://zerocaptcha.io/docs/sdks/python) | | Go | `github.com/zerocaptcha/zerocaptcha-go` (coming) | Go 1.22+ | [Go](https://zerocaptcha.io/docs/sdks/go) | Each one sends an `Idempotency-Key` with every task it creates, so when it retries a request we asked it to slow down on (429), could not serve for a moment (502, 503, 504), or whose answer never arrived whole, it never creates a second task. A wait for a result never runs past the time you give it. A task that fails or expires is an error with its code, such as `ERROR_CAPTCHA_UNSOLVABLE`, and nothing is charged for it. Give each client your API key and the API's address, from your environment, and each task the widget's site key, and its action and cData when it sets them (see [action and cData](https://zerocaptcha.io/docs/action-and-cdata)): **Node** ```ts import { ZeroCaptcha } from "@zerocaptcha/sdk"; const client = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY!, baseUrl: process.env.ZEROCAPTCHA_API!, }); const token = await client.solve({ websiteURL: "https://shop.example.com/login", // the page with the widget websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. action: "login", cdata: "session-7f3a9c2e", // proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // callbackUrl: "https://hooks.example.com/zerocaptcha", // to be called when it ends }); const { available } = await client.getBalance(); ``` **Python** ```python import os from zerocaptcha import ZeroCaptcha client = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"]) token = client.solve( website_url="https://shop.example.com/login", # the page with the widget website_key="0x4AAAAAAAB1cD2eF3gH4iJ5", # its data-sitekey # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. action="login", cdata="session-7f3a9c2e", # proxy="http://user:pass@proxy.example.net:8080", # to solve through your own proxy # callback_url="https://hooks.example.com/zerocaptcha", # to be called when it ends ) available = client.get_balance()["available"] ``` **Go** ```go client, err := zerocaptcha.NewClient(os.Getenv("ZEROCAPTCHA_KEY"), os.Getenv("ZEROCAPTCHA_API")) if err != nil { log.Fatal(err) } token, err := client.Solve(ctx, zerocaptcha.NewTask{ WebsiteURL: "https://shop.example.com/login", // the page with the widget WebsiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. Action: "login", CData: "session-7f3a9c2e", // Proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // CallbackURL: "https://hooks.example.com/zerocaptcha", // to be called when it ends }) ``` For a challenge page, `solveChallenge` (`solve_challenge` in Python, `SolveChallenge` in Go) takes the page and your proxy, and returns the `cf_clearance` cookie with the user agent to send it with. Each language's page shows it. The clients are not in those registries yet. Until they are, call the API over plain HTTP, as the [quickstart](https://zerocaptcha.io/docs/quickstart) does in Python, Node, Go and curl. --- # Go SDK > The official ZeroCaptcha client for Go, standard library only: solve Cloudflare Turnstile and WAF challenge pages, handle errors and check callbacks. Source: https://zerocaptcha.io/docs/sdks/go The official ZeroCaptcha client for Go: create a Cloudflare Turnstile task or a Cloudflare challenge page's task, wait for its result, read your balance, and check a task callback's signature. It uses the standard library only, and needs Go 1.22 or later. Every task is real and paid from your prepaid balance, and only a task that succeeds is charged. ## Install ```sh go get github.com/zerocaptcha/zerocaptcha-go ``` The module is not published yet. Until it is, call the API with `net/http`, as the [quickstart](https://zerocaptcha.io/docs/quickstart)'s Go program does: it makes the same calls. ## Use Give the client your API key (`zc_live_…`, from the dashboard's API keys page) and the API's address. Keep both in your environment rather than in your code. ```go package main import ( "context" "errors" "fmt" "log" "os" "time" zerocaptcha "github.com/zerocaptcha/zerocaptcha-go" ) func main() { client, err := zerocaptcha.NewClient(os.Getenv("ZEROCAPTCHA_KEY"), os.Getenv("ZEROCAPTCHA_API")) if err != nil { log.Fatal(err) } ctx := context.Background() // Create a task and wait for its token: one call. token, err := client.Solve(ctx, zerocaptcha.NewTask{ WebsiteURL: "https://shop.example.com/login", // the page with the widget WebsiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. Action: "login", CData: "session-7f3a9c2e", // Proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // CallbackURL: "https://hooks.example.com/zerocaptcha", // to be called when it ends }) var failed *zerocaptcha.TaskFailedError switch { case errors.As(err, &failed): fmt.Println(failed.Code) // such as ERROR_CAPTCHA_UNSOLVABLE; nothing was charged case err != nil: log.Fatal(err) default: fmt.Println(token) } // Or step by step. task, err := client.CreateTask(ctx, zerocaptcha.NewTask{ WebsiteURL: "https://shop.example.com/login", WebsiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", Action: "login", // the widget's data-action, if it sets one CData: "session-7f3a9c2e", // the widget's data-cdata, if it sets one }, zerocaptcha.CreateOptions{ // Your ID for this task, sent as the Idempotency-Key; one is made for you when you give none. IdempotencyKey: "login-2026-10-01-0001", }) if err != nil { log.Fatal(err) } done, err := client.WaitForResult(ctx, task.ID, zerocaptcha.WaitOptions{Timeout: 2 * time.Minute}) if err != nil { log.Fatal(err) } fmt.Println(done.Solution.Token, done.Cost) // Your balance, in US dollars. balance, err := client.GetBalance(ctx) if err != nil { log.Fatal(err) } fmt.Println(balance.Available) } ``` | Function | What it does | | --- | --- | | `NewClient(apiKey, baseURL, options...)` | A client; `WithHTTPClient` swaps in your own `*http.Client`, such as one with a proxy. | | `CreateTask(ctx, NewTask{...}, CreateOptions{...})` | Creates a task: `WebsiteURL`, `WebsiteKey`, and optionally `Type`, `Action`, `CData`, `Proxy`, `CallbackURL`. | | `GetTask(ctx, id)` | Reads a task; its token is in `Solution` while it is available. | | `WaitForResult(ctx, id, WaitOptions{...})` | Polls every 2 seconds, for up to 3 minutes by default, until the task ends. | | `Solve(ctx, NewTask{...})` | `CreateTask` then `WaitForResult`: the token. | | `CreateChallengeTask(ctx, NewChallengeTask{...})` | Creates a Cloudflare challenge page's task, through your proxy: `WebsiteURL`, `Proxy`, optionally `CallbackURL`. | | `SolveChallenge(ctx, NewChallengeTask{...})` | `CreateChallengeTask` then `WaitForResult`: a `*Clearance` with `CfClearance`, `UserAgent` and `TokenExpiresAt`. | | `GetBalance(ctx)` | `Available`, `Held` and `Currency`, as decimal strings. | | `VerifySignature(secret, header, body, tolerance, now)` | Whether a callback is genuine; zero values mean five minutes and now. | - `Proxy: "http://user:pass@proxy.example.net:8080"` solves a task through your proxy. - `CreateTask` sends an `Idempotency-Key` with every call, one of its own unless you give one in `CreateOptions`, so retrying it never makes a second task. - A request the API asks you to slow down (429) or cannot serve for a moment (502, 503, 504) is tried again after the wait it asks for, three times in all, as is one that got no answer or an answer cut short, with the same `Idempotency-Key`. Any other refusal is an `*APIError` with the API's `Code`, such as `insufficient_funds`, and its `RequestID`. - `WaitForResult` never runs past its `Timeout`: each read gets only the time left, and a retry that would wait longer than that is not made. A task that fails or expires is a `*TaskFailedError`; a wait that runs out is a `*WaitTimeoutError`, with the task as last read (`Task`, nil if no read finished in time), and you can wait again. Each call stops when its context does. ## Cloudflare challenge pages A [challenge page](https://zerocaptcha.io/docs/challenges) is passed through your proxy, and gives the `cf_clearance` cookie with the user agent it is bound to. Send both, through the same proxy: ```go clearance, err := client.SolveChallenge(ctx, zerocaptcha.NewChallengeTask{ WebsiteURL: "https://shop.example.com/", Proxy: os.Getenv("PROXY_URL"), // such as http://user:pass@proxy.example.net:8080 }) ``` ## Callbacks A task created with `CallbackURL` is POSTed to it once it ends, with the task as JSON. Check each call's signature against the raw body, before you parse it: ```go func callback(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) secret := os.Getenv("ZEROCAPTCHA_CALLBACK_SECRET") if !zerocaptcha.VerifySignature(secret, r.Header.Get(zerocaptcha.SignatureHeader), body, 0, time.Time{}) { http.Error(w, "bad signature", http.StatusUnauthorized) return } w.WriteHeader(http.StatusNoContent) } ``` A call older than five minutes does not verify, so a recorded call cannot be replayed. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). --- # Node.js SDK > The official ZeroCaptcha client for JavaScript and TypeScript: solve Cloudflare Turnstile and WAF challenge pages, handle errors and check callbacks. Source: https://zerocaptcha.io/docs/sdks/node The official ZeroCaptcha client for JavaScript and TypeScript: create a Cloudflare Turnstile task or a Cloudflare challenge page's task, wait for its result, read your balance, and check a task callback's signature. It has no dependencies and runs in Node.js 20 and later, Deno, Bun and browsers (keep your key on a server, never in a page). Every task is real and paid from your prepaid balance, and only a task that succeeds is charged. ## Install ```sh npm install @zerocaptcha/sdk ``` The package is not on npm yet. Until it is, call the API with `fetch`, as the [quickstart](https://zerocaptcha.io/docs/quickstart)'s Node program does: it makes the same calls. ## Use Give the client your API key (`zc_live_…`, from the dashboard's API keys page) and the API's address. Keep both in your environment rather than in your code. ```ts import { TaskFailedError, ZeroCaptcha } from "@zerocaptcha/sdk"; const client = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY!, baseUrl: process.env.ZEROCAPTCHA_API!, }); // Create a task and wait for its token: one call. try { const token = await client.solve({ websiteURL: "https://shop.example.com/login", // the page with the widget websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", // its data-sitekey // The widget's data-action and data-cdata, or the action and cData options of // turnstile.render(). Leave out any the widget does not set. action: "login", cdata: "session-7f3a9c2e", // proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy // callbackUrl: "https://hooks.example.com/zerocaptcha", // to be called when it ends }); console.log(token); } catch (error) { if (error instanceof TaskFailedError) console.log(error.code); // such as ERROR_CAPTCHA_UNSOLVABLE else throw error; } // Or step by step. const task = await client.createTask( { websiteURL: "https://shop.example.com/login", websiteKey: "0x4AAAAAAAB1cD2eF3gH4iJ5", action: "login", // the widget's data-action, if it sets one cdata: "session-7f3a9c2e", // the widget's data-cdata, if it sets one }, // Your ID for this task, sent as the Idempotency-Key; one is made for you when you give none. { idempotencyKey: "login-2026-10-01-0001" }, ); const done = await client.waitForResult(task.id, { timeoutMs: 120_000 }); console.log(done.solution?.token, done.cost); // Your balance, in US dollars. const { available } = await client.getBalance(); ``` | Method | What it does | | --- | --- | | `new ZeroCaptcha({ apiKey, baseUrl, timeoutMs?, fetch? })` | A client; `timeoutMs` bounds each request (30 seconds by default), and `fetch` swaps in your own, such as one with a proxy agent. | | `createTask(task, { idempotencyKey?, signal? })` | Creates a task: `websiteURL`, `websiteKey`, and optionally `type`, `action`, `cdata`, `proxy`, `callbackUrl`. | | `getTask(id)` | Reads a task; its token is in `solution` while it is available. | | `waitForResult(id, { timeoutMs?, intervalMs?, signal? })` | Polls every 2 seconds, for up to 3 minutes by default, until the task ends. | | `solve(task, options?)` | `createTask` then `waitForResult`: the token. | | `createChallengeTask({ websiteURL, proxy, callbackUrl? })` | Creates a Cloudflare challenge page's task, through your proxy. | | `solveChallenge({ websiteURL, proxy }, options?)` | `createChallengeTask` then `waitForResult`: `{ cfClearance, userAgent, tokenExpiresAt }`. | | `getBalance()` | `{ available, held, currency }`, as decimal strings. | | `verifySignature(secret, header, rawBody, { toleranceSeconds?, now? })` | Whether a callback is genuine. | - A task with `proxy` (such as `http://user:pass@proxy.example.net:8080`) is solved through your proxy, as `TurnstileTask`; without one it is `TurnstileTaskProxyless`. - `createTask` sends an `Idempotency-Key` with every call, one of its own unless you give yours, so retrying it never makes a second task. - A request the API asks you to slow down (429) or cannot serve for a moment (502, 503, 504) is tried again after the wait it asks for, three times in all, as is one that got no answer or an answer cut short, with the same `Idempotency-Key`. Any other refusal throws a `ZeroCaptchaError` with the API's `code`, such as `insufficient_funds`, and its `requestId`. - `waitForResult` never runs past `timeoutMs`: a slow read is cut off, and a retry that would wait longer than the time left is not made. It throws `TaskFailedError` when the task fails or expires, and nothing is charged; a wait that runs out throws `WaitTimeoutError`, with the task as last read (`task`, undefined if no read finished in time), and you can wait again. ## Cloudflare challenge pages A [challenge page](https://zerocaptcha.io/docs/challenges) is passed through your proxy, and gives the `cf_clearance` cookie with the user agent it is bound to. Send both, through the same proxy: ```ts const { cfClearance, userAgent } = await client.solveChallenge({ websiteURL: "https://shop.example.com/", proxy: process.env.PROXY_URL!, // such as http://user:pass@proxy.example.net:8080 }); ``` ## Callbacks A task that names `callbackUrl` is POSTed to it once it ends, with the task as JSON. Check each call's signature against the raw body, before you parse it: ```ts import { verifySignature } from "@zerocaptcha/sdk"; export async function handle(request: Request): Promise { const rawBody = await request.text(); const genuine = await verifySignature( process.env.ZEROCAPTCHA_CALLBACK_SECRET!, request.headers.get("zerocaptcha-signature"), rawBody, ); if (!genuine) return new Response(null, { status: 401 }); const task = JSON.parse(rawBody); console.log(task.id, task.status); return new Response(null, { status: 204 }); } ``` A call older than five minutes does not verify, so a recorded call cannot be replayed. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). --- # Python SDK > The official ZeroCaptcha client for Python, standard library only: solve Cloudflare Turnstile and WAF challenge pages, handle errors and check callbacks. Source: https://zerocaptcha.io/docs/sdks/python The official ZeroCaptcha client for Python: create a Cloudflare Turnstile task or a Cloudflare challenge page's task, wait for its result, read your balance, and check a task callback's signature. It uses the standard library only, and runs on Python 3.9 and later. Every task is real and paid from your prepaid balance, and only a task that succeeds is charged. ## Install ```sh pip install zerocaptcha ``` The package is not on PyPI yet. Until it is, call the API with `requests`, as the [quickstart](https://zerocaptcha.io/docs/quickstart)'s Python program does: it makes the same calls. ## Use Give the client your API key (`zc_live_…`, from the dashboard's API keys page) and the API's address. Keep both in your environment rather than in your code. ```python import os from zerocaptcha import TaskFailedError, ZeroCaptcha client = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"]) # Create a task and wait for its token: one call. try: token = client.solve( website_url="https://shop.example.com/login", # the page with the widget website_key="0x4AAAAAAAB1cD2eF3gH4iJ5", # its data-sitekey # The widget's data-action and data-cdata, or the action and cData options of # turnstile.render(). Leave out any the widget does not set. action="login", cdata="session-7f3a9c2e", # proxy="http://user:pass@proxy.example.net:8080", # to solve through your own proxy # callback_url="https://hooks.example.com/zerocaptcha", # to be called when it ends ) print(token) except TaskFailedError as failed: print(failed.code) # such as ERROR_CAPTCHA_UNSOLVABLE; nothing was charged # Or step by step. task = client.create_task( website_url="https://shop.example.com/login", website_key="0x4AAAAAAAB1cD2eF3gH4iJ5", action="login", # the widget's data-action, if it sets one cdata="session-7f3a9c2e", # the widget's data-cdata, if it sets one # Your ID for this task, sent as the Idempotency-Key; one is made for you when you give none. idempotency_key="login-2026-10-01-0001", ) done = client.wait_for_result(task["id"], timeout=120) print(done["solution"]["token"], done["cost"]) # Your balance, in US dollars. print(client.get_balance()["available"]) ``` | Method | What it does | | --- | --- | | `ZeroCaptcha(api_key, base_url)` | A client. | | `create_task(website_url, website_key, type=, action=, cdata=, proxy=, callback_url=, idempotency_key=)` | Creates a task. | | `get_task(task_id)` | Reads a task; its token is in `["solution"]` while it is available. | | `wait_for_result(task_id, timeout=180, interval=2)` | Polls until the task ends. | | `solve(website_url, website_key, **task)` | `create_task` then `wait_for_result`: the token. | | `create_challenge_task(website_url, proxy, callback_url=, idempotency_key=)` | Creates a Cloudflare challenge page's task, through your proxy. | | `solve_challenge(website_url, proxy)` | `create_challenge_task` then `wait_for_result`: `{"cf_clearance", "user_agent", "token_expires_at"}`. | | `get_balance()` | `{"available", "held", "currency"}`, as decimal strings. | | `verify_signature(secret, header, raw_body, tolerance=300)` | Whether a callback is genuine. | - Tasks come back as dictionaries, as the API writes them (`id`, `status`, `cost`, `solution`…). - `proxy="http://user:pass@proxy.example.net:8080"` solves a task through your proxy. - `create_task` sends an `Idempotency-Key` with every call, one of its own unless you give `idempotency_key`, so retrying it never makes a second task. - A request the API asks you to slow down (429) or cannot serve for a moment (502, 503, 504) is tried again after the wait it asks for, three times in all, as is one that got no answer or an answer cut short, with the same `Idempotency-Key`. Any other refusal raises `ZeroCaptchaError` with the API's `code`, such as `insufficient_funds`, and its `request_id`. - `wait_for_result` never runs past `timeout`: each read gets only the time left, and a retry that would wait longer than that is not made. It raises `TaskFailedError` when the task fails or expires; a wait that runs out raises `WaitTimeoutError`, with the task as last read (`task`, `None` if no read finished in time), and you can wait again. ## Cloudflare challenge pages A [challenge page](https://zerocaptcha.io/docs/challenges) is passed through your proxy, and gives the `cf_clearance` cookie with the user agent it is bound to. Send both, through the same proxy: ```python clearance = client.solve_challenge( "https://shop.example.com/", os.environ["PROXY_URL"], # such as http://user:pass@proxy.example.net:8080 ) print(clearance["cf_clearance"], clearance["user_agent"]) ``` ## Callbacks A task created with `callback_url` is POSTed to it once it ends, with the task as JSON. Check each call's signature against the raw body, before you parse it. With Flask: ```python import json import os from flask import Flask, abort, request from zerocaptcha import verify_signature app = Flask(__name__) @app.post("/zerocaptcha/callback") def callback(): if not verify_signature( os.environ["ZEROCAPTCHA_CALLBACK_SECRET"], request.headers.get("ZeroCaptcha-Signature"), request.get_data(), # the raw bytes, before parsing ): abort(401) task = json.loads(request.get_data()) print(task["id"], task["status"]) return "", 204 ``` A call older than five minutes does not verify, so a recorded call cannot be replayed. See [Polling and callbacks](https://zerocaptcha.io/docs/callbacks). --- # Teams and roles > Invite people to your ZeroCaptcha account as owners or members, what each role can do, and the activity log that records every change. Source: https://zerocaptcha.io/docs/teams An account can have several people, each with their own email, password and second factors, all sharing the account's keys, tasks and balance. Whoever signs up owns the account; invite the rest from the dashboard's **Team** page. ## Roles | | Owner | Member | | --- | :---: | :---: | | Use the account's API keys, and see the keys page | Yes | Yes | | See tasks, usage and the balance | Yes | Yes | | Create, rotate, restrict and revoke keys; set spend caps | Yes | No | | Top up, see receipts, set billing details and the low-balance email | Yes | No | | See and rotate the callback signing secret | Yes | No | | Invite people, change roles, remove people, read the activity log | Yes | No | | Leave the team | Yes | Yes | A member who tries an owner's action is refused with [`role_required`](https://zerocaptcha.io/docs/reference/errors#role_required) (HTTP 403). API keys are the account's, not a person's: removing someone does not revoke a key they created or used. Rotate the keys they had (see [API keys](https://zerocaptcha.io/docs/keys#rotate-a-key)) if that matters to you. ## Invite someone 1. On **Team**, enter their email address and choose a role. 2. We email them a link. It works once, for 7 days, and only while the address has no ZeroCaptcha account: an address belongs to one account. 3. They open it, choose a password, and are signed in. The link proves their address, so it counts as verified. Inviting the same address again replaces the open invitation, and its old link stops working. An account may have 20 invitations open at once. Revoke one from the Team page and its link stops working at once. ## Change roles and remove people Owners change a person's role or remove them from the Team page; anyone may leave. An account always keeps an owner: the last owner can neither leave, be removed, nor become a member, so promote someone first. Two owners changing each other's roles at the same moment cannot both succeed. Removing someone deletes their user: they are signed out everywhere, and their password, passkeys and second factors go with it. To bring them back, invite them again. ## The activity log Every change to the team, and every rotation of the callback secret, is recorded: who did what, to whom, and when. Owners read it on the Team page. --- # API reference > Every operation your code can call, generated from the OpenAPI contract, version 0.1.0. The API is at https://api.zerocaptcha.io; the whole contract is at https://zerocaptcha.io/openapi.json. ## [Tasks](https://zerocaptcha.io/docs/reference/api/tasks) - List tasks: `GET /v1/tasks` - Create a task: `POST /v1/tasks` - Live task updates: `GET /v1/tasks/events` - Get a task: `GET /v1/tasks/{id}` - Send a task's callback again: `POST /v1/tasks/{id}/callback/resend` - Report a task's token: `POST /v1/tasks/{id}/report` ## [Compatible format](https://zerocaptcha.io/docs/reference/api/compatible) - Create a task (compatible): `POST /createTask` - Report how a token did (CapSolver): `POST /feedbackTask` - Get the balance (compatible): `POST /getBalance` - Get a task's result (compatible): `POST /getTaskResult` - Report a token that worked (2Captcha): `POST /reportCorrect` - Report a token that worked (Anti-Captcha): `POST /reportCorrectRecaptcha` - Report a refused token (2Captcha): `POST /reportIncorrect` - Report a refused token (Anti-Captcha): `POST /reportIncorrectRecaptcha` ## [2Captcha format](https://zerocaptcha.io/docs/reference/api/2captcha) - Submit a task (2Captcha): `GET /in.php` - Submit a task by POST (2Captcha): `POST /in.php` - Get a result or the balance (2Captcha): `GET /res.php` ## [Balance](https://zerocaptcha.io/docs/reference/api/balance) - Get the balance: `GET /v1/balance` ## [Prices](https://zerocaptcha.io/docs/reference/api/prices) - List prices: `GET /v1/prices` ## [Status feed](https://zerocaptcha.io/docs/reference/api/status) - Platform status: `GET /v1/status` ## [Site-owner opt-out](https://zerocaptcha.io/docs/reference/api/opt-out) - Ask to opt a domain out: `POST /v1/opt-out-requests` ## [Abuse reports](https://zerocaptcha.io/docs/reference/api/abuse-reports) - Report abuse: `POST /v1/abuse-reports` ## [Support messages](https://zerocaptcha.io/docs/reference/api/support) - Contact us: `POST /v1/contact-messages` - Write to support: `POST /v1/support-messages` ## [Demo pages](https://zerocaptcha.io/docs/reference/api/demo) - Check for a Cloudflare clearance: `GET /v1/demo/clearance` - Check a demo token: `POST /v1/demo/verify` ## [Health](https://zerocaptcha.io/docs/reference/api/health) - Liveness probe: `GET /healthz` - Readiness probe: `GET /readyz` ## [The contract](https://zerocaptcha.io/docs/reference/api/contract) - This contract: `GET /openapi.json` --- # Tasks API > Create Turnstile tasks, read their results, and follow them live. Source: https://zerocaptcha.io/docs/reference/api/tasks ## List tasks `GET /v1/tasks` (`listTasks`) Newest first, with cursor pagination. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `status` | query | no | Only tasks with this status. | | `limit` | query | no | Tasks per page, 1 to 100; 50 by default. | | `cursor` | query | no | `nextCursor` from the previous page. | | `idempotencyKey` | query | no | Only the task created with this Idempotency-Key, to recover a reply that was lost. | Responses: - 200 OK: A page of tasks. - 429 Too Many Requests: Over a budget for reading (`rate_limited`); retry after `Retry-After`, and poll less often. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/tasks \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks", { headers: { Authorization: "Bearer YOUR_API_KEY", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/tasks", headers={"Authorization": "Bearer YOUR_API_KEY"}, timeout=30, ) print(response.status_code, response.text) ``` ## Create a task `POST /v1/tasks` (`createTask`) Queues a task and holds its price on your balance; the price is charged only if the task succeeds. Send an `Idempotency-Key` to retry safely: for 24 hours, the same key and request return the first reply instead of a second task, and the same key with a different request is refused. Creations are bounded by your balance and your account's share of the queue, and by no rate budget unless the service sets one. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Your ID for this task, 1 to 255 visible ASCII characters. | Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | Responses: - 201 Created: The task, queued; or, for a retry with the same `Idempotency-Key`, the first reply. - 409 Conflict: A request with this `Idempotency-Key` is still being processed (`idempotency_key_in_use`); retry after `Retry-After` for its reply. - 429 Too Many Requests: The queue share is full (`queue_full`), or a budget for creating the service has set is spent (`rate_limited`). Nothing was created or charged; retry after `Retry-After`. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/tasks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: order-4521-attempt-1" \ -H "Content-Type: application/json" \ -d '{ "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Idempotency-Key": "order-4521-attempt-1", "Content-Type": "application/json", }, body: JSON.stringify({ "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/tasks", headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": "order-4521-attempt-1"}, json={ "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login", }, timeout=30, ) print(response.status_code, response.text) ``` ## Live task updates `GET /v1/tasks/events` (`streamTaskEvents`) Server-sent events for the caller's tasks: `task` carries a task (without its token) each time it changes, `reset` asks the client to refetch its list because updates were missed. Start from a list's `liveCursor`. Opening a stream draws on the caller's budget for reads, as a list read does; keeping it open costs nothing more. The stream ends when its key or session no longer allows it, or when its client stops taking events; an account may hold only a few streams open at once. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `since` | query | no | Where to start: `liveCursor` from a task list, or the last event's ID. | | `Last-Event-ID` | header | no | The last event's ID, sent by a reconnecting browser. | Responses: - 200 OK: An event stream. - 429 Too Many Requests: Over a budget for reading, or as many streams open as the account, or this server, may hold (`rate_limited`); reconnect less often, or close a stream, and retry after `Retry-After`. - Any other status: An error, as RFC 9457 problem details. ```bash curl -N https://api.zerocaptcha.io/v1/tasks/events \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks/events", { headers: { Authorization: "Bearer YOUR_API_KEY", }, }); // Events arrive as they happen; stop with Ctrl+C. const decoder = new TextDecoder(); for await (const chunk of response.body) { process.stdout.write(decoder.decode(chunk, { stream: true })); } ``` ```python import requests # Events arrive as they happen; stop with Ctrl+C. with requests.get( "https://api.zerocaptcha.io/v1/tasks/events", headers={"Authorization": "Bearer YOUR_API_KEY"}, stream=True, timeout=(10, None), ) as response: for line in response.iter_lines(decode_unicode=True): print(line) ``` ## Get a task `GET /v1/tasks/{id}` (`getTask`) The task's status, cost and, while it is valid, its token: for a challenge page, its clearance cookie and the user agent it is bound to. A task that named a callback URL shows it with where its delivery stands and each attempt: when, the HTTP status, and when the next is due. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `id` | path | yes | The task's ID. | Responses: - 200 OK: The task. - 429 Too Many Requests: Over a budget for reading (`rate_limited`); retry after `Retry-After`, and poll less often. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", { headers: { Authorization: "Bearer YOUR_API_KEY", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", headers={"Authorization": "Bearer YOUR_API_KEY"}, timeout=30, ) print(response.status_code, response.text) ``` ## Send a task's callback again `POST /v1/tasks/{id}/callback/resend` (`resendTaskCallback`) Calls the task's callback URL again, as soon as the worker gets to it, with eight more attempts if it fails: once the callback was delivered or given up, or after its record was deleted. It is signed and shaped as the first call was, and carries the same `ZeroCaptcha-Delivery` ID while its record lasts. A key needs the `tasks:write` scope; a session must be an owner's (`role_required`), with the CSRF token. A suspended account sends none (`account_suspended`). | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `id` | path | yes | The task's ID. | Responses: - 202 Accepted: Queued: the callback as it stands now. - 403 Forbidden: A member's session, not an owner's (`role_required`), a key without `tasks:write` (`insufficient_scope`), or a suspended account (`account_suspended`). - 404 Not Found: No task of this account has this ID, or the task names no callback URL (`not_found`). - 409 Conflict: The task has not ended yet, or its callback is still being delivered (`state_conflict`). - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/callback/resend \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/callback/resend", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/callback/resend", headers={"Authorization": "Bearer YOUR_API_KEY"}, timeout=30, ) print(response.status_code, response.text) ``` ## Report a task's token `POST /v1/tasks/{id}/report` (`reportTask`) Says whether the site accepted a solved task's token: `bad` when it refused it, `good` when it worked. The report is recorded for our staff, who watch the solvers' quality with it; tasks are final, so it refunds nothing. One report per task, and only on a task that succeeded. Needs the `tasks:write` scope; with a session, the CSRF token. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `id` | path | yes | The task's ID. | Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `verdict` | Verdict | yes | `bad` when the site refused the token, `good` when it accepted it. | Responses: - 201 Created: Recorded. - 404 Not Found: No task of this account has this ID (`not_found`). - 409 Conflict: The task did not succeed, so there is no token to report on, or it has a report already (`state_conflict`). - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/report \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "verdict": "bad" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/report", { method: "POST", headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json", }, body: JSON.stringify({ "verdict": "bad" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/tasks/0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b/report", headers={"Authorization": "Bearer YOUR_API_KEY"}, json={ "verdict": "bad", }, timeout=30, ) print(response.status_code, response.text) ``` --- # Compatible format API > The createTask format other providers use, so existing clients work unchanged. Its errors come in its errorId shape, with HTTP 200; only a failure outside the endpoint, such as a body over the size limit, is a problem document. Source: https://zerocaptcha.io/docs/reference/api/compatible ## Create a task (compatible) `POST /createTask` (`compatCreateTask`) The createTask call other providers use. The body is `{"clientKey": "…", "task": {"type": "TurnstileTaskProxyless", "websiteURL": "…", "websiteKey": "…"}}`; the reply is `{"errorId": 0, "taskId": "…"}`. An `Idempotency-Key` header works as it does on REST. Creations share the key's and the account's budgets, if the service sets any, with `POST /v1/tasks`. Over one, the reply is `ERROR_RATE_LIMIT` with `Retry-After`, and nothing is created or charged. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Your ID for this task, 1 to 255 visible ASCII characters, as on `POST /v1/tasks`. | Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `task` | CompatAnyTask | yes | A Turnstile task, or a Cloudflare challenge page's, told apart by `type`. A missing task or `type` is `ERROR_TASK_ABSENT`, another type, a proxyless challenge among them, `ERROR_TASK_NOT_SUPPORTED`, and any other invalid field `ERROR_INVALID_TASK_DATA`. | | `callbackUrl` | string (uri) or 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. | Responses: - 200 OK: `errorId` 0 with `taskId`, or 1 with `errorCode` and `errorDescription`. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/createTask \ -H "Idempotency-Key: order-4521-attempt-1" \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "task": { "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login" } }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/createTask", { method: "POST", headers: { "Idempotency-Key": "order-4521-attempt-1", "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "task": { "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login" } }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/createTask", headers={"Idempotency-Key": "order-4521-attempt-1"}, json={ "clientKey": "YOUR_API_KEY", "task": { "type": "TurnstileTaskProxyless", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "websiteURL": "https://example.com/login", }, }, timeout=30, ) print(response.status_code, response.text) ``` ## Report how a token did (CapSolver) `POST /feedbackTask` (`compatFeedbackTask`) CapSolver's `feedbackTask`: `{"clientKey": "…", "taskId": "…", "result": {"invalid": true}}` says the site refused the token, `false` that it took it; `invalid` may also come beside `result`. Recorded for our staff, never refunded. Without `invalid`, `ERROR_INVALID_REQUEST`. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `result` | CompatFeedbackResult | yes | What the site made of the token. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. | Responses: - 200 OK: `errorId` 0 with `status: "success"`, or 1 with `errorCode` and `errorDescription`: `ERROR_NO_SUCH_CAPCHA_ID` for a task this key's account does not have, `ERROR_REPORT_NOT_RECORDED` for one that did not succeed, and `ERROR_DUPLICATE_REPORT` for one reported already. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/feedbackTask \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "result": { "invalid": true }, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/feedbackTask", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "result": { "invalid": true }, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/feedbackTask", json={ "clientKey": "YOUR_API_KEY", "result": { "invalid": True, }, "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` ## Get the balance (compatible) `POST /getBalance` (`compatGetBalance`) `{"clientKey": "…"}`; the reply is `{"errorId": 0, "balance": 12.3456}`, the available balance in US dollars, printed exactly. Reads share their budgets with REST; over one, the reply is `ERROR_RATE_LIMIT` with `Retry-After`. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | Responses: - 200 OK: The balance, or an error in the dialect's shape. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/getBalance \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/getBalance", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/getBalance", json={ "clientKey": "YOUR_API_KEY", }, timeout=30, ) print(response.status_code, response.text) ``` ## Get a task's result (compatible) `POST /getTaskResult` (`compatGetTaskResult`) `{"clientKey": "…", "taskId": "…"}`. While the task runs the reply is `{"errorId": 0, "status": "processing"}`; once solved it is `status: "ready"` with `solution.token` and `cost`, and for a challenge page `solution.userAgent` and `solution.cookies` too. A failed task, or one whose token expired, replies with `errorId: 1` and its `errorCode`. Polls share the budgets for reads with REST; over one, the reply is `ERROR_RATE_LIMIT` with `Retry-After`. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. One that names no task of this key's account is `ERROR_NO_SUCH_CAPCHA_ID`. | Responses: - 200 OK: The task's state, or an error in the dialect's shape. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/getTaskResult \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/getTaskResult", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/getTaskResult", json={ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` ## Report a token that worked (2Captcha) `POST /reportCorrect` (`compatReportCorrect`) 2Captcha's `reportCorrect`: `{"clientKey": "…", "taskId": "…"}` says the site took the solved task's token. Recorded for our staff; one report per task, on a task that succeeded. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. One that names no task of this key's account is `ERROR_NO_SUCH_CAPCHA_ID`. | Responses: - 200 OK: `errorId` 0 with `status: "success"`, or 1 with `errorCode` and `errorDescription`: `ERROR_NO_SUCH_CAPCHA_ID` for a task this key's account does not have, `ERROR_REPORT_NOT_RECORDED` for one that did not succeed, and `ERROR_DUPLICATE_REPORT` for one reported already. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/reportCorrect \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/reportCorrect", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/reportCorrect", json={ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` ## Report a token that worked (Anti-Captcha) `POST /reportCorrectRecaptcha` (`compatReportCorrectRecaptcha`) Anti-Captcha's `reportCorrectRecaptcha`: the same as `/reportCorrect`. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. One that names no task of this key's account is `ERROR_NO_SUCH_CAPCHA_ID`. | Responses: - 200 OK: `errorId` 0 with `status: "success"`, or 1 with `errorCode` and `errorDescription`: `ERROR_NO_SUCH_CAPCHA_ID` for a task this key's account does not have, `ERROR_REPORT_NOT_RECORDED` for one that did not succeed, and `ERROR_DUPLICATE_REPORT` for one reported already. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/reportCorrectRecaptcha \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/reportCorrectRecaptcha", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/reportCorrectRecaptcha", json={ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` ## Report a refused token (2Captcha) `POST /reportIncorrect` (`compatReportIncorrect`) 2Captcha's `reportIncorrect`: `{"clientKey": "…", "taskId": "…"}` says the site refused the solved task's token. It is recorded for our staff, who watch the solvers' quality with it; tasks are final, so it refunds nothing. One report per task, on a task that succeeded. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. One that names no task of this key's account is `ERROR_NO_SUCH_CAPCHA_ID`. | Responses: - 200 OK: `errorId` 0 with `status: "success"`, or 1 with `errorCode` and `errorDescription`: `ERROR_NO_SUCH_CAPCHA_ID` for a task this key's account does not have, `ERROR_REPORT_NOT_RECORDED` for one that did not succeed, and `ERROR_DUPLICATE_REPORT` for one reported already. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/reportIncorrect \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/reportIncorrect", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/reportIncorrect", json={ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` ## Report a refused token (Anti-Captcha) `POST /reportIncorrectRecaptcha` (`compatReportIncorrectRecaptcha`) Anti-Captcha's `reportIncorrectRecaptcha`, which its clients send for a token task: the same as `/reportIncorrect`. Recorded, never refunded. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `clientKey` | string | yes | Your API key, `zc_live_…`. A missing or unknown one is `ERROR_KEY_DOES_NOT_EXIST`. | | `taskId` | string (uuid) | yes | The task's ID, as createTask gave it. One that names no task of this key's account is `ERROR_NO_SUCH_CAPCHA_ID`. | Responses: - 200 OK: `errorId` 0 with `status: "success"`, or 1 with `errorCode` and `errorDescription`: `ERROR_NO_SUCH_CAPCHA_ID` for a task this key's account does not have, `ERROR_REPORT_NOT_RECORDED` for one that did not succeed, and `ERROR_DUPLICATE_REPORT` for one reported already. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with `errorId` 1. ```bash curl -X POST https://api.zerocaptcha.io/reportIncorrectRecaptcha \ -H "Content-Type: application/json" \ -d '{ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/reportIncorrectRecaptcha", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/reportIncorrectRecaptcha", json={ "clientKey": "YOUR_API_KEY", "taskId": "0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b", }, timeout=30, ) print(response.status_code, response.text) ``` --- # 2Captcha format API > 2Captcha's in.php and res.php for Turnstile, so a 2Captcha client works after changing only its base URL and key. Priced and charged as the other dialects are. Every reply is HTTP 200, as plain text or, with `json=1`, JSON; only a failure outside the endpoint is a problem document. Source: https://zerocaptcha.io/docs/reference/api/2captcha ## Submit a task (2Captcha) `GET /in.php` (`twoCaptchaSubmit`) 2Captcha's `in.php` for Cloudflare Turnstile: `method=turnstile` with `sitekey` and `pageurl`, and optionally `action`, `data`, `proxy` and `pingback`. The reply is `OK|`, or `{"status": 1, "request": ""}` with `json=1`; poll `res.php` with the ID. Priced, held and charged as `POST /v1/tasks` is; an `Idempotency-Key` header works as it does there. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `key` | query | yes | Your API key, `zc_live_…`. A missing or malformed one is `ERROR_WRONG_USER_KEY`, an unknown or revoked one `ERROR_KEY_DOES_NOT_EXIST`. | | `method` | query | yes | `turnstile`; any other method is `ERROR_BAD_PARAMETERS`. | | `sitekey` | query | yes | The widget's site key. | | `pageurl` | query | yes | The page the widget is on: a public http or https page, as `websiteURL` is on REST. Missing or invalid, `ERROR_PAGEURL`. | | `action` | query | no | The widget's action, if it sets one. | | `data` | query | no | The widget's cData, if it sets one. | | `pingback` | query | no | Where to POST the result once the task ends: 2Captcha's pingback form, `id` and `code`, signed with your callback secret. Any public URL, with no registration. | | `json` | query | no | `1` for JSON replies; `0`, the default, for plain text. | | `proxy` | query | no | Your proxy as `login:password@host:port` or `host:port`; the task then runs through it. | | `proxytype` | query | no | `HTTP`, the default, or `HTTPS`. SOCKS is not supported yet (`ERROR_PROXY_FORMAT`). | | `soft_id` | query | no | Accepted and ignored. | | `header_acao` | query | no | Accepted and ignored: this service sends no CORS headers. | | `pagedata` | query | no | Accepted and ignored: Cloudflare challenge pages are not supported. | | `userAgent` | query | no | Accepted and ignored. | | `Idempotency-Key` | header | no | Your ID for this task, 1 to 255 visible ASCII characters, as on `POST /v1/tasks`. | Responses: - 200 OK: `OK|`, or an error code such as `ERROR_ZERO_BALANCE`; with `json=1`, the same as JSON. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with an error code. ```bash curl https://api.zerocaptcha.io/in.php?key=key&method=method&sitekey=sitekey&pageurl=pageurl \ -H "Idempotency-Key: order-4521-attempt-1" ``` ```js const response = await fetch("https://api.zerocaptcha.io/in.php?key=key&method=method&sitekey=sitekey&pageurl=pageurl", { headers: { "Idempotency-Key": "order-4521-attempt-1", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/in.php?key=key&method=method&sitekey=sitekey&pageurl=pageurl", headers={"Idempotency-Key": "order-4521-attempt-1"}, timeout=30, ) print(response.status_code, response.text) ``` ## Submit a task by POST (2Captcha) `POST /in.php` (`twoCaptchaSubmitForm`) `in.php` as a POST, the way 2Captcha's own clients send it: the parameters as a form (`application/x-www-form-urlencoded` or `multipart/form-data`) or a JSON object, and any in the query string too, which win. The replies are those of `GET /in.php`. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Your ID for this task, 1 to 255 visible ASCII characters, as on `POST /v1/tasks`. | Responses: - 200 OK: `OK|`, or an error code such as `ERROR_ZERO_BALANCE`; with `json=1`, the same as JSON. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with an error code. ```bash curl -X POST https://api.zerocaptcha.io/in.php \ -H "Idempotency-Key: order-4521-attempt-1" ``` ```js const response = await fetch("https://api.zerocaptcha.io/in.php", { method: "POST", headers: { "Idempotency-Key": "order-4521-attempt-1", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/in.php", headers={"Idempotency-Key": "order-4521-attempt-1"}, timeout=30, ) print(response.status_code, response.text) ``` ## Get a result or the balance (2Captcha) `GET /res.php` (`twoCaptchaResult`) 2Captcha's `res.php`. `action=get&id=` answers `CAPCHA_NOT_READY` while the task runs, then `OK|`, or an error code such as `ERROR_CAPTCHA_UNSOLVABLE` (nothing charged) or `ERROR_TOKEN_EXPIRED`; `action=get2` adds the price, `OK||`. `action=get&ids=,,…` reads up to 100 tasks at once: each one's token, `CAPCHA_NOT_READY` or error code, in order, joined by `|`. `action=reportbad` or `reportgood` with `id` records whether the site took the token (`OK_REPORT_RECORDED`): recorded for our staff, never refunded, as tasks are final. `action=getbalance` answers the available balance in US dollars, such as `12.3456`. With `json=1`, each as `{"status": …, "request": …}`. Reads share their budgets with REST; over one, the reply is `ERROR: 1005` with `Retry-After`. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `key` | query | yes | Your API key, as for in.php. | | `action` | query | yes | `get` for a task's token (or several tasks' with `ids`), `get2` for its token and price, `reportbad` or `reportgood` to say whether the site took its token, or `getbalance` for the available balance. Missing, `ERROR_EMPTY_ACTION`. | | `id` | query | no | The task's ID, as in.php gave it, for `get`, `get2`, `reportbad` and `reportgood`; a task's UUID from another format works too. Not a task ID, `ERROR_WRONG_ID_FORMAT`; no task of this key's account, `ERROR_WRONG_CAPTCHA_ID`. | | `ids` | query | no | With `action=get`, in place of `id`: up to 100 task IDs, comma-separated. The reply is each one's answer in order, joined by `\|`, such as `CAPCHA_NOT_READY\|0.AbC…\|ERROR_CAPTCHA_UNSOLVABLE`. | | `json` | query | no | `1` for JSON replies; `0`, the default, for plain text. | Responses: - 200 OK: The token, `CAPCHA_NOT_READY`, several tasks' answers, `OK_REPORT_RECORDED`, the balance, or an error code; with `json=1`, the same as JSON. - Any other status: Only a failure outside the dialect, as RFC 9457 problem details: a body over the size limit (413), a request that ran out of time (504), this instance shedding load (503, with `Retry-After`) or a fault in the server (500). Every other failure is HTTP 200 with an error code. ```bash curl https://api.zerocaptcha.io/res.php?key=key&action=action ``` ```js const response = await fetch("https://api.zerocaptcha.io/res.php?key=key&action=action"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/res.php?key=key&action=action", timeout=30, ) print(response.status_code, response.text) ``` --- # Balance API > The prepaid balance and what is held for tasks in progress. Source: https://zerocaptcha.io/docs/reference/api/balance ## Get the balance `GET /v1/balance` (`getBalance`) The available balance and what is held for tasks in progress. Responses: - 200 OK: The balance. - 429 Too Many Requests: Over a budget for reading (`rate_limited`); retry after `Retry-After`. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/balance \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/balance", { headers: { Authorization: "Bearer YOUR_API_KEY", }, }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/balance", headers={"Authorization": "Bearer YOUR_API_KEY"}, timeout=30, ) print(response.status_code, response.text) ``` --- # Prices API > What each task type costs now and the next change scheduled: public, with no key or session, and cacheable. Source: https://zerocaptcha.io/docs/reference/api/prices ## List prices `GET /v1/prices` (`listPrices`) What each task type costs: the price a task created now is charged if it succeeds, and the next change already scheduled. It needs no key or session. Production lists approved prices only; a task type without one is listed with no price in effect, and its tasks are refused until it has one. A task is always charged the price in effect when it was created. Every caller gets the same reply, which caches may keep for up to five minutes; send the `ETag` of a reply you hold as `If-None-Match` to get 304 while it is current. Requests have a budget per client address. | Parameter | In | Required | What it is | | --- | --- | --- | --- | | `If-None-Match` | header | no | The `ETag` of a reply you hold. While the prices are unchanged, the reply is 304 with no body. | Responses: - 200 OK: The prices. - 304 Not Modified: The prices are those of the reply whose `ETag` was sent: keep using it. - 429 Too Many Requests: Over the budget of this client address (`rate_limited`); retry after `Retry-After`, and keep a copy of the list rather than asking again. - 503 Service Unavailable: The prices cannot be read now (`service_unavailable`): the database cannot be reached, or this instance is shedding load. Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/prices ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/prices"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/prices", timeout=30, ) print(response.status_code, response.text) ``` --- # Status feed API > The platform's last 24 hours: task success rate, median solve time and API availability. Public, with no key or session, and cacheable. Source: https://zerocaptcha.io/docs/reference/api/status ## Platform status `GET /v1/status` (`getStatus`) The last 24 hours of the platform, as the status page shows them: the share of finished tasks that succeeded, the median time to a token, and the share of minutes in which the API was serving. A figure drawn from too few tasks, or with no minute to judge yet, is `null`. It needs no key or session. Every caller gets the same reply, which caches may keep for a minute. Requests have a budget per client address. Responses: - 200 OK: The figures. - 429 Too Many Requests: Over the budget of this client address (`rate_limited`); retry after `Retry-After`. - 503 Service Unavailable: The figures cannot be read now (`service_unavailable`): the database cannot be reached, or this instance is shedding load. A status page shows the status as unknown. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/status ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/status"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/status", timeout=30, ) print(response.status_code, response.text) ``` --- # Site-owner opt-out API > Site owners asking for their domain to be excluded: public, with no key or session. Staff check and decide each request by hand. Source: https://zerocaptcha.io/docs/reference/api/opt-out ## Ask to opt a domain out `POST /v1/opt-out-requests` (`requestOptOut`) Asks for a domain, and everything under it, to be excluded: tasks against it refused. The request is stored and sent to our staff, who check that you speak for the domain and answer you by email; nothing changes until they decide. It needs no key or session. A browser's request must send no `Sec-Fetch-Site` but `same-origin` or `none` (`csrf_rejected`). Requests have a budget per client address, then per domain. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `domain` | string | yes | The domain to exclude, such as `example.com`; everything under it is excluded with it. An internationalized one is kept in ASCII. | | `email` | string (email) | yes | Where staff answer you. | | `message` | string | no | Anything staff should know, such as how they can confirm you run the site: up to 2,000 characters. | Responses: - 202 Accepted: Received: staff will answer by email. - 403 Forbidden: A browser's request from another site (`csrf_rejected`). - 422 Unprocessable Content: Not a domain, not an email address, or a message over 2,000 characters (`validation_failed`). - 429 Too Many Requests: Too many requests from this address, or for this domain (`rate_limited`). - 503 Service Unavailable: The request cannot be stored now (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/opt-out-requests \ -H "Content-Type: application/json" \ -d '{ "domain": "example.com", "email": "owner@example.com" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/opt-out-requests", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "domain": "example.com", "email": "owner@example.com" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/opt-out-requests", json={ "domain": "example.com", "email": "owner@example.com", }, timeout=30, ) print(response.status_code, response.text) ``` --- # Abuse reports API > Reporting a site ZeroCaptcha was used against: public, with no key or session. Staff look into each report and act by hand. Source: https://zerocaptcha.io/docs/reference/api/abuse-reports ## Report abuse `POST /v1/abuse-reports` (`reportAbuse`) Reports a site that ZeroCaptcha was used against without permission. The report is stored and sent to our staff, who look into it and act by hand: suspending the customer, or refusing tasks for the site. Nothing changes automatically. It needs no key or session. A browser's request must send no `Sec-Fetch-Site` but `same-origin` or `none` (`csrf_rejected`). Reports have a budget per client address. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `site` | string | yes | The site ZeroCaptcha was used against: its domain, such as `shop.example.com`, or an address on it. | | `what` | string | yes | What happened: 1 to 5,000 characters. | | `email` | string (email) | no | Where staff may answer you, if you want an answer. | | `evidenceUrl` | string (uri) | no | A link to evidence, such as logs or a screenshot: `http` or `https`, up to 2,048 characters. | Responses: - 202 Accepted: Received: staff will look into it. - 403 Forbidden: A browser's request from another site (`csrf_rejected`). - 422 Unprocessable Content: Not a site, nothing said, an evidence link that is not http or https, or not an email address (`validation_failed`). - 429 Too Many Requests: Too many reports from this address (`rate_limited`). - 503 Service Unavailable: The report cannot be stored now (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/abuse-reports \ -H "Content-Type: application/json" \ -d '{ "site": "shop.example.com", "what": "what" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/abuse-reports", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "site": "shop.example.com", "what": "what" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/abuse-reports", json={ "site": "shop.example.com", "what": "what", }, timeout=30, ) print(response.status_code, response.text) ``` --- # Support messages API > Messages to support: from the dashboard with a session, or from the public contact form with none. Staff answer by email. Source: https://zerocaptcha.io/docs/reference/api/support ## Contact us `POST /v1/contact-messages` (`sendContactMessage`) Sends a message to support from the public site: stored, and emailed to our support inbox. Staff answer by email, at the address given. It needs no key or session. A browser's request must send no `Sec-Fetch-Site` but `same-origin` or `none` (`csrf_rejected`). Messages have a budget per client address. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `email` | string (email) | yes | Where staff answer you. | | `message` | string | yes | The message: 1 to 5,000 characters. | | `subject` | string | no | What it is about, in a line: up to 200 characters. Left out, it is "Message from the contact form". | Responses: - 202 Accepted: Received: staff will answer by email. - 403 Forbidden: A browser's request from another site (`csrf_rejected`). - 422 Unprocessable Content: Not an email address, no message, one over 5,000 characters, or a subject over 200 (`validation_failed`). - 429 Too Many Requests: Too many messages from this address (`rate_limited`). - 503 Service Unavailable: The message cannot be stored now (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/contact-messages \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com", "message": "message" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/contact-messages", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "email": "you@example.com", "message": "message" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/contact-messages", json={ "email": "you@example.com", "message": "message", }, timeout=30, ) print(response.status_code, response.text) ``` ## Write to support `POST /v1/support-messages` (`sendSupportMessage`) Sends a message to support from the dashboard: stored, and emailed to our support inbox. Staff answer by email, at the signed-in person's address. A suspended account may write too, and appeals its suspension with `topic: appeal`, which reaches staff marked as an appeal. With the session's CSRF token. Messages have a budget per user. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `message` | string | yes | The message: 1 to 5,000 characters. Task IDs help. | | `subject` | string | no | What it is about, in a line: up to 200 characters. Left out, it is "Question from the dashboard". | | `topic` | SupportTopic | no | `question`, the default, or `appeal`: an appeal of the account's suspension, which only a suspended account may send. Left out, a question. An appeal without a subject is "Appeal of a suspension". | Responses: - 202 Accepted: Received: staff will answer by email. - 409 Conflict: An appeal from an account that is not suspended (`state_conflict`). - 422 Unprocessable Content: No message, one over 5,000 characters, or a subject over 200 (`validation_failed`). - 429 Too Many Requests: Too many messages from this person (`rate_limited`). - 503 Service Unavailable: The message cannot be stored now (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/support-messages \ -H "Content-Type: application/json" \ -d '{ "message": "message", "subject": "A charge I don'\''t recognise" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/support-messages", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "message": "message", "subject": "A charge I don't recognise" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/support-messages", json={ "message": "message", "subject": "A charge I don't recognise", }, timeout=30, ) print(response.status_code, response.text) ``` --- # Demo pages API > The public demo and CAPTCHA test pages: checking a token from one of their Cloudflare Turnstile widgets with Cloudflare's siteverify, and whether a request carried a Cloudflare clearance. Public, with no key or session; nothing here solves anything. Source: https://zerocaptcha.io/docs/reference/api/demo ## Check for a Cloudflare clearance `GET /v1/demo/clearance` (`getDemoClearance`) Says whether this request carried a `cf_clearance` cookie, the one Cloudflare sets once a challenge, or a Cloudflare Turnstile widget with pre-clearance, is passed, and whether it came through Cloudflare's network. The demo challenge pages call it from the browser, which sends the cookie that page scripts may not be able to read. The cookie's value is never read out, logged or kept, and only Cloudflare can say whether it is still valid. It needs no key or session. Requests have the budget per client address that public reads share. Never cached. Responses: - 200 OK: What the request carried. - 429 Too Many Requests: Too many requests from this address (`rate_limited`). - 503 Service Unavailable: The budget could not be checked now (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/v1/demo/clearance ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/demo/clearance"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/v1/demo/clearance", timeout=30, ) print(response.status_code, response.text) ``` ## Check a demo token `POST /v1/demo/verify` (`verifyDemoToken`) Asks Cloudflare's siteverify about a token from one of the demo pages' Cloudflare Turnstile widgets, with that widget's secret, and returns the verdict as it came: success, the hostname, the action, the cData and the error codes. Solves nothing: bring a token a widget gave, in a browser or through a task. A token passes siteverify once, within 300 seconds of the solve. It needs no key or session. A browser's request must send no `Sec-Fetch-Site` but `same-origin` or `none` (`csrf_rejected`). Checks have a budget per client address. The token is never logged or kept. Body fields: | Field | Type | Required | What it is | | --- | --- | --- | --- | | `token` | string | yes | The token: the widget's `cf-turnstile-response`, or the `token` a Cloudflare Turnstile task returned for the page, up to 2,048 characters. | | `widget` | DemoWidget | yes | The demo widget the token came from. | Responses: - 200 OK: Cloudflare's verdict, a failing one included. - 403 Forbidden: A browser's request from another site (`csrf_rejected`). - 422 Unprocessable Content: An unknown widget, or no token, or not one siteverify takes (`validation_failed`). - 429 Too Many Requests: Too many checks from this address (`rate_limited`). - 503 Service Unavailable: Cloudflare's siteverify did not answer, or this server does not serve the demo (`service_unavailable`). Retry shortly. - Any other status: An error, as RFC 9457 problem details. ```bash curl -X POST https://api.zerocaptcha.io/v1/demo/verify \ -H "Content-Type: application/json" \ -d '{ "token": "token", "widget": "managed" }' ``` ```js const response = await fetch("https://api.zerocaptcha.io/v1/demo/verify", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "token": "token", "widget": "managed" }), }); console.log(response.status, await response.text()); ``` ```python import requests response = requests.post( "https://api.zerocaptcha.io/v1/demo/verify", json={ "token": "token", "widget": "managed", }, timeout=30, ) print(response.status_code, response.text) ``` --- # Health API > Liveness and readiness probes for load balancers and orchestrators. Source: https://zerocaptcha.io/docs/reference/api/health ## Liveness probe `GET /healthz` (`getLiveness`) Answers while the process can serve HTTP, without checking any dependency. Responses: - 200 OK: The process is up. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/healthz ``` ```js const response = await fetch("https://api.zerocaptcha.io/healthz"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/healthz", timeout=30, ) print(response.status_code, response.text) ``` ## Readiness probe `GET /readyz` (`getReadiness`) Answers when the service can reach its database, which must reply within two seconds. Responses: - 200 OK: Ready for traffic. - 503 Service Unavailable: The database cannot be reached. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/readyz ``` ```js const response = await fetch("https://api.zerocaptcha.io/readyz"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/readyz", timeout=30, ) print(response.status_code, response.text) ``` --- # The contract API > Documents about the API itself, such as this contract. Source: https://zerocaptcha.io/docs/reference/api/contract ## This contract `GET /openapi.json` (`getOpenApi`) The OpenAPI 3.1 document that describes this API. Responses: - 200 OK: The OpenAPI document. - Any other status: An error, as RFC 9457 problem details. ```bash curl https://api.zerocaptcha.io/openapi.json ``` ```js const response = await fetch("https://api.zerocaptcha.io/openapi.json"); console.log(response.status, await response.text()); ``` ```python import requests response = requests.get( "https://api.zerocaptcha.io/openapi.json", timeout=30, ) print(response.status_code, response.text) ``` --- # 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 . - `POST /v1/tasks` (createTask): Create a task. Auth: Authorization: Bearer . - `GET /v1/tasks/events` (streamTaskEvents): Live task updates. Auth: Authorization: Bearer . - `GET /v1/tasks/{id}` (getTask): Get a task. Auth: Authorization: Bearer . - `POST /v1/tasks/{id}/callback/resend` (resendTaskCallback): Send a task's callback again. Auth: Authorization: Bearer . - `POST /v1/tasks/{id}/report` (reportTask): Report a task's token. Auth: Authorization: Bearer . - `GET /v1/balance` (getBalance): Get the balance. Auth: Authorization: Bearer . - `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 `. 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=&code=. - Each call carries `ZeroCaptcha-Signature: t=,v1=.` 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=`. ## 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 [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 [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 [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 [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 [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, "", ¤t); 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 [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 [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 [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.