This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Edge Platform

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-response field
  • _gotcha honeypot: bots get a normal success reply, nothing is stored or emailed
  • Per-IP rate limiting keyed by CF-Connecting-IP
  • Per-form allowedOrigin enforcement and redirectUrl (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 replyTo set from the submitter's email field 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 when TURNSTILE_SECRET_KEY is 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_SUBMISSION
  • 401 - UNAUTHORIZED (missing or wrong admin token, or ADMIN_TOKEN not configured)
  • 403 - ORIGIN_NOT_ALLOWED, TURNSTILE_FAILED
  • 404 - FORM_NOT_FOUND
  • 415 - UNSUPPORTED_CONTENT_TYPE
  • 429 - RATE_LIMITED
  • 502 - 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:

  1. Create a form with POST /forms and copy the endpoint into the form's action.
  2. Create a Turnstile widget for your site's hostname, put the site key in data-sitekey, and set the secret as TURNSTILE_SECRET_KEY on the Worker.
  3. Keep the visually hidden _gotcha input; 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
  • allowedOrigin relies on the Origin header, 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.

DependenciesNoneBindingsNonePlatformRequest.formData(), Turnstile siteverify
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

Click the deploy button

Deploy to Cloudflare Workers

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.com

Set 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 dev
curl -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.sql creates forms and submissions)
  • send_email binding EMAIL
  • 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

On this page