Skip to content

CAPTCHA for developers

When a site has CAPTCHA enabled, every submission to /api/v1/submissions must carry a valid token or Expeed Relay rejects it. Your job is to render the widget, get a token, and put it in captchaToken.

Which provider is in use is a per-site setting your admin controls — see CAPTCHA setup. Ask them for the provider type and the site key. The secret key stays on the server and you never see it.

Expeed Relay implements exactly three:

Provider type What it is Visitor sees
recaptcha-v2 Google reCAPTCHA v2 An “I’m not a robot” checkbox
recaptcha-v3 Google reCAPTCHA v3 Nothing — scored invisibly
turnstile Cloudflare Turnstile Usually nothing
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<form id="contact-form">
<input name="email" type="email" required />
<div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const token = grecaptcha.getResponse();
if (!token) {
// The visitor hasn't completed the checkbox yet.
return;
}
await fetch('https://api.relay.expeed.com/api/v1/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteKey: 'marketing-site-a',
formKey: 'contact-us',
captchaToken: token,
data: Object.fromEntries(new FormData(e.currentTarget)),
}),
});
grecaptcha.reset(); // tokens are single-use
});
</script>

v3 never blocks the visitor. It returns a score, and Expeed Relay rejects anything below the threshold.

<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
<script>
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' });
await fetch('https://api.relay.expeed.com/api/v1/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteKey: 'marketing-site-a',
formKey: 'contact-us',
captchaToken: token,
data: Object.fromEntries(new FormData(e.currentTarget)),
}),
});
});
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="contact-form">
<input name="email" type="email" required />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const token = turnstile.getResponse();
await fetch('https://api.relay.expeed.com/api/v1/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteKey: 'marketing-site-a',
formKey: 'contact-us',
captchaToken: token,
data: Object.fromEntries(new FormData(e.currentTarget)),
}),
});
turnstile.reset();
});
</script>

Every provider issues a token that verifies once. Reusing one — for instance retrying a failed request with the same token — fails with 400 CAPTCHA verification failed. Call grecaptcha.reset() or turnstile.reset() and get a fresh token before retrying.

Tokens also expire (about two minutes for reCAPTCHA). A visitor who solves a checkbox and then spends five minutes writing their message will submit an expired token. Fetch the token at submit time, not on page load.

Expeed Relay compares the hostname the provider reports against the hostnames in your site’s Allowed origins. A token solved on a domain not in that list is rejected even though the provider considered it valid.

This bites when a form moves to a new domain, or when a staging domain is registered with the CAPTCHA provider but not with Expeed Relay. Both lists need the domain.

CAPTCHA scripts and frames need explicit entries. Combine with the connect-src from frontend integration.

reCAPTCHA v2 and v3:

script-src 'self' https://www.google.com https://www.gstatic.com;
frame-src https://www.google.com;
connect-src 'self' https://api.relay.expeed.com;

Turnstile:

script-src 'self' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
connect-src 'self' https://api.relay.expeed.com;

A blocked script means the widget never renders and grecaptcha or turnstile is undefined. The browser console names the violated directive.

Both providers publish keys that always pass, for local development. They are the providers’ test keys, not Expeed Relay’s, and your admin has to store the matching secret against the site for them to work end to end.

reCAPTCHA v2 — always passes:

site key: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
secret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe

Turnstile — always passes:

site key: 1x00000000000000000000AA
secret key: 1x0000000000000000000000000000000AA

Turnstile also publishes 2x00000000000000000000AB (always blocks) and 3x00000000000000000000FF (forces an interactive challenge), which are useful for testing your failure path.

Status Message What it means
400 CAPTCHA token is required for this site The site has CAPTCHA on and you sent no captchaToken
400 CAPTCHA verification failed Token invalid, expired, already used, key pair mismatched, v3 score too low, or hostname not allowed
503 CAPTCHA is misconfigured for this site A provider is selected but no secret is stored — an admin fix, not yours
503 CAPTCHA verification temporarily unavailable. Please try again. Expeed Relay could not reach Google or Cloudflare. Usually transient; safe to retry with a fresh token

If you don’t know, send a submission without captchaToken. If the site requires one you’ll get 400 CAPTCHA token is required for this site; if it doesn’t, the submission succeeds.