2Captcha format
More
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
Section titled “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.
# 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"{ "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. |
pingback |
No | A URL to call with the result when the task ends. See Polling and 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 in REST or the createTask format. |
The reply is OK|<task id> in plain text, or {"status": 1, "request": "<task id>"} 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.
Read the result: res.php
Section titled “Read the result: res.php”GET /res.php?key=…&action=get&id=<task 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|<token> |
{"status": 1, "request": "<token>"} |
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|<token>|<price> 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
Section titled “Report a token: reportbad and reportgood”Once a task is solved, GET /res.php?key=…&action=reportbad&id=<task 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
Section titled “Samples”# 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; fiTASK_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"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}`);}import osimport timeimport 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')}")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$api = getenv('ZEROCAPTCHA_API');$key = getenv('ZEROCAPTCHA_KEY');
$submitted = json_decode(file_get_contents("$api/in.php", false, stream_context_create(['http' => [ '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
Section titled “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.
| 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
Section titled “What differs from 2Captcha”- Only
method=turnstile. Other CAPTCHA types are not offered, and Cloudflare challenge pages need the createTask format or REST, whose replies carry the user agent a clearance needs. pingbackneeds no registration: any public URL works, and each call is signed so you can check it came from us.reportbadnever 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.