Sur un projet Node.js, vous n'avez aucun widget Cloudflare Turnstile à piloter : le serveur attend seulement une valeur cf-turnstile-response valide dans votre POST. Le fetch natif de Node.js 18 couvre tout le chemin — repérer le data-sitekey dans le HTML servi, confier le défi à l'API CaptchaAI en method=turnstile, puis replacer le token dans le formulaire. Aucune dépendance, aucun navigateur headless tant que le widget est présent dans le HTML initial.
Le sitekey commence toujours par 0x — s'il commence par 6Le, vous êtes en face de reCAPTCHA et le reste de ce guide ne s'applique pas. La suite déroule le chemin complet : détection, résolution, soumission, puis la mise en production.
Ce qu'il faut avoir sous la main
- Node.js 18+ (le
fetchnatif, sans dépendance externe) - Une clé API CaptchaAI et un solde suffisant
- L'URL exacte de la page qui affiche le widget — celle du formulaire, pas la page d'accueil
Le sitekey et l'URL envoyés à l'API doivent pointer sur la même page : une URL approximative est la première cause de token refusé.
Étape 1 : extraire le sitekey Turnstile de la page
Le sitekey est public : il apparaît dans l'attribut data-sitekey du conteneur cf-turnstile ou dans l'appel turnstile.render(). La fonction ci-dessous essaie quatre emplacements, du plus fiable au plus générique.
async function extractTurnstileSitekey(url) {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
});
const html = await resp.text();
// Method 1: data-sitekey attribute on Turnstile div
const divMatch = html.match(
/class=["'][^"]*cf-turnstile[^"]*["'][^>]*data-sitekey=["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (divMatch) return divMatch[1];
// Method 2: data-sitekey on any element (Turnstile keys start with 0x)
const attrMatch = html.match(
/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/
);
if (attrMatch) return attrMatch[1];
// Method 3: In JavaScript turnstile.render call
const jsMatch = html.match(
/turnstile\.render\s*\([^,]+,\s*\{[^}]*sitekey\s*:\s*["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (jsMatch) return jsMatch[1];
// Method 4: Generic sitekey in inline script
const inlineMatch = html.match(
/sitekey\s*:\s*["'](0x[A-Za-z0-9_-]+)["']/
);
if (inlineMatch) return inlineMatch[1];
return null;
}
L'ordre compte : la première expression cible le conteneur Turnstile lui-même, la plus sûre quand la page héberge plusieurs widgets ; les suivantes servent de repli sur du balisage minifié. Si les quatre échouent, récupérez le HTML rendu via Puppeteer ou Playwright, puis réappliquez ce code.
Étape 2 : envoyer le défi à l'API CaptchaAI
La soumission part sur in.php avec method=turnstile ; le résultat s'obtient en interrogeant res.php. Deux paramètres sont obligatoires : sitekey et pageurl.
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveTurnstile(sitekey, pageurl, action = null) {
// Submit task
const submitData = {
key: API_KEY,
method: "turnstile",
sitekey: sitekey,
pageurl: pageurl,
json: "1",
};
if (action) {
submitData.action = action;
}
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams(submitData),
});
const submitResult = await submitResp.json();
if (submitResult.status !== 1) {
throw new Error(`Submit error: ${submitResult.request}`);
}
const taskId = submitResult.request;
console.log(`Task ID: ${taskId}`);
// Poll for result
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const pollResult = await pollResp.json();
if (pollResult.status === 1) {
return pollResult.request;
}
if (pollResult.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Turnstile unsolvable");
}
}
throw new Error("Solve timed out");
}
Trois détails méritent un commentaire :
- Le rythme du polling. Cinq secondes entre deux appels : interroger toutes les 500 ms n'accélère rien et vous expose au rate limiting. Turnstile se résout typiquement en moins de 10 s.
- La sortie de boucle. Trente itérations de 5 s donnent un plafond de 150 s.
ERROR_CAPTCHA_UNSOLVABLE. Ce n'est pas une erreur réseau : relancer donnera le même résultat, vérifiez le sitekey.CAPCHA_NOT_READYest normal.
Étape 3 : rejouer le token dans le formulaire
Le token revient sous forme de chaîne. Il s'injecte dans le corps de la requête sous le nom cf-turnstile-response, comme le navigateur l'aurait fait.
async function submitTurnstileForm(url, formData, token) {
const body = new URLSearchParams({
...formData,
"cf-turnstile-response": token,
});
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
body,
});
return {
status: resp.status,
body: await resp.text(),
};
}
Le token a une durée de vie courte : soumettez-le dans la foulée, avec le même User-Agent qu'à l'extraction.
Assembler le flux de connexion de bout en bout
Les trois fonctions s'enchaînent : extraction, résolution, soumission.
async function loginWithTurnstile(loginUrl, credentials) {
// Step 1: Extract sitekey
const sitekey = await extractTurnstileSitekey(loginUrl);
if (!sitekey) {
throw new Error("Turnstile sitekey not found");
}
console.log(`Sitekey: ${sitekey}`);
// Step 2: Solve Turnstile
const token = await solveTurnstile(sitekey, loginUrl);
console.log(`Token: ${token.substring(0, 50)}...`);
// Step 3: Submit form
const result = await submitTurnstileForm(loginUrl, credentials, token);
console.log(`Result: ${result.status}`);
return result;
}
// Usage
const result = await loginWithTurnstile("https://example.com/login", {
email: "user@example.com",
password: "pass123",
});
Journalisez chaque étape : sitekey trouvé, token émis, statut HTTP renvoyé. C'est ce qui sépare un problème d'extraction d'un problème de session.
Encapsuler le solveur dans une classe réutilisable
Dès que plusieurs scripts partagent la même clé API, une classe évite de dupliquer le polling et la gestion des erreurs.
class TurnstileSolver {
#apiKey;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async solve(sitekey, pageurl, options = {}) {
const taskId = await this.#submit(sitekey, pageurl, options);
return await this.#poll(taskId);
}
async detectAndSolve(url) {
const sitekey = await this.#detect(url);
if (!sitekey) throw new Error("No Turnstile found");
return await this.solve(sitekey, url);
}
async #detect(url) {
const resp = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" },
});
const html = await resp.text();
const match = html.match(/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/);
return match ? match[1] : null;
}
async #submit(sitekey, pageurl, options) {
const body = new URLSearchParams({
key: this.#apiKey,
method: "turnstile",
sitekey,
pageurl,
json: "1",
...(options.action && { action: options.action }),
...(options.cdata && { data: options.cdata }),
});
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body,
});
const data = await resp.json();
if (data.status !== 1) throw new Error(`Submit: ${data.request}`);
return data.request;
}
async #poll(taskId) {
const params = new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
});
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const resp = await fetch(`https://ocr.captchaai.com/res.php?${params}`);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Unsolvable");
}
}
throw new Error("Timed out");
}
}
// Usage
const solver = new TurnstileSolver("YOUR_API_KEY");
const token = await solver.detectAndSolve("https://example.com/login");
Les champs privés (#apiKey) évitent que la clé ne fuite dans un console.log ou une sérialisation JSON. Chargez-la depuis une variable d'environnement, jamais depuis le dépôt.
Prendre en charge les paramètres action et cData
Certains sites paramètrent leur widget avec action (connexion, inscription, paiement) et cData (valeur liée à la session). Ces valeurs figurent dans la page et doivent accompagner la demande de résolution, sinon le token sera rejeté.
// Extract action from the page
function extractTurnstileAction(html) {
const match = html.match(
/data-action=["']([^"']+)["']|action\s*:\s*["']([^"']+)["']/
);
return match ? match[1] || match[2] : null;
}
// Solve with action
const token = await solver.solve(sitekey, pageurl, {
action: "login",
cdata: "session_abc123",
});
Cherchez data-action dans le HTML et action: dans les scripts en ligne. Sans ces attributs, n'inventez rien : un action erroné est pire qu'un action absent.
Vérifier un token Turnstile côté serveur
Si vous développez l'application protégée par Turnstile, la vérification passe par l'endpoint siteverify de Cloudflare.
async function verifyTurnstileToken(token, ip) {
const resp = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: "YOUR_TURNSTILE_SECRET_KEY",
response: token,
remoteip: ip,
}),
}
);
const data = await resp.json();
return data.success;
}
Le paramètre remoteip transmet une adresse IP, donc une donnée personnelle au sens du RGPD : ne l'envoyez que si votre politique de sécurité le justifie, et ne la conservez pas dans vos logs. La clé secrète reste côté serveur, jamais dans un bundle front.
Combien de threads pour votre volume ?
CaptchaAI facture des threads simultanés, pas des résolutions à l'unité : chaque plan inclut un nombre illimité de résolutions par thread. Un thread correspond à un défi en cours ; dès qu'il se termine, il reprend le suivant.
Un cas concret : une équipe QA lyonnaise rejoue chaque nuit, sur ses environnements de recette hébergés chez OVHcloud, un parcours d'inscription protégé par Turnstile. Quelques centaines de soumissions étalées sur deux heures : BASIC ($15/mois, 5 threads) suffit. Une campagne en rafale sur quinze minutes justifie STANDARD ($30/mois, 15 threads) ; un pipeline permanent passe à ADVANCE ($90/mois, 50 threads). La facturation est en dollars US.
Mesurez votre pic de défis simultanés, puis prenez le palier au-dessus : ajouter des threads ne raccourcit pas une résolution, cela en traite davantage en parallèle.
Dépannage
| Symptôme | Cause probable | Correctif |
|---|---|---|
Sitekey en 6Le |
Widget reCAPTCHA, pas Turnstile | Basculez sur method=userrecaptcha |
| Token refusé | Sitekey d'une autre page, ou token périmé | Réextrayez le sitekey sur l'URL exacte, soumettez aussitôt |
| Aucun sitekey dans le HTML | Widget monté par JavaScript | Récupérez le HTML rendu via Puppeteer ou Playwright |
ERROR_BAD_PARAMETERS |
sitekey ou pageurl absent ou mal formé |
Vérifiez les deux dans le corps POST |
| Réponse 403 après envoi | En-têtes incohérents entre requêtes | Réutilisez le même User-Agent et les cookies |
CAPCHA_NOT_READY jusqu'au timeout |
Boucle interrompue trop tôt | Laissez le polling aller à son terme |
Questions fréquentes
Faut-il un navigateur headless pour résoudre Turnstile ?
Non, pas si le widget figure dans le HTML initial : fetch et une expression régulière suffisent. Le navigateur headless ne sert qu'à obtenir le HTML rendu quand le widget est injecté dynamiquement.
Quel plan choisir pour 10 000 résolutions par mois ?
Le volume mensuel n'est pas le bon critère : les résolutions sont illimitées par thread. Ce qui compte est le nombre de défis simultanés — BASIC ($15/mois, 5 threads) pour un pipeline régulier, STANDARD ($30/mois, 15 threads) pour des lots concentrés.
CaptchaAI prend-il en charge hCaptcha ou FunCaptcha ?
Non — ces deux types ne sont pas pris en charge, et GeeTest v4 est annoncé comme à venir. Disponibles : reCAPTCHA v2 et v3, Cloudflare Turnstile et Challenge, GeeTest v3, image/OCR et grilles, plus CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta).
Comment déboguer un token accepté par l'API mais refusé par le site ?
Comparez trois éléments : l'URL envoyée en pageurl et celle réellement appelée, la présence de action et cData, puis la cohérence des en-têtes. L'écart s'y trouve presque toujours.
À retenir
Un sitekey, un appel d'API, un champ de formulaire : le sitekey en 0x, l'API CaptchaAI en method=turnstile, le token rejoué dans cf-turnstile-response. Le reste n'est que de la robustesse autour de ce noyau.