Tutorials

Résoudre Cloudflare Turnstile en Python avec requests et l'API CaptchaAI

Un défi Turnstile se résout en trois appels HTTP : vous lisez le sitekey dans le HTML de la page, vous envoyez une tâche method=turnstile à l'API CaptchaAI, puis vous interrogez le résultat jusqu'à récupérer un token que vous replacez dans le champ cf-turnstile-response du formulaire. Aucun navigateur headless n'est nécessaire : la bibliothèque requests suffit.

Turnstile n'affiche aucune grille d'images à cliquer : rien à piloter, seulement un token à obtenir puis à poster avec vos champs. Comptez moins de 10 s par résolution. Voici le code exact, dans l'ordre, jusqu'au formulaire accepté.


Ce qu'il vous faut avant de commencer

Trois éléments, et rien de plus :

  • Une clé API CaptchaAI, disponible sur captchaai.com une fois votre compte créé
  • L'URL exacte de la page qui affiche le widget (pas la page d'accueil du site)
  • Le sitekey Turnstile, que l'étape suivante va extraire automatiquement

Côté dépendances, une seule ligne :

pip install requests

Étape 1 : récupérez le sitekey dans le HTML de la page

Le sitekey Turnstile est public : il vit dans l'attribut data-sitekey du widget, ou dans la configuration JavaScript qui l'initialise. Les trois expressions régulières ci-dessous couvrent les variantes courantes et reconnaissent le préfixe 0x typique des clés Turnstile.

import re
import requests

def extract_turnstile_sitekey(url):
    """Extract Cloudflare Turnstile sitekey from page HTML."""
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 Chrome/120.0.0.0",
        "Accept": "text/html,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = requests.get(url, headers=headers, timeout=15)

    patterns = [
        r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']',
        r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
        r"siteKey\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    for pattern in patterns:
        match = re.search(pattern, response.text)
        if match:
            return match.group(1)

    return None


sitekey = extract_turnstile_sitekey("https://example.com/signup")
print(f"Sitekey: {sitekey}")

Les en-têtes ne sont pas décoratifs : sans User-Agent ni Accept-Language crédible, vous récupérez une page d'erreur, donc un HTML sans sitekey.


Étape 2 : envoyez la tâche à l'API CaptchaAI

L'endpoint in.php reçoit la tâche et renvoie immédiatement un identifiant ; la résolution se fait en arrière-plan. Sans le paramètre json: 1, l'API répond en texte brut, que vous devrez découper à la main.

import requests

API_KEY = "YOUR_API_KEY"

def submit_turnstile(sitekey, page_url):
    """Submit Turnstile solving task to CaptchaAI."""
    response = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = response.json()

    if data.get("status") != 1:
        raise Exception(f"Submit failed: {data.get('request')}")

    return data["request"]


task_id = submit_turnstile("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")
print(f"Task ID: {task_id}")

pageurl doit correspondre à l'URL réellement visitée, redirections comprises. Une valeur approximative produit un token que le site refusera, sans erreur côté API.


Étape 3 : interrogez le résultat jusqu'à obtenir le token

L'endpoint res.php renvoie CAPCHA_NOT_READY tant que la résolution est en cours. Une pause de 5 s entre deux appels est le bon compromis : plus court, vous consommez du rate limiting pour rien ; plus long, vous attendez sur un défi déjà résolu.

import time

def poll_result(task_id, timeout=120):
    """Poll CaptchaAI for the solved Turnstile token."""
    start = time.time()

    while time.time() - start < timeout:
        time.sleep(5)

        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

        if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
            raise Exception("Turnstile could not be solved")

    raise TimeoutError("Solve timed out")


token = poll_result(task_id)
print(f"Token: {token[:50]}...")

Traiter ERROR_CAPTCHA_UNSOLVABLE à part évite d'attendre le timeout complet : ce code signale un abandon, pas une attente.


Le script complet : de la page au formulaire soumis

Les trois étapes en un flux, avec une requests.Session qui conserve les cookies pendant la résolution.

import re
import time
import requests

API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://example.com/signup"


def solve_turnstile(sitekey, page_url):
    """Full Turnstile solve: submit + poll."""
    # Submit
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]
    print(f"Task submitted: {task_id}")

    # Poll
    for _ in range(30):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")


