# Python httpx and requests: Solve Cloudflare Turnstile at Scale

> Get Cloudflare Turnstile tokens from Python with requests for one form, or httpx and asyncio for hundreds at once, with retries that never pay twice.

- Source: https://zerocaptcha.io/blog/python-httpx-cloudflare-turnstile
- Published: 2026-09-30
- Updated: 2026-10-01
- Author: ZeroCaptcha Engineering

To get a Cloudflare Turnstile token from Python, send the page URL and its sitekey to a solving
API's `createTask`, then poll `getTaskResult` until the token is ready: about ten lines with
`requests`. When you need hundreds of tokens, switch to `httpx` with `asyncio`, cap how many tasks
are in flight, and make every retry safe with an `Idempotency-Key`. This tutorial builds both, and
the async version is the one to copy for a pipeline.

The [Python page of the Cloudflare Turnstile solver](https://zerocaptcha.io/cloudflare-turnstile-solver/python) shows the tested quickstart program and the coming SDK. This
article builds the calls itself, with requests and then httpx, so you can see every request and fit it
into an existing client.

## What you need

- Python 3.10 or later (the current requests release needs it), and `pip install requests httpx`.
- An API key and a funded balance: see the [quickstart](https://zerocaptcha.io/docs/quickstart).
- Two environment variables: `ZEROCAPTCHA_API`, the API's base URL, and `ZEROCAPTCHA_KEY`, your
  key.
- The page's URL and sitekey. [Find a Cloudflare Turnstile sitekey](https://zerocaptcha.io/guides/find-cloudflare-turnstile-sitekey)
  shows where it sits in the HTML.

## One token with requests

For a script that fills one form at a time, `requests` is all you need:

```python
import os
import time
import uuid

import requests

API = os.environ["ZEROCAPTCHA_API"]
KEY = os.environ["ZEROCAPTCHA_KEY"]


def solve_turnstile(page_url: str, sitekey: str, action: str | None = None, cdata: str | None = None) -> str:
    task = {"type": "TurnstileTaskProxyless", "websiteURL": page_url, "websiteKey": sitekey, "metadata": {}}
    # The widget's data-action and data-cdata go in metadata, only when it sets them: many sites
    # check both when they verify the token.
    if action:
        task["metadata"]["action"] = action
    if cdata:
        task["metadata"]["cdata"] = cdata
    # One Idempotency-Key per task: a retried create with it returns the same task.
    created = requests.post(f"{API}/createTask", json={"clientKey": KEY, "task": task},
                            headers={"Idempotency-Key": str(uuid.uuid4())}, timeout=15).json()
    if created["errorId"]:
        raise RuntimeError(f"createTask: {created['errorCode']}")
    deadline = time.monotonic() + 180
    while time.monotonic() < deadline:
        time.sleep(2)
        result = requests.post(
            f"{API}/getTaskResult",
            json={"clientKey": KEY, "taskId": created["taskId"]},
            timeout=15,
        ).json()
        if result["errorId"]:
            raise RuntimeError(f"getTaskResult: {result['errorCode']}")
        if result["status"] == "ready":
            return result["solution"]["token"]
    raise TimeoutError("no token within 180 seconds")


if __name__ == "__main__":
    print(solve_turnstile("https://shop.example.com/login", "0x4AAAAAAAB1cD2eF3gH4iJ5",
                          action="login", cdata="session-7f3a9c2e"))
```

Two details matter. The format answers HTTP 200 even when a call fails, so the code checks
`errorId` on every reply. And a token works once, for 300 seconds, so call `solve_turnstile` when
your code reaches the form, not at the start of a batch. [Cloudflare Turnstile token expiry](https://zerocaptcha.io/guides/cloudflare-turnstile-token-expiry)
explains why.

## Why async pays off

A solve takes seconds, and your process spends nearly all of it waiting. With `requests`, each
wait holds a thread. With `httpx.AsyncClient`, one event loop can wait on every task your account
may have in flight at once (50 queued or running by default), and one connection pool serves all
of them.

The async version below adds three things a pipeline needs:

1. **A semaphore** caps the tasks in flight, so a burst of 5,000 pages does not become 5,000
   simultaneous tasks polling at once.
2. **An idempotency key per task**, sent as the `Idempotency-Key` header. If `createTask` times
   out and is sent again with the same key, the API returns the task the first request created
   instead of making a second one. See [idempotency keys](https://zerocaptcha.io/guides/idempotency-keys-for-captcha-tasks).
3. **Backoff that honors `Retry-After`** for HTTP 429 and 5xx replies, and for the
   "try again later" codes `ERROR_RATE_LIMIT`, `ERROR_NO_SLOT_AVAILABLE` and
   `ERROR_SERVICE_UNAVAILABLE`.

## Many tokens with httpx and asyncio

```python
import asyncio
import os
import uuid

import httpx

API = os.environ["ZEROCAPTCHA_API"]
KEY = os.environ["ZEROCAPTCHA_KEY"]
BUSY = {"ERROR_RATE_LIMIT", "ERROR_NO_SLOT_AVAILABLE", "ERROR_SERVICE_UNAVAILABLE"}


async def call(client: httpx.AsyncClient, method: str, body: dict, headers: dict | None = None) -> dict:
    pause = 1.0
    while True:
        try:
            reply = await client.post(f"/{method}", json={"clientKey": KEY, **body}, headers=headers)
        except httpx.TransportError:
            await asyncio.sleep(pause)  # safe: createTask carries an Idempotency-Key
            pause = min(pause * 2, 16)
            continue
        retry_after = reply.headers.get("retry-after", "")
        wait = float(retry_after) if retry_after.isdigit() else pause
        if reply.status_code == 429 or reply.status_code >= 500:
            await asyncio.sleep(wait)
            pause = min(pause * 2, 16)
            continue
        data = reply.json()
        if data["errorId"] and data["errorCode"] in BUSY:
            await asyncio.sleep(wait)
            pause = min(pause * 2, 16)
            continue
        if data["errorId"]:
            raise RuntimeError(f"{method}: {data['errorCode']}")
        return data


async def solve_turnstile(client: httpx.AsyncClient, page_url: str, sitekey: str,
                          action: str | None = None, cdata: str | None = None) -> str:
    task = {"type": "TurnstileTaskProxyless", "websiteURL": page_url, "websiteKey": sitekey, "metadata": {}}
    # The widget's data-action and data-cdata go in metadata, only when it sets them.
    if action:
        task["metadata"]["action"] = action
    if cdata:
        task["metadata"]["cdata"] = cdata
    created = await call(client, "createTask", {"task": task}, {"Idempotency-Key": str(uuid.uuid4())})
    loop = asyncio.get_running_loop()
    deadline = loop.time() + 180
    while loop.time() < deadline:
        await asyncio.sleep(2)
        result = await call(client, "getTaskResult", {"taskId": created["taskId"]})
        if result["status"] == "ready":
            return result["solution"]["token"]
    raise TimeoutError(f"task {created['taskId']}: no token within 180 seconds")


async def main(pages: list[tuple[str, str]], in_flight: int = 50) -> None:
    limit = asyncio.Semaphore(in_flight)
    timeout = httpx.Timeout(15.0)
    limits = httpx.Limits(max_connections=in_flight)
    async with httpx.AsyncClient(base_url=API, timeout=timeout, limits=limits) as client:

        async def one(page_url: str, sitekey: str) -> None:
            async with limit:
                try:
                    token = await solve_turnstile(client, page_url, sitekey)
                except (RuntimeError, TimeoutError) as error:
                    print(page_url, "failed:", error)
                    return
            # Use the token now: submit the form it belongs to (see below).
            print(page_url, token[:24] + "…")

        await asyncio.gather(*(one(url, key) for url, key in pages))


if __name__ == "__main__":
    pages = [(f"https://shop.example.com/item/{n}", "0x4AAAAAAAB1cD2eF3gH4iJ5") for n in range(200)]
    asyncio.run(main(pages))
```

Each task gets a fresh UUID as
its idempotency key, so retrying a lost `createTask` is always safe, while two different pages
never share one. A task that fails, such as `ERROR_CAPTCHA_UNSOLVABLE`, raises and costs nothing:
only solved tasks are charged.

## Submitting the token with the same client

Cloudflare Turnstile puts its token in a hidden input named `cf-turnstile-response`. Post it with
the rest of the form, from the same session that loaded the page, so the site's cookies match:

```python
import httpx


async def submit_login(page_url: str, form_action: str, token: str) -> int:
    async with httpx.AsyncClient(follow_redirects=True, timeout=15) as site:
        await site.get(page_url)  # sets the site's session cookies
        reply = await site.post(
            form_action,
            data={"email": "me@example.com", "password": "…", "cf-turnstile-response": token},
        )
        return reply.status_code
```

Use a separate client for the site and for the API: they have different base URLs, cookies and
timeouts. If the site answers with a validation error for any reason, get a new token before the
next attempt, because the site has already redeemed the old one. [Submit a Cloudflare Turnstile token](https://zerocaptcha.io/guides/submit-cloudflare-turnstile-token)
covers sites that read the token from a JavaScript callback instead of the form.

## Sizing `in_flight`

- **Start around 50.** That is how many tasks an account may have queued or running at once by
  default; above it, `createTask` answers `ERROR_NO_SLOT_AVAILABLE`. Each task in flight polls
  every 2 seconds, so 50 tasks make about 25 reads a second. If you see `ERROR_RATE_LIMIT`, lower the number or poll every 3 seconds.
- **Keep the pipeline downstream as fast as the solves.** A token that waits in a queue for your
  form submitter is losing its 300 seconds. Put the submission inside `one()`, right after the
  token arrives, as the comment shows.
- **Replace polling with callbacks** when you run thousands of tasks: add a `callbackUrl` to the
  task and the result is posted to you. See [captcha solver callbacks](https://zerocaptcha.io/guides/captcha-solver-callbacks).

## Common errors

| Error | Meaning | Fix |
| --- | --- | --- |
| `ERROR_KEY_DOES_NOT_EXIST` | The key is wrong or incomplete | Copy it again from the dashboard |
| `ERROR_KEY_REVOKED` | The key was revoked, or replaced by a rotation | Use a working key from the dashboard |
| `ERROR_NO_SLOT_AVAILABLE` | Your account's tasks in flight are at its limit | The code above waits and retries; lower `in_flight` |
| `ERROR_ZERO_BALANCE` | The balance cannot cover the task | Add funds |
| `ERROR_CAPTCHA_UNSOLVABLE` | The task failed, at no charge | Check the URL and sitekey, then retry |
| `ERROR_TOKEN_EXPIRED` | The token was ready but read too late | Poll more often; use tokens at once |
| `httpx.ReadTimeout` | No reply within 15 seconds | Retry; the idempotency key makes it safe |

Every code, with whether a retry helps, is in the [errors reference](https://zerocaptcha.io/docs/reference/errors).

## Sources

- [httpx: async support](https://www.python-httpx.org/async/), with timeouts and pool limits
  (checked 1 October 2026).
- ZeroCaptcha's [limits](https://zerocaptcha.io/docs/reference/limits), for the 50 tasks queued or running per account
  and the read budgets.
- [Requests documentation](https://requests.readthedocs.io/) and [PyPI](https://pypi.org/project/requests/):
  requests 2.34.2 needs Python 3.10 or later; httpx 0.28.1 is the current release (checked 1
  October 2026).
- [Cloudflare Turnstile: server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/),
  for the token's lifetime and single use (checked 1 October 2026).

## Questions

### Should I use requests or httpx to call a CAPTCHA-solving API?

Either works. requests is simplest for one form at a time. httpx with asyncio lets one process wait on many tasks at once, because waiting for a Cloudflare Turnstile token is almost all idle time.

### How many Cloudflare Turnstile tasks can one Python process run at once?

As many as your account may have queued or running, 50 by default: the waiting is asynchronous, so one process can hold all of them. Cap the number in flight with a semaphore so you stay inside that share and the API's read budget, and use each token as soon as it is ready.

### What happens if a createTask request times out in Python?

Send it again with the same Idempotency-Key header. The API answers with the task the first request created, so a lost reply never creates or charges a second task.
