CAPTCHA, free,
no keys, zero friction.
Protect your forms against bots in 30 seconds. No signup, no tracking, no quotas. Self-hosted by Medel Platforms, based on asymmetric Proof-of-Work.
Invisible for humans. Expensive for bots.
The server issues a challenge
When the widget loads, it asks Medel Captcha for a challenge: a random seed + a difficulty level. Server cost: 1 INSERT + 2 ms.
The browser solves a PoW
In a WebWorker (without blocking the UI), the browser searches for a nonce such that sha256(seed + nonce) starts with N zeros. For a user it takes ~0.5-2s. For a bot at scale, it multiplies its CPU cost.
Single-use token, 2 minutes
The server verifies the PoW and issues a single-use token, valid for 2 minutes. Your backend verifies it with one call before processing the form.
2 lines of HTML.
Copy the snippet and paste it into your form. No npm, no keys, no config.
<form action="/contact" method="POST">
<input name="email" required>
<textarea name="message" required></textarea>
<div class="medel-captcha"></div>
<button type="submit">Send</button>
</form>
<script src="https://medel.es/captcha.js" async defer></script>
Before processing the form, verify the token.
On your server, when receiving the POST from the form, call the verification endpoint with the token that came in the medel_captcha_token field. The token is single-use: it is invalidated after the first call.
<?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"}
Complete reference
https://medel.es/api/captcha/challenge
Issues a new Proof-of-Work challenge. The widget calls it on load; your server does not need it directly.
Response 200:{
"ok": true,
"challenge_token": "21091047befb5f136b6c6525119b8eb0",
"seed": "7bb242e980f302742c743370955afef1",
"difficulty": 4,
"expires_in": 300
}
Common errors:
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
The widget sends the found nonce. The widget calls it; your server does not need it.
JSON body:{
"challenge_token": "21091047befb5f136b6c6525119b8eb0",
"nonce": "1841"
}
Response 200:
{
"ok": true,
"token": "391faafa6b881fc4e174d046fd2eb9f6",
"expires_in": 120
}
https://medel.es/api/captcha/verify
Your server calls here
Verify the token received from the form. Single-use: after the first successful verification, the token is invalidated.
JSON body:{
"token": "391faafa6b881fc4e174d046fd2eb9f6",
"expected_origin": "https://tusite.com" (optional)}
Response 200 (OK):
{
"ok": true,
"verified_at": "2026-06-22 12:24:56"
}
Response 400 (KO):
{
"ok": false,
"error": "already_used"
}
Error codes:
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.
Works on 98% of sites without touching anything.
Works automatically in:
- Any site served over HTTPS (required by the WebCrypto API).
- Modern browsers: Chrome/Edge 60+, Firefox 60+, Safari 11+ (released since 2017).
- localhost during development (browsers treat localhost as a secure context).
- Sites with moderate CSP that allow third-party scripts.
- Traditional server-rendered forms AND SPAs (React/Vue/Svelte) — the script detects dynamically added widgets.
If your site has strict Content Security Policy
Add these minimum directives to your CSP. No changes needed in your backend or rest of the HTML:
Content-Security-Policy: script-src 'self' https://medel.es; connect-src 'self' https://medel.es; style-src 'self' https://medel.es;
The widget does NOT use WebWorkers (common CSP-paranoia), does NOT inject inline <style> (CSS from external file), and does NOT require unsafe-eval. Zero conflicts with serious security policies.
Will NOT work in these (edge) cases:
- Pure HTTP sites (no TLS). WebCrypto requires secure context. Solution: use HTTPS — your site needs it in 2026 anyway.
- JavaScript disabled. Limitation of any modern captcha. As fallback, keep a pure HTML honeypot.
- Aggressive blockers. Some extensions could block third-party scripts. Rare for captchas (usually allowlisted).
What we do NOT store.
Frequently asked questions
Is it really free and without keys?
How do you prevent service abuse?
Does it work without JavaScript?
Why Proof-of-Work and not a puzzle?
Can a bot solve the PoW?
Can I use it in AJAX forms?
medel_captcha_token field is filled automatically in the form. If you submit via fetch/axios, the field travels with the rest of the FormData.