EphCaptcha — Server-Side Verification

Every completed tap challenge produces a real, single-use, server-issued token —
not just a client-side flag. Before trusting any form submission, your own
backend must verify that token with EphCaptcha's server. This is the same
model used by Google reCAPTCHA, hCaptcha, and Cloudflare Turnstile.

Important change: Every verification request must now include hostname, set to the domain where the challenge was completed. Pro and Enterprise requests must also include the widget's siteKey; Free-tier requests should send siteKey: null.

The token

The embed widget automatically places the token into a hidden field on your
form:

<input type="hidden" class="ephcaptcha-human-field" name="ephcaptcha_human" value="...">

That field is submitted along with the rest of your form normally — no extra
work needed on the frontend.

Verifying it — Node.js / Express example

app.post('/contact', async (req, res) => {
  const token = req.body.ephcaptcha_human;
  const hostname = 'yourdomain.com'; // Required for every tier.
  const siteKey = null; // Free: null. Pro/Enterprise: use your widget site key.

  const verifyRes = await fetch('https://ephcaptcha.com/.netlify/functions/siteverify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      token,
      hostname,
      siteKey
    })
  });
  if (!verifyRes.ok) {
    return res.status(503).send('CAPTCHA verification service unavailable. Please try again.');
  }
  const result = await verifyRes.json();

  if (!result.success) {
    return res.status(400).send('CAPTCHA verification failed: ' + result.error);
  }

  // Only past this point is it safe to trust the submission.
  // ... handle the real form submission here ...
});

Verifying it — PHP example

<?php
$token = $_POST['ephcaptcha_human'] ?? '';
$hostname = 'yourdomain.com'; // Required for every tier.
$siteKey = null; // Free: null. Pro/Enterprise: use your widget site key.

$ch = curl_init('https://ephcaptcha.com/.netlify/functions/siteverify');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'token' => $token,
    'hostname' => $hostname,
    'siteKey' => $siteKey
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$rawResponse = curl_exec($ch);
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$response = json_decode($rawResponse, true);
curl_close($ch);

if ($httpStatus !== 200 || empty($response['success'])) {
    http_response_code(400);
    die('CAPTCHA verification failed: ' . ($response['error'] ?? 'unknown'));
}

// Only past this point is it safe to trust the submission.

What the response looks like

Success:

{ "success": true, "tier": "PRO", "hostname": "yourdomain.com", "challenge_ts": 1735000000000 }

Failure (invalid, expired, or already-used token):

{ "success": false, "error": "invalid-token" }

Possible error values: invalid-token, token-already-used, token-expired, missing-token, missing-hostname, hostname-mismatch, site-key-mismatch, token-origin-unavailable, and rate-limit-exceeded.

Domain binding

The widget records the hostname where the challenge was completed. Your backend must send its expected hostname with every verification request. Paid widgets must also send the same siteKey used by the widget. A token issued on one hostname or for one site key will not verify for another.

Fail-closed behavior

EphCaptcha fails closed. The widget does not populate the hidden token field or emit ephcaptcha:verified unless the token service succeeds. It emits ephcaptcha:error and shows a retry message when token issuance is unavailable. Your backend must reject or pause the form submission whenever siteverify is unreachable, returns a non-200 response, or returns success: false. Never treat a network error or timeout as a successful verification.

Why "already-used" matters

Each token can only be verified once. If someone tries to submit the
same token twice — replaying a captured request, for example — the second
attempt is rejected automatically. This is enforced server-side and cannot
be bypassed from the browser.

Token lifetime

Tokens expire 10 minutes after the tap challenge is completed. A form
sitting open far longer than that will need the visitor to re-verify before
submitting.