Cloudflare Turnstile is one of the most widely deployed bot-detection widgets in production today. Unlike legacy CAPTCHAs, Turnstile is often invisible — it fires silently, validates in the background, and injects a token into a hidden field before the user even clicks submit.
This guide walks through two complementary problems:
- Interception — how to reliably capture the
sitekey,action, and other parameters CaptchaAI needs. - Solving — how to send those parameters to CaptchaAI, receive a token in under 10 seconds, and inject it so the target page accepts it.
All examples use Python + Playwright (async).
Prerequisites
pip install playwright requests
playwright install chromium
CaptchaAI is called over plain HTTPS — no SDK install is required. The
requestslibrary is enough.
You will also need a CaptchaAI API key. Set it as an environment variable:
export CAPTCHAAI_API_KEY="your_api_key_here"
Why Interception Matters
When a site uses explicit Turnstile rendering, the sitekey is passed directly to turnstile.render() in JavaScript — it may never appear in a DOM attribute. Standard DOM scraping returns nothing:
# This fails on many modern sites
sitekey = await page.get_attribute("[data-sitekey]", "data-sitekey") # returns None
You need to hook into the JS call itself. The reliable solution is to install an init script — a JavaScript fragment that runs before any page JS, including Turnstile.
Understanding the Turnstile Lifecycle
Browser loads page
→ Page JS calls turnstile.render(container, { sitekey, action, callback, … })
→ Turnstile widget fires a challenge in the background
→ On success, Turnstile calls params.callback(token)
→ Token is injected into a hidden <input cf-turnstile-response>
→ Form submit includes the token
→ Server verifies the token with Cloudflare
Your automation replaces the middle section: intercept the render() call to capture parameters, solve the challenge externally, then inject the token and trigger the callback.
End-to-end lifecycle
sequenceDiagram
participant Browser as Headless browser<br/>(Playwright)
participant TS as Cloudflare Turnstile<br/>(challenges.cloudflare.com)
participant Cap as CaptchaAI<br/>(ocr.captchaai.com)
participant Site as Target site backend
Browser->>TS: Load page → Turnstile widget injects
Note over Browser: Init script wraps<br/>turnstile.render()
Browser->>Browser: Capture {websiteKey,<br/>websiteURL, action}
Browser->>Cap: POST task (TurnstileTaskProxyless)
Cap-->>Browser: taskId
loop poll every 3s
Browser->>Cap: GET status?taskId
Cap-->>Browser: pending / ready+token
end
Browser->>Browser: Inject token into<br/>cf-turnstile-response + call __tsCallback
Browser->>Site: Submit form with token
Site->>TS: siteverify
TS-->>Site: success: true
Site-->>Browser: 200 OK
Most integration bugs live in one of three steps: the capture step (wrong sitekey, missed action), the inject step (forgot to call the callback), or the submit step (lost session / changed UA).
Step 1: Install the Interception Script
Create an init script that hooks turnstile.render() and logs the captured parameters as a JSON console message:
// turnstile_interceptor.js — install via context.add_init_script()
(function () {
const PREFIX = "TS_INTERCEPT:";
function buildPayload(params) {
return {
type: "TurnstileTaskProxyless",
websiteKey: params.sitekey,
websiteURL: window.location.href,
action: params.action || null,
data: params.cData || null,
pagedata: params.chlPageData || null,
userAgent: navigator.userAgent,
};
}
const tryPatch = function () {
if (window.turnstile && (window.turnstile.render || window.turnstile.execute)) {
const originalRender = window.turnstile.render;
const originalExecute = window.turnstile.execute;
if (originalRender) {
window.turnstile.render = function (container, params) {
const payload = buildPayload(params);
window.__tsCallback = params.callback;
window.__tsInterceptData = payload;
console.log(PREFIX + JSON.stringify(payload));
return originalRender.call(this, container, params);
};
}
if (originalExecute) {
window.turnstile.execute = function (container, params) {
const payload = buildPayload(params);
window.__tsCallback = params.callback;
console.log(PREFIX + JSON.stringify(payload));
return originalExecute.call(this, container, params);
};
}
return true;
}
return false;
};
if (!tryPatch()) {
const interval = setInterval(function () {
if (tryPatch()) clearInterval(interval);
}, 10);
}
})();
Step 2: Capture Parameters in Playwright
import asyncio
import json
import os
import time
from pathlib import Path
import requests
from playwright.async_api import async_playwright
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
INTERCEPTOR_JS = Path(__file__).parent / "turnstile_interceptor.js"
captured: dict = {}
def on_console(msg):
"""Parse TS_INTERCEPT: JSON payloads emitted by the init script."""
text = msg.text
if not text.startswith("TS_INTERCEPT:"):
return
try:
data = json.loads(text[len("TS_INTERCEPT:"):].strip())
if "websiteKey" in data and "websiteURL" in data:
captured.update(data)
except json.JSONDecodeError:
pass
Step 3: Send the Task to CaptchaAI
CaptchaAI accepts Turnstile tasks at the legacy https://ocr.captchaai.com/in.php endpoint. Submit key, method=turnstile, sitekey, and pageurl as form fields. Pass json=1 to get a structured response (the endpoint also accepts plain-text OK|<task_id> if you omit json):
def create_turnstile_task(website_key: str, website_url: str, action: str | None = None) -> str:
"""Submit a Turnstile task to /in.php. Returns the task ID."""
payload = {
"key": API_KEY,
"method": "turnstile",
"sitekey": website_key,
"pageurl": website_url,
"json": "1",
}
if action:
payload["action"] = action
response = requests.post(
"https://ocr.captchaai.com/in.php",
data=payload,
timeout=30,
)
result = response.json()
if result.get("status") != 1:
raise RuntimeError(f"CaptchaAI submit error: {result.get('request')}")
return result["request"] # task ID
API style — both endpoints accept form-encoded POST or GET with query parameters. We use
data=(form POST) here;params=(GET) works equivalently. Usejson=1for{"status": …, "request": …}JSON responses; omitjsonfor legacyOK|<token>/ERROR_CODEplain-text responses.
Step 4: Poll for the Solution
CaptchaAI solves Turnstile in under 10 seconds. Poll /res.php every 5 seconds with action=get and the task ID returned from Step 3. A status: 0 response with request: "CAPCHA_NOT_READY" means keep polling; status: 1 means the request field contains the token:
def get_turnstile_token(task_id: str, timeout: int = 60) -> str:
"""Poll /res.php until the task is solved. Returns the Turnstile token."""
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(5)
response = requests.get(
"https://ocr.captchaai.com/res.php",
params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": "1",
},
timeout=30,
)
result = response.json()
if result.get("status") == 1:
return result["request"] # the Turnstile token
if result.get("request") != "CAPCHA_NOT_READY":
raise RuntimeError(f"CaptchaAI poll error: {result.get('request')}")
raise TimeoutError(f"Turnstile task {task_id} did not complete within {timeout} seconds")
Step 5: Inject the Token
Once you have the token, inject it into every cf-turnstile-response input on the page and invoke the original callback so Turnstile marks itself as solved:
async def inject_token(page, token: str) -> None:
"""Inject the solved Turnstile token and trigger the callback."""
# 1. Write token into the hidden input(s)
await page.evaluate(
"""(token) => {
document.querySelectorAll(
'input[name="cf-turnstile-response"]'
).forEach(el => { el.value = token; });
}""",
token,
)
# 2. Trigger the registered Turnstile callback (if any)
await page.evaluate(
"""(token) => {
if (typeof window.__tsCallback === "function") {
window.__tsCallback(token);
}
}""",
token,
)
Complete End-to-End Example
async def solve_turnstile_page(url: str) -> None:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
)
# Install init script BEFORE any page JS runs
await context.add_init_script(INTERCEPTOR_JS.read_text())
page = await context.new_page()
page.on("console", on_console)
# Navigate and wait for Turnstile to fire
await page.goto(url, wait_until="networkidle")
await page.wait_for_timeout(2000)
if not captured.get("websiteKey"):
raise RuntimeError("Turnstile parameters were not captured. Check the init script.")
print(f"Captured: websiteKey={captured['websiteKey']} action={captured.get('action')}")
# Solve with CaptchaAI
task_id = create_turnstile_task(
website_key=captured["websiteKey"],
website_url=captured["websiteURL"],
action=captured.get("action"),
)
print(f"Task submitted: {task_id}")
token = get_turnstile_token(task_id)
print(f"Token received: {token[:40]}…")
# Inject and submit
await inject_token(page, token)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle")
print("Form submitted successfully.")
await browser.close()
if __name__ == "__main__":
asyncio.run(solve_turnstile_page("https://example.com/protected-form"))
Common Pitfalls
Token not accepted by the server
Turnstile tokens are single-use and expire quickly (approximately 5 minutes (~300 seconds)). Always inject and submit immediately after receiving the token — never cache or reuse tokens across requests.
Callback not triggering — form stays locked
If the form waits for the Turnstile callback before enabling the submit button, injecting the hidden input alone is not enough. You must also call window.__tsCallback(token) as shown in the inject step.
If the callback variable was not captured by the init script (e.g. Turnstile rendered before the script ran), fall back to:
await page.evaluate(
"""(token) => {
// Try the global Turnstile API
if (window.turnstile && window.turnstile.isExpired) {
document.querySelectorAll('[data-sitekey]').forEach(el => {
el.dispatchEvent(new Event('turnstile-callback', { bubbles: true }));
});
}
}""",
token,
)
websiteKey is None after navigation
If captured is empty after networkidle, the page may load Turnstile in an iframe. Check:
# List all frames
for frame in page.frames:
print(frame.url)
If Turnstile lives in a child frame, attach the console listener to that frame too:
page.on("frameattached", lambda frame: frame.on("console", on_console))
Wrong User-Agent
Turnstile ties its validation to the browser fingerprint. Set user_agent in your Playwright context to a realistic, up-to-date string. CaptchaAI uses the same pool of real browser fingerprints, so mismatches are rare — but if you get consistent TOKEN_ALREADY_USED errors, check that your context UA matches what Turnstile received during solving.
Best Practices
| Practice | Why it matters |
|---|---|
Always use add_init_script at context level |
Applies to all frames and popups, not just the main frame |
Use networkidle + a 2-second extra wait |
Gives late-loading SPAs time to call turnstile.render() |
Include action when available |
Some sites verify the action server-side |
| Implement retry logic (max 3 attempts) | Rare solver failures are recoverable |
| Never reuse tokens | Tokens are single-use and short-lived |
| Store API keys in environment variables | Prevents accidental credential leakage |
| Close context after each task | Avoid fingerprint contamination across sessions |
Performance
CaptchaAI solves Cloudflare Turnstile in under 10 seconds on average with a consistently high success rate (typically 95%+ on healthy sitekeys). This makes it suitable for high-throughput scraping pipelines where per-task solve time is a bottleneck.
For batch workloads, submit multiple tasks concurrently using asyncio.gather:
task_ids = await asyncio.gather(*[
asyncio.to_thread(create_turnstile_task, key, url)
for key, url in batch
])
Summary
| Step | Action |
|---|---|
| 1 | Install interceptor as init script before navigation |
| 2 | Navigate to target page; wait for networkidle |
| 3 | Capture websiteKey, websiteURL, action from console |
| 4 | POST /in.php with method=turnstile, sitekey, pageurl, json=1 |
| 5 | Poll /res.php with action=get every 5 seconds until status == 1 |
| 6 | Inject token into cf-turnstile-response and call callback |
| 7 | Submit form and verify response |
Related articles in this series
Part of the Turnstile Mastery series — nine practical guides covering Turnstile end-to-end:
- Cloudflare Turnstile Widget Modes Explained
- Cloudflare Turnstile Implementation Detection
- Cloudflare Turnstile Sitekey Extraction
- Cloudflare Turnstile Interception Methods
- Cloudflare Turnstile CaptchaAI Solving Guide — you are here
- Cloudflare Turnstile Token Handoff with CaptchaAI
- Cloudflare Turnstile Token Expiration and Timing
- Cloudflare Turnstile 403 After Token Fix
- Cloudflare Turnstile Errors and Troubleshooting