Integraciones

Resolver CAPTCHA en React Native WebViews con CaptchaAI

La respuesta corta: no resuelvas el CAPTCHA dentro del WebView ni desde el bundle de la app. Detéctalo con JavaScript inyectado, envía el sitekey y la URL a tu backend, deja que él hable con la API de CaptchaAI y devuelve solo el token al dispositivo. El WebView lo inyecta en el formulario y el envío continúa.

Ese reparto separa una integración de producción de una que filtra tu clave API en el primer análisis del APK. Aquí lo montas con react-native-webview, un backend Express, reCAPTCHA v2 y Cloudflare Turnstile.

Tipos compatibles con este puente

Identifica el tipo antes de escribir código:

Tipo Estado en CaptchaAI
reCAPTCHA v2 (invisible, callback, Enterprise) y v3 Compatible
Cloudflare Turnstile y Cloudflare Challenge Compatible
GeeTest v3, imagen/OCR, cuadrícula, BLS Compatible
CaptchaFox, Friendly Captcha, Lemin Compatible en beta
hCaptcha, FunCaptcha (Arkose Labs) No compatible
GeeTest v4 Próximamente

Las tres capas de la integración

Tres piezas, una responsabilidad cada una:

Capa Responsabilidad
WebView de React Native Detecta el CAPTCHA, extrae el sitekey, inyecta el token resuelto
API de backend (Node.js) Recibe sitekey + pageurl, llama a CaptchaAI y devuelve el token
API de CaptchaAI Resuelve el desafío y entrega el token

El WebView habla con tu código mediante window.ReactNativeWebView.postMessage(); el backend concentra la interacción con CaptchaAI.

Entorno de referencia:

  • React Native 0.72+ y react-native-webview 13+
  • Backend Node.js con Express
  • Cuenta de CaptchaAI con clave API

Un caso típico: trámites y pagos incrustados

El patrón se repite en apps hispanohablantes que envuelven portales de terceros: una gestoría que carga el formulario de una plataforma de trámites públicos, un comercio que incrusta el checkout de una pasarela regional. No controlas ese HTML y el envío se detiene en el widget. En QA duele antes: cada regresión contra un staging con CAPTCHA activo se bloquea y alguien resuelve casillas a mano. Respeta los términos de servicio del sitio y la normativa de protección de datos aplicable.

El objetivo es encadenar cuatro cosas: detectar el widget al terminar la carga, extraer el sitekey del DOM, resolverlo con la API de CaptchaAI desde el backend e inyectar el token antes de enviar el formulario.

Paso 1: detectar el CAPTCHA y extraer el sitekey

injectedJavaScript ejecuta tu script al terminar la carga: busca los contenedores conocidos (.g-recaptcha, .cf-turnstile), lee data-sitekey y devuelve el hallazgo con postMessage. El mismo componente inyecta después el token:

// CaptchaDetector.js — React Native Component
import React, { useRef, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { WebView } from 'react-native-webview';

const CAPTCHA_DETECTION_SCRIPT = `
  (function() {
    // Detect reCAPTCHA v2
    const recaptchaDiv = document.querySelector('.g-recaptcha');
    if (recaptchaDiv) {
      const sitekey = recaptchaDiv.getAttribute('data-sitekey');
      window.ReactNativeWebView.postMessage(JSON.stringify({
        type: 'captcha_detected',
        captchaType: 'recaptcha_v2',
        sitekey: sitekey,
        pageurl: window.location.href
      }));
      return;
    }

    // Detect Cloudflare Turnstile
    const turnstileDiv = document.querySelector('.cf-turnstile');
    if (turnstileDiv) {
      const sitekey = turnstileDiv.getAttribute('data-sitekey');
      window.ReactNativeWebView.postMessage(JSON.stringify({
        type: 'captcha_detected',
        captchaType: 'turnstile',
        sitekey: sitekey,
        pageurl: window.location.href
      }));
      return;
    }

    window.ReactNativeWebView.postMessage(JSON.stringify({
      type: 'no_captcha'
    }));
  })();
  true;
`;

export default function CaptchaWebView({ url }) {
  const webviewRef = useRef(null);
  const [solving, setSolving] = useState(false);

  const handleMessage = async (event) => {
    const data = JSON.parse(event.nativeEvent.data);

    if (data.type === 'captcha_detected') {
      setSolving(true);
      try {
        const token = await solveCaptchaViaBackend(
          data.captchaType,
          data.sitekey,
          data.pageurl
        );
        injectToken(data.captchaType, token);
      } catch (err) {
        console.error('CAPTCHA solve failed:', err.message);
      } finally {
        setSolving(false);
      }
    }
  };

  const solveCaptchaViaBackend = async (captchaType, sitekey, pageurl) => {
    const response = await fetch('https://your-backend.com/api/solve-captcha', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ captchaType, sitekey, pageurl }),
    });
    const result = await response.json();
    if (!result.token) throw new Error(result.error || 'No token returned');
    return result.token;
  };

  const injectToken = (captchaType, token) => {
    let script;
    if (captchaType === 'recaptcha_v2') {
      script = `
        document.getElementById('g-recaptcha-response').value = '${token}';
        if (typeof ___grecaptcha_cfg !== 'undefined') {
          Object.keys(___grecaptcha_cfg.clients).forEach(key => {
            const client = ___grecaptcha_cfg.clients[key];
            Object.keys(client).forEach(k => {
              const item = client[k];
              if (item && item.callback) {
                item.callback('${token}');
              }
            });
          });
        }
        true;
      `;
    } else if (captchaType === 'turnstile') {
      script = `
        const input = document.querySelector('[name="cf-turnstile-response"]');
        if (input) input.value = '${token}';
        const callback = document.querySelector('.cf-turnstile')
          ?.getAttribute('data-callback');
        if (callback && typeof window[callback] === 'function') {
          window[callback]('${token}');
        }
        true;
      `;
    }
    webviewRef.current?.injectJavaScript(script);
  };

  return (
    <View style={{ flex: 1 }}>
      {solving && <ActivityIndicator size="large" />}
      <WebView
        ref={webviewRef}
        source={{ uri: url }}
        injectedJavaScript={CAPTCHA_DETECTION_SCRIPT}
        onMessage={handleMessage}
        javaScriptEnabled={true}
      />
    </View>
  );
}

Fíjate en injectToken: con reCAPTCHA v2 no basta con rellenar g-recaptcha-response, porque muchos formularios dependen del callback de ___grecaptcha_cfg. Turnstile es más directo: escribes el valor en cf-turnstile-response y llamas al data-callback.

Paso 2: montar el solver en el backend

La clave API vive solo en el servidor: recibe sitekey y URL, envía la tarea a in.php, consulta res.php y devuelve únicamente el token:

// server.js — Express backend
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());

const API_KEY = process.env.CAPTCHAAI_API_KEY || 'YOUR_API_KEY';

app.post('/api/solve-captcha', async (req, res) => {
  const { captchaType, sitekey, pageurl } = req.body;

  try {
    // Step 1: Submit task to CaptchaAI
    const submitParams = {
      key: API_KEY,
      pageurl: pageurl,
      json: '1',
    };

    if (captchaType === 'recaptcha_v2') {
      submitParams.method = 'userrecaptcha';
      submitParams.googlekey = sitekey;
    } else if (captchaType === 'turnstile') {
      submitParams.method = 'turnstile';
      submitParams.sitekey = sitekey;
    }

    const submitResponse = await axios.get(
      'https://ocr.captchaai.com/in.php',
      { params: submitParams }
    );

    if (submitResponse.data.status !== 1) {
      return res.status(400).json({ error: submitResponse.data.request });
    }

    const taskId = submitResponse.data.request;

    // Step 2: Poll for result
    const token = await pollForResult(taskId);
    res.json({ token });
  } catch (error) {
    console.error('Solve error:', error.message);
    res.status(500).json({ error: 'Failed to solve CAPTCHA' });
  }
});

async function pollForResult(taskId, maxAttempts = 30) {
  for (let i = 0; i < maxAttempts; i++) {
    await new Promise((r) => setTimeout(r, 5000));

    const response = await axios.get('https://ocr.captchaai.com/res.php', {
      params: {
        key: API_KEY,
        action: 'get',
        id: taskId,
        json: '1',
      },
    });

    if (response.data.status === 1) {
      return response.data.request;
    }

    if (
      response.data.request !== 'CAPCHA_NOT_READY' &&
      response.data.status === 0
    ) {
      throw new Error(response.data.request);
    }
  }
  throw new Error('Polling timeout — CAPTCHA not solved in time');
}

app.listen(3000, () => console.log('Solver backend running on port 3000'));

El método cambia según el tipo: userrecaptcha con googlekey para reCAPTCHA v2, turnstile con sitekey para Cloudflare. La espera de 5 segundos entre sondeos es intencionada: consultar más rápido no acelera nada, y con reCAPTCHA v2 en menos de 60 segundos, 30 intentos sobran.

Paso 3: controlar la caducidad del token

Un token caduca: los de reCAPTCHA v2 rondan los 120 segundos y los de Turnstile unos 300. En móvil ese margen se consume solo, entre teclado y cambios de app. Guarda el instante de la resolución y pide uno nuevo si el anterior pasó su ventana:

// Add to CaptchaWebView component
const [tokenTimestamp, setTokenTimestamp] = useState(null);
const TOKEN_TTL_MS = 110000; // 110 seconds for reCAPTCHA v2

const handleFormSubmit = async (captchaType, sitekey, pageurl) => {
  const now = Date.now();
  if (!tokenTimestamp || now - tokenTimestamp > TOKEN_TTL_MS) {
    const freshToken = await solveCaptchaViaBackend(
      captchaType, sitekey, pageurl
    );
    injectToken(captchaType, freshToken);
    setTokenTimestamp(Date.now());
  }

  webviewRef.current?.injectJavaScript(`
    document.querySelector('form').submit();
    true;
  `);
};

TOKEN_TTL_MS a 110 segundos deja diez de colchón frente al límite de reCAPTCHA v2. Con Turnstile puedes ampliarlo, pero un token caducado es un envío rechazado que el usuario lee como fallo tuyo.

Errores frecuentes

Casi todos los fallos de este puente caen en cinco categorías:

Síntoma Causa probable Qué hacer
postMessage no llega onMessage sin enlazar Enlázalo; envuelve el script en try/catch
ERROR_BAD_TOKEN_OR_PAGEURL Sitekey y URL no casan Lee el sitekey del src del iframe
El callback no se dispara Falta en ___grecaptcha_cfg Recorre los objetos cliente anidados
CAPCHA_NOT_READY sin fin Parámetros malos Amplía intentos; confirma sitekey y pageurl
El CAPTCHA sale en blanco JavaScript deshabilitado javaScriptEnabled={true}; revisa la CSP

Preguntas frecuentes

¿Cuántos threads necesito para una app móvil en producción?

Depende de los envíos simultáneos, no del total diario: CaptchaAI factura por thread concurrente con resoluciones ilimitadas, así que un thread es un CAPTCHA en vuelo. BASIC ($15/mes, 5 threads) cubre una app temprana o un entorno de QA; STANDARD ($30/mes, 15 threads) y ADVANCE ($90/mes, 50 threads) siguen desde ahí.

¿Qué hago si el sitekey está dentro de un iframe y mi script no lo encuentra?

Lee el src del iframe y extrae el parámetro k: ahí viaja el sitekey real. Como pageurl envía la de la página contenedora, que es la que valida el CAPTCHA. Más detalle en extracción de parámetros de reCAPTCHA.

¿Qué pasa si el dispositivo pierde conexión a mitad del sondeo?

El id de la tarea sigue vivo, así que el backend puede seguir consultando res.php aunque la app se desconecte. Guárdalo y expón un endpoint de reconsulta: al volver, el dispositivo recupera el token sin otra resolución.

¿Puedo llamar a la API desde el código React Native y ahorrarme el backend?

El fetch funciona, pero tu clave acabaría en el binario y cualquiera puede sacarla de un APK o un IPA. En el servidor puedes rotarla, medir consumo y limitar por usuario sin publicar nada.

Artículos relacionados

Empieza por tu entorno de QA

Monta el puente contra tu staging con logs del sitekey y del token en cada paso: los errores de pageurl salen en minutos. Con el flujo estable, obtén tu clave API de CaptchaAI, conéctalo a la app real y sigue con estas guías:

Los comentarios están deshabilitados para este artículo.