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.
Supported providers
Section titled “Supported providers”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 |
reCAPTCHA v2 (checkbox)
Section titled “reCAPTCHA v2 (checkbox)”<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>reCAPTCHA v3 (invisible)
Section titled “reCAPTCHA v3 (invisible)”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>Cloudflare Turnstile
Section titled “Cloudflare Turnstile”<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>Tokens are single-use and short-lived
Section titled “Tokens are single-use and short-lived”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.
The hostname is cross-checked
Section titled “The hostname is cross-checked”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.
Content Security Policy
Section titled “Content Security Policy”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.
Provider test keys
Section titled “Provider test keys”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_MXjiZKhIsecret key: 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWeTurnstile — always passes:
site key: 1x00000000000000000000AAsecret key: 1x0000000000000000000000000000000AATurnstile also publishes 2x00000000000000000000AB (always blocks) and 3x00000000000000000000FF (forces an interactive challenge), which are useful for testing your failure path.
Errors you will actually see
Section titled “Errors you will actually see”| 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 |
Checking whether a site needs CAPTCHA
Section titled “Checking whether a site needs CAPTCHA”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.
Related
Section titled “Related”- CAPTCHA setup — the admin side: choosing a provider and storing keys
- Frontend integration — the submission call itself
- API reference — every field and every error