# 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|<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.

> **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=<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

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

**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
<?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

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).
