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 ZeroCaptcha Engineering6 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, andZEROCAPTCHA_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 osimport timeimport 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:
- A semaphore caps the tasks in flight, so a burst of 5,000 pages does not become 5,000 simultaneous tasks polling at once.
- An idempotency key per task, sent as the
Idempotency-Keyheader. IfcreateTasktimes 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. - Backoff that honors
Retry-Afterfor HTTP 429 and 5xx replies, and for the “try again later” codesERROR_RATE_LIMIT,ERROR_NO_SLOT_AVAILABLEandERROR_SERVICE_UNAVAILABLE.
Many tokens with httpx and asyncio
import asyncioimport osimport 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_codeUse 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,
createTaskanswersERROR_NO_SLOT_AVAILABLE. Each task in flight polls every 2 seconds, so 50 tasks make about 25 reads a second. If you seeERROR_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
callbackUrlto 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
- httpx: async support, with timeouts and pool limits (checked 1 October 2026).
- ZeroCaptcha’s limits, for the 50 tasks queued or running per account and the read budgets.
- Requests documentation and PyPI: 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, for the token’s lifetime and single use (checked 1 October 2026).
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.