Resolver Turnstile desde Node.js son tres funciones: una saca el sitekey del HTML, otra lo envía a la API de CaptchaAI y espera el token, y la tercera lo adjunta al formulario como cf-turnstile-response. Sin navegador y sin renderizar el widget: con Node.js 18 o superior basta el fetch nativo, sin dependencias externas.
Cloudflare ha ido desplazando a reCAPTCHA, así que quien mantiene un scraper o una suite end-to-end en JavaScript se topa con un widget casi invisible donde antes había casillas. La ventaja: al ser silencioso, Turnstile se resuelve rápido, en menos de 10 segundos según los tiempos de referencia de CaptchaAI.
Un detalle que ahorra media hora de depuración: los sitekey de Turnstile empiezan por 0x. Si el tuyo empieza por 6Le, es un reCAPTCHA y necesitas userrecaptcha.
Qué necesitas antes de escribir código
- Node.js 18 o superior, que trae
fetchincorporado (no necesitas Axios ni node-fetch). - Una API key de CaptchaAI. El ciclo no cambia: envías la tarea, sondeas y recoges el token.
- La URL de la página protegida, que viaja tal cual en el parámetro
pageurl.
Paso 1: extraer el sitekey de la página
El sitekey es público y viaja en el HTML, pero cada integración lo coloca en un sitio distinto. La función siguiente prueba cuatro ubicaciones y se queda con la primera: data-sitekey sobre el div cf-turnstile, el mismo atributo en otro elemento, la llamada turnstile.render() y cualquier clave sitekey: en JavaScript en línea.
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;
}
Si las cuatro devuelven null, el widget se inyecta después de cargar la página: no hay HTML que analizar y toca renderizar con Puppeteer o Playwright.
Paso 2: resolver el desafío con la API de CaptchaAI
El trabajo real son dos llamadas HTTP. La primera hace un POST a in.php con method=turnstile, el sitekey y la URL, y devuelve un identificador de tarea. La segunda consulta res.php con ese id hasta que llega el token.
El bucle espera 5 segundos entre consultas y reintenta hasta 30 veces. Ese margen es amplio a propósito: Turnstile suele estar listo en la primera o segunda vuelta y las 30 iteraciones cubren picos de carga. Fíjate en el corte temprano ante ERROR_CAPTCHA_UNSOLVABLE: sondear una tarea que ya falló solo consume tiempo.
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");
}
Un apunte de facturación: CaptchaAI cobra por thread concurrente, no por resolución. BASIC ($15/mes) incluye 5 threads y resoluciones ilimitadas; STANDARD ($30/mes) sube a 15 threads y ADVANCE ($90/mes) a 50. Lo que dimensionas no es cuántos Turnstile resolverás, sino cuántos tendrás en vuelo a la vez.
Paso 3: enviar el token junto al formulario
El token que devuelve la API es lo que el widget habría producido en el navegador. Lo adjuntas al cuerpo del POST bajo el campo cf-turnstile-response (nombre fijo, sensible a mayúsculas).
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(),
};
}
El token dura poco: entre recibirlo y enviarlo deberían pasar segundos, no minutos. Si los acumulas para reutilizarlos, el servidor los rechazará. Y si recibes un 403 con un token recién emitido, el problema suele estar en la sesión, no en el token.
Los tres pasos encadenados: un login de staging
Con las funciones anteriores, un login automatizado contra tu entorno de pruebas queda así de corto: el caso típico de una suite de QA en cada despliegue.
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://staging.example.com/qa-login", {
email: "[email protected]",
password: "pass123",
});
Encapsular todo en una clase reutilizable
Con más de un script tocando Turnstile, mueve la lógica a una clase con la API key en un campo privado. Esta versión añade detectAndSolve(), que combina detección y resolución en una llamada, y acepta action y cdata.
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://staging.example.com/qa-login");
Un caso concreto: una agencia que monitorea precios en marketplaces regionales de España y Latinoamérica mantiene un worker por mercado, todos con la misma clase. El dimensionamiento vive en un único sitio (los threads del plan), y ese costo fijo en USD es más fácil de presupuestar que un pago por resolución cuando facturas en monedas volátiles. Respeta los términos de servicio del sitio y la normativa de protección de datos aplicable (GDPR y LOPDGDD en España, LFPDPPP en México).
Cuando el sitio usa los parámetros action y cData
Algunas integraciones añaden action, una etiqueta que describe la operación (login, por ejemplo), y cData, un dato de sesión adjunto al desafío. Si el widget los declara y no los envías, el token sale con un contexto distinto al que espera el backend y la validación falla.
// 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",
});
Búscalos como data-action y data-cdata, o dentro de turnstile.render(). Si no aparecen, no los inventes: enviarlos cuando el sitio no los usa es tan dañino como omitirlos.
Verificar tokens en tu propio servidor
El lado contrario también es habitual: si eres tú quien protege el formulario, tu backend valida el token contra el endpoint siteverify de Cloudflare con tu clave secreta.
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;
}
Son dos mundos separados: method=turnstile produce tokens para automatizar un sitio ajeno; siteverify valida los que llegan al tuyo. Mezclar las claves es un error frecuente.
Errores frecuentes y cómo salir de ellos
| Síntoma | Causa | Solución |
|---|---|---|
El sitekey empieza por 6Le |
Es reCAPTCHA, no Turnstile | Usa method=userrecaptcha |
| Token rechazado | Sitekey incorrecto o caducado | Reextrae el sitekey y envía antes |
| No aparece ningún sitekey | Turnstile se carga por JavaScript | Renderiza con Puppeteer o Playwright |
ERROR_BAD_PARAMETERS |
Falta el sitekey o la pageurl |
Comprueba ambos en el POST |
| 403 tras enviar | Detección de bots en las cabeceras | User-Agent realista, conserva la sesión |
Preguntas frecuentes
¿Puedo resolver Turnstile sin abrir un navegador?
Sí, y es lo recomendable: el flujo de este artículo es puro HTTP. Solo necesitas Puppeteer o Playwright cuando el widget se inyecta dinámicamente y no hay sitekey en el HTML inicial.
¿Cuánto tarda en llegar el token?
Los tiempos de referencia sitúan Turnstile en menos de 10 segundos con una tasa de éxito alta. Por eso el bucle sondea cada 5 segundos: suele bastar la primera o segunda consulta.
¿Cuántos threads necesito para mi volumen?
Depende de la concurrencia, no del total mensual: con resoluciones de menos de 10 segundos, un thread procesa varios cientos de desafíos por hora. BASIC ($15/mes) da 5 threads y ADVANCE ($90/mes) da 50, con resoluciones ilimitadas.
¿Sirve el mismo código para Cloudflare Challenge?
No directamente. Cloudflare Challenge es la pantalla completa previa al contenido y usa cloudflare_challenge; Turnstile es el widget dentro de un formulario cargado. Ambos están soportados, pero el método cambia.
¿Y si el sitio usa hCaptcha en vez de Turnstile?
CaptchaAI no resuelve hCaptcha ni FunCaptcha (Arkose Labs), así que este flujo no aplica. Sí cubre reCAPTCHA v2 y v3, Turnstile y Challenge, GeeTest v3, imagen/OCR y grid, más CaptchaFox (beta), Friendly Captcha (beta) y Lemin (beta).
En resumen
Turnstile en Node.js son tres movimientos: localiza el sitekey que empieza por 0x, resuélvelo con method=turnstile en la API de CaptchaAI y envía el resultado como cf-turnstile-response antes de que caduque. Lo demás son variaciones sobre ese esqueleto.