Static Form Backend
Contact-form backend for static sites: Turnstile, honeypot, rate limiting, D1 storage, and email notifications
Point any static site's <form action> at this Worker. Each submission is checked with Turnstile, a honeypot field, and the native Rate Limiting binding, then stored in D1 and emailed to the form owner through the send_email binding.
Features
- Accepts
application/x-www-form-urlencoded,multipart/form-data(text fields only), and JSON - Turnstile siteverify on the
cf-turnstile-responsefield _gotchahoneypot: bots get a normal success reply, nothing is stored or emailed- Per-IP rate limiting keyed by
CF-Connecting-IP - Per-form
allowedOriginenforcement andredirectUrl(303) for plain HTML form posts - Submissions stored as a JSON blob with a salted SHA-256 IP hash (never the raw IP) and user agent
- Owner notification email with
replyToset from the submitter'semailfield when valid
API Reference
POST /forms
Create a form. Requires Authorization: Bearer <ADMIN_TOKEN>.
ownerEmail string (required)
Where submissions are emailed.
allowedOrigin string (optional)
Origin allowed to submit, e.g. https://mysite.com. Normalized to scheme://host[:port]. When set, any other Origin header (or none) is rejected.
redirectUrl string (optional)
Where browsers are sent after an HTML form post (http or https only).
curl -X POST "https://your-worker.workers.dev/forms" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ownerEmail":"me@example.com","allowedOrigin":"https://mysite.com","redirectUrl":"https://mysite.com/thanks"}'{
"id": "3f9c2a7b1d4e8f60",
"ownerEmail": "me@example.com",
"allowedOrigin": "https://mysite.com",
"redirectUrl": "https://mysite.com/thanks",
"endpoint": "https://your-worker.workers.dev/f/3f9c2a7b1d4e8f60",
"createdAt": "2026-10-01T09:00:00.000Z"
}POST /f/:formId
Public submission endpoint. Fields are free-form, with these limits and reserved names:
- Max 30 fields and 5000 characters per value (repeated keys are joined with
,); field names up to 100 characters cf-turnstile-response- Turnstile token (required whenTURNSTILE_SECRET_KEYis set)_gotcha- honeypot; leave empty
The request is checked in this order: form exists → Origin → rate limit → body parse → honeypot → Turnstile → store and email.
curl -X POST "https://your-worker.workers.dev/f/3f9c2a7b1d4e8f60" \
-H "Origin: https://mysite.com" \
-H "Accept: application/json" \
-d "name=Ada&email=ada@example.com&message=Hello&cf-turnstile-response=<token>"{ "ok": true, "id": 42, "turnstile": "passed", "emailed": true }With Accept: text/html (a normal browser form post) and a redirectUrl configured, the response is 303 See Other to that URL instead. emailed: false means the submission was stored but the notification failed.
When TURNSTILE_SECRET_KEY is not set, verification is skipped and responses include "turnstile": "skipped". This is for local development only; always set the secret in production.
GET /forms/:id/submissions
Latest 50 submissions, newest first. Requires Authorization: Bearer <ADMIN_TOKEN>.
{
"form": { "id": "3f9c2a7b1d4e8f60", "ownerEmail": "me@example.com", "...": "..." },
"submissions": [
{
"id": 42,
"fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
"ipHash": "9b74c9897bac770ffc029102a200c5de...",
"userAgent": "Mozilla/5.0 ...",
"createdAt": "2026-10-01T09:05:00.000Z"
}
]
}Error Codes
400-INVALID_BODY,INVALID_EMAIL,INVALID_ORIGIN,INVALID_URL,INVALID_FIELD_NAME,FIELD_TOO_LONG,TOO_MANY_FIELDS,MISSING_TURNSTILE_TOKEN,EMPTY_SUBMISSION401-UNAUTHORIZED(missing or wrong admin token, orADMIN_TOKENnot configured)403-ORIGIN_NOT_ALLOWED,TURNSTILE_FAILED404-FORM_NOT_FOUND415-UNSUPPORTED_CONTENT_TYPE429-RATE_LIMITED502-TURNSTILE_ERROR(siteverify unreachable)500-INTERNAL_ERROR
Wiring it into a static site
examples/contact.html is a plain HTML form that works on any static host:
- Create a form with
POST /formsand copy theendpointinto the form'saction. - Create a Turnstile widget for your site's hostname, put the site key in
data-sitekey, and set the secret asTURNSTILE_SECRET_KEYon the Worker. - Keep the visually hidden
_gotchainput; real users never fill it.
Without JavaScript, the browser posts the form and follows the 303 redirect. The optional script in the example submits with fetch() and Accept: application/json instead. CORS headers are returned for /f/*, so the JSON reply is readable cross-origin.
Use Cases
- Contact and feedback forms on Pages, GitHub Pages, or any static host
- Waitlist or beta sign-up forms without running a server
- Learn Turnstile + Rate Limiting + D1 + Email Sending together
- Replace a third-party form service with something you control
Limitations
- File uploads are dropped; only text fields are stored
- The Rate Limiting binding is per Cloudflare location and eventually consistent, so treat it as abuse protection, not an exact quota
allowedOriginrelies on theOriginheader, which browsers send but scripts can forge; Turnstile is the real bot defense- The honeypot only stops naive bots
- No submission deletion, export, or pagination beyond the latest 50
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/static-form-backend.
import { HONEYPOT_FIELD, MAX_FIELD_LENGTH, MAX_FIELD_NAME_LENGTH, MAX_FIELDS, TURNSTILE_FIELD,} from "../constants/defaults";import type { FormFields, ParseResult } from "../types/form";type Entry = [string, string];function invalid(message: string): ParseResult { return { ok: false, code: "INVALID_BODY", message, status: 400 };}async function readEntries(request: Request): Promise<Entry[] | ParseResult> { const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); if (contentType.includes("application/json")) { let body: unknown; try { body = await request.json(); } catch { return invalid("Invalid JSON body"); } if (!body || typeof body !== "object" || Array.isArray(body)) { return invalid("JSON body must be an object of fields"); } const entries: Entry[] = []; for (const [key, value] of Object.entries(body)) { const values = Array.isArray(value) ? value : [value]; for (const v of values) { if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") { return invalid(`Field "${key}" must be a string, number, or boolean`); } entries.push([key, String(v)]); } } return entries; } if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { let form: FormData; try { form = await request.formData(); } catch { return invalid("Malformed form body"); } const entries: Entry[] = []; form.forEach((value, key) => { if (typeof value === "string") entries.push([key, value]); }); return entries; } return { ok: false, code: "UNSUPPORTED_CONTENT_TYPE", message: "Use application/x-www-form-urlencoded, multipart/form-data, or application/json", status: 415, };}/** Text fields only: file parts in multipart bodies are dropped. Repeated keys are joined with ", ". */export async function parseSubmission(request: Request): Promise<ParseResult> { const entries = await readEntries(request); if (!Array.isArray(entries)) return entries; const fields: FormFields = {}; let turnstileToken: string | null = null; let honeypotFilled = false; for (const [rawKey, value] of entries) { const key = rawKey.trim(); if (key === TURNSTILE_FIELD) { turnstileToken = value.trim() || null; continue; } if (key === HONEYPOT_FIELD) { honeypotFilled ||= value.trim() !== ""; continue; } if (!key || key.length > MAX_FIELD_NAME_LENGTH) { return { ok: false, code: "INVALID_FIELD_NAME", message: `Field names must be 1-${MAX_FIELD_NAME_LENGTH} characters`, status: 400, }; } const next = key in fields ? `${fields[key]}, ${value}` : value; if (next.length > MAX_FIELD_LENGTH) { return { ok: false, code: "FIELD_TOO_LONG", message: `Field "${key}" exceeds ${MAX_FIELD_LENGTH} characters`, status: 400, }; } fields[key] = next; if (Object.keys(fields).length > MAX_FIELDS) { return { ok: false, code: "TOO_MANY_FIELDS", message: `Submissions are limited to ${MAX_FIELDS} fields`, status: 400, }; } } return { ok: true, fields, turnstileToken, honeypotFilled };}Deployment
Apply migrations, set secrets, and configure email
npx wrangler d1 migrations apply static-form-backend-db --remote
npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put IP_HASH_SALT
npx wrangler email sending enable yourdomain.comSet FROM_EMAIL in wrangler.json to an address on the onboarded domain.
Create a form
curl -X POST "https://your-worker.workers.dev/forms" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ownerEmail":"me@example.com"}'Local Development
cd apps/experiments/static-form-backend
npm install
npx wrangler d1 migrations apply static-form-backend-db --local
echo 'ADMIN_TOKEN=dev-token' > .dev.vars
npm run devcurl -X POST "http://localhost:8787/forms" \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{"ownerEmail":"me@example.com"}'
curl -X POST "http://localhost:8787/f/<id>" -d "name=Ada&message=Hello"Configuration
wrangler.json declares:
- D1
DB(migrations/0000_init.sqlcreatesformsandsubmissions) send_emailbindingEMAIL- Rate Limiting
FORM_RATE_LIMITER(5 requests / 60 seconds per IP) - Var
FROM_EMAIL
Secrets: ADMIN_TOKEN, TURNSTILE_SECRET_KEY, and optional IP_HASH_SALT.
Cloudflare Features Used
- Turnstile - Server-side token validation
- Rate Limiting binding - Per-IP submission limits
- D1 - Forms and submissions
- Email Sending - Owner notifications via
send_email