Skip to content

Tutorial

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.

By 6 min readPublished Updated

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 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.
  • 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 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:

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

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:

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

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.

Sources

The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.

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.

Read next

This article is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key