# 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
<?php
// Before
// $base = 'https://2captcha.com';
// $key = getenv('TWOCAPTCHA_KEY');

// After
$base = getenv('ZEROCAPTCHA_API');
$key = getenv('ZEROCAPTCHA_KEY');

$query = http_build_query([
    '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.
$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|<token>` 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
<?php
// Before: $base = 'https://api.2captcha.com';
$base = getenv('ZEROCAPTCHA_API');
$created = json_decode(file_get_contents("$base/createTask", false, stream_context_create(['http' => [
    '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.