# --- Main flow ---
session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 Chrome/120.0.0.0",
    "Accept": "text/html,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
})

# 1. Get page and extract sitekey
response = session.get(TARGET_URL, timeout=15)
match = re.search(r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text)
if not match:
    raise ValueError("Turnstile sitekey not found")
sitekey = match.group(1)
print(f"Sitekey: {sitekey}")

# 2. Solve Turnstile
token = solve_turnstile(sitekey, TARGET_URL)
print(f"Token: {token[:50]}...")

# 3. Submit form with token
form_response = session.post(TARGET_URL, data={
    "cf-turnstile-response": token,
    "email": "user@example.com",
    "password": "SecurePass123",
})
print(f"Form status: {form_response.status_code}")

Le paramètre action, quand le site le vérifie

Certaines intégrations ajoutent un attribut data-action au widget et le vérifient côté serveur. L'omettre produit un token valide que le site rejettera : lisez la valeur dans le HTML et transmettez-la telle quelle.

def solve_turnstile_with_action(sitekey, page_url, action):
    """Solve Turnstile that requires an action parameter."""
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "action": action,  # Include the action from data-action attribute
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]

    for _ in range(30):
        time.sleep(5)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")

Où injecter le token : trois schémas de soumission

Obtenir le token est standardisé ; le placer au bon endroit dépend de l'application.

Schéma 1 : POST de formulaire classique

Le cas majoritaire : le champ porte son nom standard.

# Most common — Turnstile uses cf-turnstile-response field
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "email": "user@example.com",
})

Schéma 2 : API JSON

Le front transmet le token en JSON, souvent sous un nom camelCase.

response = session.post(api_url, json={
    "turnstileToken": token,
    "email": "user@example.com",
})

Schéma 3 : champ renommé par l'application

Le back-end attend un nom maison : envoyez les deux champs, l'inspection du formulaire tranche.

# Some sites rename the field — check the form HTML
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "captcha_token": token,  # Custom duplicate field
    "action": "signup",
})

Une classe réutilisable en production

En production, il faut un retry borné et une distinction nette entre erreurs à retenter et erreurs définitives : une clé API invalide ou un solde à zéro ne se corrigent pas en réessayant.

import re
import time
import requests

class TurnstileSolver:
    """Production-ready Turnstile solver with retry logic."""

    API_URL = "https://ocr.captchaai.com"

    def __init__(self, api_key, max_retries=3):
        self.api_key = api_key
        self.max_retries = max_retries

    def extract_sitekey(self, session, url):
        """Extract Turnstile sitekey from page."""
        response = session.get(url, timeout=15)
        match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text
        )
        return match.group(1) if match else None

    def solve(self, sitekey, page_url, action=None):
        """Solve Turnstile with retry logic. Returns token string."""
        for attempt in range(1, self.max_retries + 1):
            try:
                token = self._solve_once(sitekey, page_url, action)
                return token
            except TimeoutError:
                print(f"Attempt {attempt} timed out")
            except Exception as e:
                error_str = str(e)
                if "ERROR_ZERO_BALANCE" in error_str:
                    raise  # Don't retry billing errors
                if "ERROR_WRONG_USER_KEY" in error_str:
                    raise
                print(f"Attempt {attempt} failed: {e}")

        raise Exception(f"Failed after {self.max_retries} attempts")

    def _solve_once(self, sitekey, page_url, action=None):
        """Single solve attempt."""
        params = {
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }
        if action:
            params["action"] = action

        submit = requests.post(f"{self.API_URL}/in.php", data=params, timeout=30)
        submit.raise_for_status()
        data = submit.json()

        if data.get("status") != 1:
            raise Exception(f"Submit error: {data.get('request')}")

        task_id = data["request"]

        for _ in range(30):
            time.sleep(5)
            result = requests.get(f"{self.API_URL}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1,
            }, timeout=30).json()

            if result.get("status") == 1:
                return result["request"]
            if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
                raise Exception("CAPTCHA unsolvable")

        raise TimeoutError("Poll timed out")


