Seu script em Node.js recebeu uma página com a div cf-turnstile no lugar do formulário esperado? A resposta curta é que você não precisa abrir um navegador para seguir em frente: três requisições HTTP resolvem o caso. Você lê a sitekey no HTML, envia o desafio para a API da CaptchaAI e reenvia o formulário com o token no campo cf-turnstile-response.
O código usa fetch nativo e termina em uma classe reutilizável pronta para o seu worker. O Turnstile é plenamente suportado pela CaptchaAI e costuma ser resolvido em menos de 10 s.
O que você precisa antes de começar
- Node.js 18 ou superior, porque o
fetchjá vem embutido no runtime. - Uma chave de API da CaptchaAI. O plano BASIC (US$ 15/mês, 5 threads) cobre o exemplo deste artigo; a cobrança é por thread simultânea, com resoluções ilimitadas no mês. Comece pelo guia de início rápido da CaptchaAI.
- Um alvo autorizado. Os exemplos apontam para
https://staging.example.com/qa-login, um endpoint fictício de homologação.
Guarde a chave em variável de ambiente: YOUR_API_KEY é só um marcador.
Como o fluxo funciona em três requisições
O modelo mental é o mesmo em qualquer linguagem:
- GET na página para achar a sitekey, que sempre começa com
0x. - POST em
in.phpcommethod=turnstile, a sitekey e apageurl; a API devolve um ID de tarefa. - Consulta em
res.phpaté o token voltar, seguida do POST no formulário.
A diferença em relação ao reCAPTCHA aparece no method (turnstile) e no campo de destino do token, cf-turnstile-response. Reaproveitar o campo de outro tipo de desafio é a causa número um de token recusado sem erro claro.
Passo 1: extrair a sitekey do Turnstile na página
A sitekey aparece em quatro lugares possíveis: no data-sitekey da div do widget, em outro elemento qualquer, em uma chamada turnstile.render ou solta em script inline. A função tenta os quatro padrões em ordem.
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;
}
Se ela retornar null em uma página que exibe o widget, o Turnstile é injetado por JavaScript: capture o HTML já renderizado com Puppeteer ou Playwright e aplique a mesma regex.
Passo 2: enviar o desafio à CaptchaAI e consultar o resultado
O envio é um POST com json: "1"; depois, um laço de polling consulta o resultado a cada 5 s, até 30 vezes.
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");
}
Três detalhes evitam dor de cabeça: espere alguns segundos antes da primeira consulta; trate ERROR_CAPTCHA_UNSOLVABLE como falha da tarefa e reenvie; e registre o taskId em log para rastrear lentidão depois.
Passo 3: devolver o token no campo cf-turnstile-response
O token só vale no mesmo formulário e na mesma URL de onde a sitekey saiu. Envie os campos originais mais o token, com os cabeçalhos esperados.
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(),
};
}
O token tem vida curta e uso único: trate os passos 2 e 3 como uma sequência contínua, sem reaproveitar tokens entre execuções.
Fluxo de login completo em um ambiente de homologação
Juntando as três funções, um login automatizado de teste fica assim:
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",
});
É o formato típico de uma suíte de integração: credenciais fictícias, endpoint de staging e verificação do status.
Encapsulando tudo em uma classe reutilizável
Em produção, três funções soltas incomodam. A classe abaixo concentra detecção, envio e polling, guarda a chave em campo privado e expõe dois métodos públicos.
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");
O worker instancia a classe uma vez e chama detectAndSolve por item da fila. Como o faturamento é por thread simultânea, a concorrência deve acompanhar o plano: com 5 threads, cinco resoluções em voo.
Parâmetros action e cData: quando o Turnstile exige contexto extra
Algumas implementações do Turnstile incluem os parâmetros action e cData:
// 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",
});
Quando o site define esses valores, enviá-los é obrigatório: sem a action correta, o token volta da API e é recusado pelo site — o famoso 403 depois de tudo parecer certo.
Verificação do token no seu próprio servidor
Do outro lado, um backend que valida tokens recebidos do próprio frontend usa o endpoint oficial da Cloudflare com a chave 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;
}
Reproduzir essa verificação em ambiente controlado é a forma mais rápida de saber se o problema está no token ou na requisição.
Um cenário prático: monitoramento autorizado com workers em São Paulo
Pense em uma equipe brasileira que verifica diariamente um portal interno protegido por Turnstile, com workers em sa-east-1. Abrir um navegador completo a cada checagem custa memória e tempo; no fluxo em três requisições, cada verificação vira uma chamada HTTP leve que cabe em uma função serverless.
Dois pontos locais: meça o RTT até a API antes de fixar timeouts; e, se houver dado pessoal no caminho, considere as obrigações da LGPD (ou do RGPD, em Portugal) sobre o que vai para o log — guardar apenas taskId e status costuma bastar.
Erros comuns e como corrigir
| Sintoma | Causa provável | Correção |
|---|---|---|
Sitekey começa com 6Le |
É reCAPTCHA, não Turnstile | Use method=userrecaptcha |
| Token recusado pelo site | Sitekey errada ou token expirado | Extraia a sitekey de novo e envie o formulário na sequência |
| Nenhuma sitekey encontrada | O widget é carregado via JavaScript | Renderize com Puppeteer ou Playwright e aplique a regex |
ERROR_BAD_PARAMETERS |
Falta a sitekey ou a pageurl |
Confira se os dois campos vão no POST |
| Resposta 403 depois do envio | Cabeçalhos inconsistentes com a sessão | Repita os mesmos cabeçalhos do GET inicial |
Se o token funciona no cURL e falha no script, o problema costuma estar na camada do navegador, não na resolução.
Perguntas frequentes
Preciso de Puppeteer ou Playwright para resolver o Turnstile?
Na maioria dos casos, não. Se a sitekey estiver no HTML do GET, o fluxo com fetch basta e custa muito menos recursos. O navegador só entra quando o widget é injetado dinamicamente ou a sessão depende de JavaScript.
Por quanto tempo o token do Turnstile continua válido?
Pouco tempo, e o uso é único. Envie o formulário na mesma execução em que o token chegou; guardá-lo para depois não funciona.
Quanto tempo leva uma resolução de Turnstile?
O Turnstile é resolvido em menos de 10 s no padrão da plataforma, com alta taxa de sucesso. O polling do exemplo consulta a cada 5 s até 30 vezes — margem folgada para picos.
Quantas resoluções consigo rodar em paralelo?
Tantas quantas forem as threads do plano, já que a cobrança é por thread simultânea. No BASIC (US$ 15/mês, 5 threads) são cinco tarefas em voo. Se a fila crescer, suba de plano em vez de abrir mais conexões.
Resumo
Três movimentos resumem o caminho em Node.js: achar a sitekey que começa com 0x, resolver o desafio pela API da CaptchaAI com method=turnstile e devolver o resultado em cf-turnstile-response. O resto é ajustar timeouts e concorrência ao seu plano.