Forms Integration Guide
Reference for wiring a contact form on a new Sevenoways client site to this backend. Give this whole page to the agent building the site.
forms.sevenoways.dev is a self-hosted, stateless form endpoint. A client site POSTs form data with an access_key; the service validates the origin, filters spam, stores the submission, and emails the site owner with the submitter's address set as Reply-To. There is no client-side library and nothing to install — it's one URL and a few hidden fields.
1. Get an access key first
Every form needs a key scoped to the domain(s) it will run on. Ask the site owner to mint one at forms.sevenoways.dev/admin (magic-link login, owner-only) — they'll need a label, the domain(s) the form will live on exactly as they appear in the browser (e.g. clientsite.ie, www.clientsite.ie — both, if both resolve), and the recipient email address(es).
The key only works from its registered domain(s) — submissions from anywhere else are rejected. If the site later moves domains or adds a subdomain, that has to be added to the key first.
2. The endpoint
POST https://forms.sevenoways.dev/submitAccepts three content types, so it works with or without JavaScript:
| Content-Type | Use case |
|---|---|
application/json | fetch/AJAX submission |
application/x-www-form-urlencoded | plain <form>, JS disabled |
multipart/form-data | also works; files accepted but currently ignored |
3. Every form needs these three things
access_key— hidden input or JSON field, the UUID from step 1.- A honeypot field named
botcheck— hidden from real users, left empty by them, filled in by bots. A submission withbotcheckset is silently accepted (200) and dropped — nothing stored, no email sent, and the bot isn't told it was caught. - A matching Origin/Referer — enforced automatically by the browser; nothing to add in markup, but it's why the domain has to be registered on the key first.
Reserved field names — read as control data, not form content: access_key, redirect, botcheck, cf-turnstile-response. Everything else you name (name, email, message, phone, whatever the form needs) is stored as submission content and appears in the notification email.
4. Copy-paste snippets
No-JS — works with scripts disabled. redirect sends the visitor to a thank-you page on success:
<form action="https://forms.sevenoways.dev/submit" method="POST">
<input type="hidden" name="access_key" value="THE-UUID">
<input type="hidden" name="redirect" value="https://clientsite.ie/thanks">
<input type="text" name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send</button>
</form>JS/fetch — inline success message, no page navigation:
<form id="cf">
<input type="hidden" name="access_key" value="THE-UUID">
<input name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1">
<button>Send</button>
</form>
<script>
document.getElementById('cf').addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
const res = await fetch('https://forms.sevenoways.dev/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const out = await res.json();
alert(out.message);
});
</script>Swap THE-UUID and the redirect URL for the real values from step 1.
5. Response shapes
// success (JSON mode)
{ "success": true, "message": "Thanks, your message was sent." }
// error
{ "success": false, "message": "Invalid access key." }The no-JS form gets a 303 redirect to redirect (or the key's configured default) on success instead of a JSON body.
| Status | Meaning |
|---|---|
200 | accepted |
303 | accepted, redirecting (no-JS mode) |
403 | unknown/inactive key, or wrong origin |
413 | payload too large |
422 | Turnstile check failed (only if the key has it enabled) |
429 | rate limited |
500 | server error |
Error messages are intentionally generic — the service never reveals whether a key is unknown vs. disabled.
6. Limits
- 50 fields max, 5 KB per field, 100 KB total body — larger requests get
413. - File uploads are parsed but not stored yet.
- Rate limited per submitter IP and per form key; a real visitor won't hit this, a script hammering the endpoint will.
7. Optional: Turnstile captcha
Off by default. If the site owner enabled it for a key, add the Cloudflare Turnstile widget and its script tag to the form — a submission with a missing or invalid token is rejected (422) the same way. The site key comes from the Cloudflare dashboard, not from this service.
<div class="cf-turnstile" data-sitekey="YOUR-TURNSTILE-SITE-KEY"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>8. If something's wrong
- CORS/403 from a domain that should be allowed — the key's domain list may not include the exact host (check
www.vs. bare domain), or it can take up to 60 seconds after key creation for the CORS preflight cache to catch up. - Need a new key, or need to add a domain to an existing one — that's done in the admin panel, not by this page; ask the site owner.