Um painel que consulta res.php a cada 5 segundos entrega o token sempre atrasado: se a CaptchaAI terminou aos 12 s, o navegador só descobre aos 15 s.
Encurtar o intervalo só troca atraso por requisição desperdiçada. Inverta o sentido: aponte o pingback para o seu backend e mantenha aberto um stream de Server-Sent Events (SSE). Quando o callback chega, o servidor empurra o resultado pela conexão já de pé.
Quando o SSE compensa e quando é exagero
O resultado anda em um sentido só: do servidor para o cliente.
| Critério | SSE | WebSocket | Polling |
|---|---|---|---|
| Direção | Servidor → cliente | Bidirecional | Cliente → servidor |
| Reconexão | Nativa | Manual | N/A |
| Requisições à toa | Nenhuma | Nenhuma | Muitas |
| Para resultado de CAPTCHA | Recomendado | Exagero | Funciona, mas desperdiça |
Troque por WebSocket só se o navegador precisar responder.
O caminho do token, do in.php até a tela
[Client] ← SSE stream ← [Your Server] ← Callback ← [CaptchaAI]
↓ ↑
Submit task → [CaptchaAI] ──┘ (pingback URL points to your server)
- O navegador abre o stream SSE (conexão HTTP persistente)
- O backend envia a tarefa ao
in.phpcom opingbackapontando para o próprio domínio - A CaptchaAI resolve e chama o seu endpoint de callback
- O callback deposita o evento e o stream entrega ao navegador
A chave de API nunca sai do backend.
Servidor SSE em Python com Flask
Cada conexão ganha uma Queue; o handler de callback só precisa saber em qual fila depositar o token.
import os
import queue
import threading
import requests
from flask import Flask, Response, request, jsonify
app = Flask(__name__)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Per-client event queues: client_id -> Queue
client_queues = {}
queues_lock = threading.Lock()
@app.route("/events/<client_id>")
def sse_stream(client_id):
"""SSE endpoint — clients connect here for real-time results."""
q = queue.Queue()
with queues_lock:
client_queues[client_id] = q
def generate():
try:
while True:
# Block until a result arrives (timeout for keepalive)
try:
data = q.get(timeout=30)
yield f"event: captcha-solved\ndata: {data}\n\n"
except queue.Empty:
# Send keepalive comment to prevent connection timeout
yield ": keepalive\n\n"
finally:
with queues_lock:
client_queues.pop(client_id, None)
return Response(
generate(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)
@app.route("/submit", methods=["POST"])
def submit_captcha():
"""Submit a CAPTCHA task with callback to this server."""
data = request.json
client_id = data["client_id"]
sitekey = data["sitekey"]
pageurl = data["pageurl"]
callback_url = f"{request.host_url}callback?client_id={client_id}"
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"pingback": callback_url,
"json": 1
})
result = resp.json()
if result.get("status") == 1:
return jsonify({"task_id": result["request"]})
return jsonify({"error": result.get("request")}), 400
@app.route("/callback")
def captcha_callback():
"""Receive CaptchaAI callback and push to SSE stream."""
client_id = request.args.get("client_id")
task_id = request.args.get("id")
solution = request.args.get("code")
import json
message = json.dumps({
"task_id": task_id,
"solution": solution
})
with queues_lock:
q = client_queues.get(client_id)
if q:
q.put(message)
return "OK", 200
if __name__ == "__main__":
app.run(port=5000, threaded=True)
Três detalhes decidem se isso sobrevive em produção: X-Accel-Buffering: no impede o buffer do nginx, o keepalive de 30 s evita que proxies fechem a conexão ociosa e o finally limpa as filas órfãs.
O cliente no navegador
O EventSource reconecta sozinho quando a rede oscila.
<!DOCTYPE html>
<html>
<body>
<button onclick="submitCaptcha()">Solve CAPTCHA</button>
<div id="results"></div>
<script>
const clientId = crypto.randomUUID();
const resultsDiv = document.getElementById("results");
// Connect SSE stream
const eventSource = new EventSource(`/events/${clientId}`);
eventSource.addEventListener("captcha-solved", (event) => {
const data = JSON.parse(event.data);
resultsDiv.innerHTML += `<p>Task ${data.task_id}: ${data.solution.substring(0, 30)}...</p>`;
});
eventSource.onerror = () => {
console.log("SSE connection lost, reconnecting...");
};
async function submitCaptcha() {
const response = await fetch("/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
pageurl: "https://example.com"
})
});
const result = await response.json();
resultsDiv.innerHTML += `<p>Submitted: ${result.task_id}</p>`;
}
</script>
</body>
</html>
A mesma rota em Node.js com Express
Aqui não há fila: você guarda o Response de cada conexão aberta em um Map e escreve nele quando o callback chega.
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
// Per-client SSE connections: clientId -> Response object
const clients = new Map();
// SSE endpoint
app.get("/events/:clientId", (req, res) => {
const clientId = req.params.clientId;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
clients.set(clientId, res);
// Keepalive every 30 seconds
const keepalive = setInterval(() => {
res.write(": keepalive\n\n");
}, 30000);
req.on("close", () => {
clearInterval(keepalive);
clients.delete(clientId);
});
});
// Submit CAPTCHA
app.post("/submit", async (req, res) => {
const { client_id, sitekey, pageurl } = req.body;
const callbackUrl = `${BASE_URL}/callback?client_id=${client_id}`;
try {
const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: sitekey,
pageurl: pageurl,
pingback: callbackUrl,
json: 1,
},
});
if (resp.data.status === 1) {
return res.json({ task_id: resp.data.request });
}
res.status(400).json({ error: resp.data.request });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// CaptchaAI callback → push to SSE
app.get("/callback", (req, res) => {
const clientId = req.query.client_id;
const taskId = req.query.id;
const solution = req.query.code;
const clientRes = clients.get(clientId);
if (clientRes) {
const data = JSON.stringify({ task_id: taskId, solution: solution });
clientRes.write(`event: captcha-solved\ndata: ${data}\n\n`);
}
res.sendStatus(200);
});
app.listen(3000, () => console.log("SSE server running on :3000"));
Produção: várias instâncias, limites e custo
Barramento entre o callback e o stream
Conexões SSE têm estado. Com duas instâncias atrás de um balanceador, o callback pode cair na instância B enquanto o navegador está preso à A, e o token some sem erro no log. Um Redis Pub/Sub resolve:
# Callback handler publishes to Redis
import redis
r = redis.Redis()
r.publish(f"captcha:{client_id}", json.dumps(message))
# SSE handler subscribes to Redis
pubsub = r.pubsub()
pubsub.subscribe(f"captcha:{client_id}")
for msg in pubsub.listen():
if msg["type"] == "message":
yield f"data: {msg['data'].decode()}\n\n"
Limites, latência e escopo autorizado
Em HTTP/1.1 o navegador abre no máximo 6 conexões por domínio; com HTTP/2 esse teto some. Se os seus workers rodam em sa-east-1 (São Paulo), meça o RTT do callback no seu ambiente antes de calibrar o timeout do proxy.
Use o padrão em ambiente próprio, em staging e em QA autorizado, e considere a LGPD (RGPD, em Portugal) nos logs de callback.
O SSE não muda a fatura: os planos são cobrados por thread simultânea, com resoluções ilimitadas. O BASIC (US$ 15/mês, 5 threads) já sustenta um painel de QA; o ADVANCE (US$ 90/mês, 50 threads) atende suítes em paralelo.
Quando o evento não chega: diagnóstico
| Sintoma | Causa provável | Correção |
|---|---|---|
| A conexão cai a cada 30 s | Timeout do proxy | Aumente o timeout do proxy |
| Nada chega ao navegador | Callback em outra instância | Redis Pub/Sub entre callback e stream |
| Erro de CORS no console | Cabeçalho ausente | Adicione Access-Control-Allow-Origin |
| Reconexões em sequência | Evento mal formado | Termine cada evento com linha em branco |
| Os tokens chegam em lote | Buffer do nginx ou da CDN | Envie X-Accel-Buffering: no |
Se o res.php devolve o token e o navegador segue mudo, o defeito está na entrega.
Perguntas frequentes
Ainda preciso do res.php?
Como plano B, sim. Guarde o task_id e faça uma consulta única se o callback não chegar em 120 s.
Que valor chega pelo stream?
O campo code do callback: g-recaptcha-response no reCAPTCHA e cf-turnstile-response no Turnstile. O front-end insere esse valor no campo do formulário.
O SSE funciona atrás da Cloudflare?
Funciona, mas a CDN pode fazer buffer da resposta. Envie X-Accel-Buffering: no ou ative o modo de streaming na rota.
Manter o stream aberto consome threads do plano?
Não. A thread é ocupada pela tarefa em resolução, não pela conexão com o navegador.
E se o usuário fechar a aba antes do callback?
A conexão morre e o finally tira o cliente do registro. Guarde o token no Redis com TTL curto para a próxima conexão.
Próximos passos
Ligue o pingback ao seu stream e meça a diferença em staging: crie sua chave de API da CaptchaAI e teste hoje.
Guias relacionados: