CAPTCHA libre,
sin keys, sin fricción.
Protege tus formularios contra bots en 30 segundos. Sin registros, sin tracking, sin cuotas. Self-hosted por Medel Platforms, basado en Proof-of-Work asimétrico.
Invisible para humanos. Caro para bots.
El servidor emite un reto
Cuando el widget se carga, pide a Medel Captcha un challenge: una semilla aleatoria + un nivel de dificultad. Coste para el servidor: 1 INSERT + 2 ms.
El navegador resuelve un PoW
En un WebWorker (sin bloquear la UI), el navegador busca un nonce tal que sha256(semilla + nonce) empiece por N ceros. Para un usuario tarda ~0.5-2s. Para un bot a escala, multiplica su coste de CPU.
Token single-use de 2 minutos
El servidor verifica el PoW y emite un token de un solo uso, válido 2 minutos. Tu backend lo verifica con una llamada antes de procesar el form.
2 líneas de HTML.
Copia el snippet y pégalo en tu formulario. Sin npm, sin keys, sin configuración.
<form action="/contact" method="POST">
<input name="email" required>
<textarea name="message" required></textarea>
<div class="medel-captcha"></div>
<button type="submit">Enviar</button>
</form>
<script src="https://medel.es/captcha.js" async defer></script>
Antes de procesar el form, verifica el token.
En tu servidor, al recibir el POST del formulario, llama al endpoint de verificación con el token que vino en el campo medel_captcha_token. El token es single-use: se invalida tras la primera llamada.
<?php
$token = $_POST['medel_captcha_token'] ?? '';
$ch = curl_init('https://medel.es/api/captcha/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['token' => $token]),
]);
$res = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($res['ok'])) {
http_response_code(400);
exit('Captcha verification failed: ' . ($res['error'] ?? 'unknown'));
}
// ✅ Captcha verificado. Procede a procesar el formulario.
const token = req.body.medel_captcha_token;
const res = await fetch('https://medel.es/api/captcha/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (!data.ok) {
return res.status(400).json({ error: 'Captcha failed', reason: data.error });
}
// ✅ Captcha verificado. Procede a procesar el formulario.
import requests
token = request.form.get('medel_captcha_token', '')
res = requests.post(
'https://medel.es/api/captcha/verify',
json={'token': token},
timeout=5,
)
data = res.json()
if not data.get('ok'):
abort(400, f"Captcha failed: {data.get('error')}")
# ✅ Captcha verificado. Procede a procesar el formulario.
curl -X POST 'https://medel.es/api/captcha/verify' \
-H 'Content-Type: application/json' \
-d '{"token":"EL_TOKEN_DEL_FORM"}'
# Respuesta OK:
# {"ok":true,"verified_at":"2026-06-22 12:24:56"}
# Respuesta error:
# {"ok":false,"error":"already_used"}
Referencia completa
https://medel.es/api/captcha/challenge
Emite un nuevo reto Proof-of-Work. Lo llama el widget al cargarse; tu servidor no lo necesita directamente.
Respuesta 200:{
"ok": true,
"challenge_token": "21091047befb5f136b6c6525119b8eb0",
"seed": "7bb242e980f302742c743370955afef1",
"difficulty": 4,
"expires_in": 300
}
Errores comunes:
banned— la IP está baneada 24h por abuso.rate_limit_ip— más de 100 challenges/hora desde esta IP.rate_limit_origin— más de 5000/hora desde tu dominio.
https://medel.es/api/captcha/solve
El widget envía el nonce encontrado. Lo llama el widget; tu servidor no lo necesita.
Body JSON:{
"challenge_token": "21091047befb5f136b6c6525119b8eb0",
"nonce": "1841"
}
Respuesta 200:
{
"ok": true,
"token": "391faafa6b881fc4e174d046fd2eb9f6",
"expires_in": 120
}
https://medel.es/api/captcha/verify
Tu servidor llama aquí
Verifica el token recibido del formulario. Single-use: tras la primera verificación exitosa, el token queda invalidado.
Body JSON:{
"token": "391faafa6b881fc4e174d046fd2eb9f6",
"expected_origin": "https://tusite.com" (opcional)}
Respuesta 200 (OK):
{
"ok": true,
"verified_at": "2026-06-22 12:24:56"
}
Respuesta 400 (KO):
{
"ok": false,
"error": "already_used"
}
Códigos de error:
missing_token— falta el campotoken.unknown_token— el token no existe o nunca se emitió.already_used— el token ya se consumió una vez (single-use).expired— pasaron más de 2 minutos desde su emisión.origin_mismatch— el origin esperado no coincide con el del token.
Funciona en el 98% de los sitios sin tocar nada.
Funciona automáticamente en:
- Cualquier sitio servido por HTTPS (requisito de la WebCrypto API).
- Navegadores modernos: Chrome/Edge 60+, Firefox 60+, Safari 11+ (lanzados desde 2017).
- localhost durante desarrollo (los navegadores tratan localhost como contexto seguro).
- Webs con CSP moderada que permita scripts de terceros.
- Formularios server-rendered tradicionales Y SPAs (React/Vue/Svelte) — el script detecta widgets añadidos dinámicamente.
Si tu sitio tiene Content Security Policy estricta
Añade estas directivas mínimas a tu CSP. No requiere cambios en tu backend ni en el resto del HTML:
Content-Security-Policy: script-src 'self' https://medel.es; connect-src 'self' https://medel.es; style-src 'self' https://medel.es;
El widget NO usa WebWorkers (CSP-paranoia común), NO inyecta <style> inline (CSS desde archivo externo), y NO requiere unsafe-eval. Cero conflictos con políticas de seguridad serias.
No funcionará en estos casos (edge):
- Sitios sólo HTTP (sin TLS). WebCrypto exige contexto seguro. Solución: usa HTTPS — tu sitio lo necesita en 2026 igual.
- JavaScript deshabilitado. Limitación de cualquier captcha moderno. Como fallback, mantén un honeypot HTML puro.
- Bloqueadores agresivos. Algunas extensiones podrían bloquear el script third-party. Es raro en captchas (suelen estar en allowlist).
Lo que NO guardamos.
Preguntas frecuentes
¿Realmente es gratis y sin keys?
¿Cómo evitan el abuso del servicio?
¿Funciona sin JavaScript?
¿Por qué Proof-of-Work y no un puzzle?
¿Puede un bot resolver el PoW?
¿Se puede usar en formularios con AJAX?
medel_captcha_token se rellena automáticamente en el form. Si lo envías por fetch/axios, el campo viaja con el resto del FormData.