Tutorials

How to Solve Cloudflare Turnstile with CaptchaAI: Complete Playwright Guide

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:

  1. Interception — how to reliably capture the sitekey, action, and other parameters CaptchaAI needs.
  2. 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 requests library 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. Use json=1 for {"status": …, "request": …} JSON responses; omit json for legacy OK|<token> / ERROR_CODE plain-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

Part of the Turnstile Mastery series — nine practical guides covering Turnstile end-to-end:

  1. Cloudflare Turnstile Widget Modes Explained
  2. Cloudflare Turnstile Implementation Detection
  3. Cloudflare Turnstile Sitekey Extraction
  4. Cloudflare Turnstile Interception Methods
  5. Cloudflare Turnstile CaptchaAI Solving Guide — you are here
  6. Cloudflare Turnstile Token Handoff with CaptchaAI
  7. Cloudflare Turnstile Token Expiration and Timing
  8. Cloudflare Turnstile 403 After Token Fix
  9. Cloudflare Turnstile Errors and Troubleshooting
Comments are disabled for this article.