# Usage
solver = TurnstileSolver("YOUR_API_KEY")
token = solver.solve("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")

Cas concret : la recette d'un SaaS hébergé en Europe

Une équipe QA valide chaque nuit le parcours d'inscription de son application hébergée chez OVHcloud, depuis un runner planifié en région AWS eu-west-3 (Paris). Le formulaire passe en Turnstile mode « managed » : le scénario, qui postait directement, échoue.

La correction tient en trois lignes : extraire le sitekey, appeler TurnstileSolver, injecter le token dans le payload existant. Le runner restant dans la région de l'application, le temps de cycle vient surtout de la résolution.

Deux réflexes au passage :

  • Côté RGPD : des adresses e-mail fictives et des données synthétiques suffisent en recette.
  • Côté périmètre : restez sur vos propres domaines et environnements autorisés.

Dimensionner vos threads et votre budget

La facturation se fait au thread simultané, résolutions illimitées sur le mois : un thread = une résolution en vol, libérée dès qu'elle se termine. À moins de 10 s par défi, un thread saturé plafonnerait vers 259 000 résolutions mensuelles — une borne théorique, pas un débit réel.

Plan Tarif et capacité Charge typique
BASIC $15/mois, 5 threads suite de tests nocturne, crawler modeste
STANDARD $30/mois, 15 threads plusieurs workers en parallèle
ADVANCE $90/mois, 50 threads quelques dizaines de sessions simultanées

La bonne question n'est donc pas « combien de résolutions par mois ? » mais « combien en simultané en pointe ? ». Facturation en dollars US.


Dépannage : symptôme, cause, correctif

Symptôme Cause probable Correctif
Token obtenu, formulaire refusé Sitekey périmé ou action absent Réextraire le sitekey et transmettre data-action
Sitekey introuvable dans le HTML Widget injecté par JavaScript Passer par un navigateur automatisé
HTTP 403 avant de lire la page En-têtes de requête incomplets Renseigner User-Agent et Accept-Language
Résolution au-delà de 60 s File d'attente chargée en pointe Allonger le timeout, laisser le retry agir
Token valide une fois puis rejeté Un token neuf est exigé par envoi Résoudre un défi avant chaque soumission
ERROR_ZERO_BALANCE répété Solde épuisé Recharger le compte, ne pas retenter

Questions fréquentes

Faut-il un navigateur headless pour résoudre Turnstile ?

Non. Turnstile ne demande aucune interaction visuelle : requests et une Session suffisent tant que le sitekey figure dans le HTML servi. Le navigateur automatisé ne redevient nécessaire que si le widget est injecté en JavaScript.

Le token Turnstile est-il réutilisable sur plusieurs soumissions ?

Non. Le token est à usage unique et sa durée de vie est courte : résolvez-en un juste avant l'envoi. Stocker des tokens à l'avance ne fonctionne pas et rend le débogage illisible.

Puis-je utiliser la même clé API pour Turnstile et reCAPTCHA v2 ?

Oui : une seule clé couvre tous les types pris en charge, seul method change. Sont pris en charge reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge et GeeTest v3 ; hCaptcha et FunCaptcha ne le sont pas.

Combien de temps prend une résolution Turnstile ?

Moins de 10 s en conditions nominales, l'un des types les plus rapides du catalogue. Prévoyez malgré tout un timeout de 120 s.


À retenir

La séquence ne change jamais :

  1. Extraire le sitekey du HTML servi.
  2. Envoyer la tâche à CaptchaAI avec method=turnstile.
  3. Interroger res.php, puis poster le token dans cf-turnstile-response.

Le paramètre action, la fraîcheur du token et des en-têtes cohérents règlent le reste des échecs.

Articles connexes

Les commentaires sont désactivés pour cet article.