Si tu integración resuelve el CAPTCHA sin errores y aun así el servidor responde que el token no es válido, el problema casi nunca es la resolución: es el dominio. Cada token de reCAPTCHA lleva incrustado el hostname de la página donde se generó, y basta con que no sea el que valida el backend para que todo falle en silencio, sin excepción ni log claro.
La corrección suele caber en una línea: el pageurl que envías al servicio de resolución tiene que ser la URL final donde ese token se va a consumir. El resto de esta guía es cómo llegar a esa URL y cómo distinguir este fallo de otros que se le parecen.
Dónde se rompe realmente el dominio
Site owner registers reCAPTCHA → adds allowed domains (example.com, www.example.com)
↓
reCAPTCHA widget loads on example.com → matches allowed domain ✓
↓
Token generated with embedded hostname
↓
Server validates token via siteverify API
↓
Google checks: Does token hostname match allowed domains?
├─ YES → { "success": true, "hostname": "example.com" }
└─ NO → { "success": false, error or hostname mismatch }
Esa cadena tiene tres puntos de control y el síntoma es idéntico en los tres:
- Lado del cliente: el widget solo se carga en dominios permitidos (opcional; el propietario puede desactivarlo).
- Generación del token: el hostname incrustado es el del origen de la página.
- Validación en el servidor: siteverify devuelve el hostname y el backend decide si lo acepta.
Los dos primeros dependen del propietario del sitio; el tercero es código tuyo.
Los tres errores de dominio que verás en producción
Error 1: el hostname no coincide en la respuesta de siteverify
{
"success": true,
"hostname": "subdomain.example.com",
"challenge_ts": "2025-01-15T10:30:00Z"
}
Fíjate en el detalle incómodo: success es true. El token es legítimo, pero el hostname no es el que el backend espera, y esa comparación posterior es la que falla:
# Server-side validation that checks hostname
def validate_token(token, secret_key, expected_hostname):
result = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={"secret": secret_key, "response": token},
).json()
if not result.get("success"):
return False
# This check causes failures when hostnames don't match
if result.get("hostname") != expected_hostname:
return False # Domain mismatch!
return True
Origen habitual: el token se resolvió para www.example.com o staging.example.com y se valida contra example.com, o bien un proxy o CDN altera el hostname aparente.
Corrección: haz que el pageurl sea exactamente el dominio donde vas a enviar el token.
Error 2: el widget se niega a cargar
El widget no aparece o devuelve un mensaje seco:
ERROR: Invalid domain for site key
Origen habitual: los dominios del sitekey no incluyen el de la página actual, cargas el widget desde localhost o file://, o usas una IP en lugar de un nombre de dominio.
Corrección en automatización: es configuración del propietario, no algo que tu script fuerce. Lo que controlas es enviar un pageurl de un dominio permitido.
Error 3: token rechazado pese a una resolución correcta
{
"success": false,
"error-codes": ["invalid-input-response"]
}
El token se generó para un dominio distinto del que se valida. Es el clásico de la automatización: el pageurl enviado al solver no es el destino real.
# WRONG: pageurl doesn't match actual target
submit = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": "https://staging.example.com/qa-login", # ← Must match actual domain
"json": 1,
})
# But submitting token to:
requests.post("https://app.staging.example.com/qa-login", ...) # Different subdomain!
Un subdominio de diferencia basta.
Del síntoma a la causa
| Síntoma | Causa probable | Cómo lo confirmas | Qué cambias |
|---|---|---|---|
| El token se rechaza siempre | El pageurl no coincide con el dominio de destino |
Compara el pageurl enviado con el dominio donde se envía el token |
Actualiza el pageurl para que coincida |
| Funciona con www y falla sin www (o al revés) | Variante de dominio distinta | Revisa el comportamiento de redirección del sitio | Usa la variante que el propio sitio impone |
| Unas veces funciona y otras no | Una CDN o un balanceador sirven dominios distintos | Comprueba si el hostname varía entre solicitudes | Fija una URL coherente al final de la cadena |
| Funciona en el navegador y falla en el script | El script parte de un origen distinto | Compara la barra de direcciones del navegador con la URL del script | Alinea el script con la URL final del navegador |
| Token Enterprise rechazado | Proyecto o dominio mal enlazado | Verifica que el sitekey Enterprise corresponda al dominio | Corrige la configuración de dominio en la consola Enterprise |
Reglas de coincidencia: qué acepta realmente reCAPTCHA
Detrás de casi todos estos fallos hay una suposición equivocada: reCAPTCHA no aplica por defecto coincidencia estricta de subdominios. Depende de cómo registró los dominios el propietario.
| Dominio registrado | Orígenes que se aceptan |
|---|---|
example.com |
example.com, www.example.com, sub.example.com (si el comodín está habilitado) |
www.example.com |
Solo www.example.com (en configuración estricta) |
*.example.com |
Cualquier subdominio de example.com |
localhost |
Solo localhost (entorno de desarrollo) |
Desde fuera no ves cuál de estas configuraciones usa el sitio: solo la deduces por el hostname que devuelve siteverify.
Cómo se comporta el hostname en el servidor
El hostname de siteverify refleja la página donde se generó el token. Quien decide si vale es el backend, y ahí hay dos escuelas:
# Permissive validation (accepts any subdomain)
def validate_permissive(token, secret, base_domain):
result = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={"secret": secret, "response": token},
).json()
if not result.get("success"):
return False
hostname = result.get("hostname", "")
return hostname == base_domain or hostname.endswith(f".{base_domain}")
# Strict validation (exact match only)
def validate_strict(token, secret, expected_hostname):
result = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={"secret": secret, "response": token},
).json()
return result.get("success") and result.get("hostname") == expected_hostname
Con la variante estricta, un token bien resuelto para www. se rechaza en el dominio desnudo. No hay nada que arreglar en el solver: hay que alinear la URL de origen.
Escenario típico: portales públicos con redirección
Los portales públicos de habla hispana —cita previa en España, trámites tipo SAT en México, AFIP en Argentina— son el caso que más veces provoca esto:
- Copias la URL del portal de entrada y la usas como
pageurl. - El portal te manda a un dominio de autenticación antes de enseñar el formulario.
- El token se resuelve bien, pero nace atado al hostname equivocado.
Respeta los términos de servicio del portal y la normativa de protección de datos aplicable.
Cuatro correcciones concretas
En orden: la primera resuelve la mayoría de los casos y cada siguiente cubre un supuesto más raro.
Corrección 1: haz coincidir el pageurl con el destino exacto
# Correct: pageurl matches where you'll submit the token
target_url = "https://www.staging.example.com/qa-login"
submit = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": "6LcR_RsTAAAAAN_r0GEkGBfq3L7KmU5JbPHJtwNp",
"pageurl": target_url, # Must match the actual domain
"json": 1,
})
Corrección 2: decide la variante www antes de resolver
No adivines cuál usa el sitio. Pídela y deja que redirija:
from urllib.parse import urlparse
def normalize_url(url):
"""Normalize URL for consistent domain matching."""
parsed = urlparse(url)
# Use exactly what the target site uses
# Check if the site redirects www → non-www or vice versa
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
# Test which variant the site uses
response = requests.get("https://staging.example.com/qa-login", allow_redirects=True)
actual_url = response.url # May be https://www.staging.example.com/qa-login after redirect
Corrección 3: sigue la cadena de redirecciones hasta el final
def get_final_url(url):
"""Follow redirects to find the actual CAPTCHA page domain."""
response = requests.get(url, allow_redirects=True, timeout=15)
return response.url
# Login URL might redirect:
# https://staging.example.com/qa-login → https://auth.staging.example.com/qa-login
final_url = get_final_url("https://staging.example.com/qa-login")
# Use final_url as pageurl for solver
Corrección 4: lee el dominio desde el propio widget
Último recurso cuando la página monta el widget por JavaScript:
from bs4 import BeautifulSoup
from urllib.parse import urlparse
def extract_recaptcha_domain(html, page_url):
"""Extract the domain reCAPTCHA uses for token binding."""
soup = BeautifulSoup(html, "html.parser")
# Check for reCAPTCHA iframe
iframe = soup.find("iframe", src=lambda s: s and "recaptcha" in s)
if iframe:
src = iframe.get("src", "")
# The iframe URL may contain the domain parameter
if "domain=" in src:
# Extract domain from iframe URL
pass
# Default: use the page URL's domain
return urlparse(page_url).netloc
Un diagnóstico automatizado antes de gastar threads
Antes de mandar nada al solver conviene una comprobación barata que te diga qué URL usar. Este script sigue redirecciones, contrasta la variante con y sin www e imprime el pageurl recomendado:
import requests
from urllib.parse import urlparse
class DomainDiagnostic:
"""Diagnose domain verification issues for reCAPTCHA solving."""
def __init__(self, target_url):
self.target_url = target_url
self.issues = []
def check_redirects(self):
"""Check if the URL redirects to a different domain."""
try:
response = requests.get(
self.target_url, allow_redirects=True, timeout=15,
headers={"User-Agent": "Mozilla/5.0 Chrome/120.0.0.0"},
)
final_url = response.url
original_domain = urlparse(self.target_url).netloc
final_domain = urlparse(final_url).netloc
if original_domain != final_domain:
self.issues.append({
"type": "redirect",
"message": f"Redirects from {original_domain} to {final_domain}",
"fix": f"Use pageurl: {final_url}",
})
return final_url
except Exception as e:
self.issues.append({"type": "error", "message": str(e)})
return self.target_url
def check_www_variant(self):
"""Check if www and non-www point to the same content."""
parsed = urlparse(self.target_url)
domain = parsed.netloc
if domain.startswith("www."):
alt_domain = domain[4:]
else:
alt_domain = f"www.{domain}"
alt_url = self.target_url.replace(domain, alt_domain)
try:
alt_response = requests.get(alt_url, allow_redirects=True, timeout=10)
alt_final = urlparse(alt_response.url).netloc
if alt_final != domain and alt_final != alt_domain:
self.issues.append({
"type": "www_redirect",
"message": f"{alt_domain} redirects to {alt_final}",
})
except Exception:
pass
def report(self):
"""Generate diagnostic report."""
final_url = self.check_redirects()
self.check_www_variant()
print(f"Target URL: {self.target_url}")
print(f"Final URL: {final_url}")
print(f"Use as pageurl: {final_url}")
if self.issues:
print("\nIssues found:")
for issue in self.issues:
print(f" [{issue['type']}] {issue['message']}")
if "fix" in issue:
print(f" Fix: {issue['fix']}")
else:
print("\nNo domain issues detected.")
# Usage
diag = DomainDiagnostic("https://staging.example.com/qa-login")
diag.report()
Ejecútalo una vez por entorno: con el plan BASIC ($15/mes, 5 threads), cada thread ocupado por un token que acabará rechazado es capacidad perdida.
Preguntas frecuentes
¿Cómo sé si el fallo es de dominio y no del sitekey?
Te lo dice la respuesta de siteverify:
success: truey falla tu comparación dehostname→ problema de dominio.invalid-input-responseconsuccess: false→ revisa primero elpageurly solo después el sitekey.
¿Tengo que enviar la URL completa o basta el dominio?
Dos cosas distintas:
- reCAPTCHA valida el hostname, no la ruta:
/loginy/registrodan lo mismo. - Aun así, envía la URL completa como
pageurl: parte de la integración se apoya en ella para localizar el widget.
¿Sirve el mismo token en dos subdominios de mi propio sitio?
Depende del backend:
- Validación permisiva (
endswith): sí. - Validación estricta: no.
- Entre dominios distintos: nunca. El token está atado al hostname donde nació.
¿CaptchaAI comprueba por mí que el dominio esté bien configurado?
No. CaptchaAI genera el token contra el pageurl que le pasas y no valida la configuración de dominios del destino. Esa comprobación es tuya, y la automatiza el script de diagnóstico de esta guía.
¿Esto pasa igual con Cloudflare Turnstile?
Parecido, con un matiz:
- Igual: el token se emite para un contexto concreto y se rechaza fuera de él.
- Distinto: cambian los mensajes de error. No traslades un diagnóstico de reCAPTCHA a Cloudflare Turnstile sin mirar qué devuelve tu validación.
Para llevarte
El token de reCAPTCHA vive atado al hostname donde se generó, y el error número uno en automatización es un pageurl que no corresponde al destino real. Tres hábitos lo evitan:
- Sigue las redirecciones y fija la variante www antes de resolver con CaptchaAI.
- Ejecuta el diagnóstico antes de gastar threads, no después de acumular rechazos.
- Guarda el
pageurlresuelto con la configuración del entorno, para comparar cuando el sitio cambie una redirección.