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 :
- Extraire le sitekey du HTML servi.
- Envoyer la tâche à CaptchaAI avec
method=turnstile. - Interroger
res.php, puis poster le token danscf-turnstile-response.
Le paramètre action, la fraîcheur du token et des en-têtes cohérents règlent le reste des échecs.