# Static Form Backend

URL: /docs/experiments/static-form-backend
> Contact-form backend for static sites: Turnstile, honeypot, rate limiting, D1 storage, and email notifications
Tags: edge, security, email, forms, turnstile, d1, rate-limiting
Bindings: D1, RATE_LIMITER, EMAIL
Markdown: /llms.mdx/experiments/static-form-backend/content.md



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 [#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 [#api-reference]

### POST /forms [#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).

```bash
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"}'
```

```json
{
  "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 [#post-fformid]

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.

```bash
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>"
```

```json
{ "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.

<Callout type="warning">
  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.
</Callout>

### GET /forms/:id/submissions [#get-formsidsubmissions]

Latest 50 submissions, newest first. Requires `Authorization: Bearer <ADMIN_TOKEN>`.

```json
{
  "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 [#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 [#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](https://developers.cloudflare.com/turnstile/get-started/) 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 [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="static-form-backend"
  bindings="[]"
  platform="[&#x22;Request.formData()&#x22;, &#x22;Turnstile siteverify&#x22;]"
  files="[
  &#x22;src/lib/body.ts&#x22;,
  &#x22;src/lib/turnstile.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/types/form.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
  &#x22;examples/contact.html&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/body.ts&#x22;,&#x22;title&#x22;:&#x22;body.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  HONEYPOT_FIELD,\n  MAX_FIELD_LENGTH,\n  MAX_FIELD_NAME_LENGTH,\n  MAX_FIELDS,\n  TURNSTILE_FIELD,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { FormFields, ParseResult } from \&#x22;../types/form\&#x22;;\n\ntype Entry = [string, string];\n\nfunction invalid(message: string): ParseResult {\n  return { ok: false, code: \&#x22;INVALID_BODY\&#x22;, message, status: 400 };\n}\n\nasync function readEntries(request: Request): Promise<Entry[] | ParseResult> {\n  const contentType = (request.headers.get(\&#x22;content-type\&#x22;) ?? \&#x22;\&#x22;).toLowerCase();\n\n  if (contentType.includes(\&#x22;application/json\&#x22;)) {\n    let body: unknown;\n    try {\n      body = await request.json();\n    } catch {\n      return invalid(\&#x22;Invalid JSON body\&#x22;);\n    }\n    if (!body || typeof body !== \&#x22;object\&#x22; || Array.isArray(body)) {\n      return invalid(\&#x22;JSON body must be an object of fields\&#x22;);\n    }\n    const entries: Entry[] = [];\n    for (const [key, value] of Object.entries(body)) {\n      const values = Array.isArray(value) ? value : [value];\n      for (const v of values) {\n        if (typeof v !== \&#x22;string\&#x22; && typeof v !== \&#x22;number\&#x22; && typeof v !== \&#x22;boolean\&#x22;) {\n          return invalid(`Field \&#x22;${key}\&#x22; must be a string, number, or boolean`);\n        }\n        entries.push([key, String(v)]);\n      }\n    }\n    return entries;\n  }\n\n  if (\n    contentType.includes(\&#x22;application/x-www-form-urlencoded\&#x22;) ||\n    contentType.includes(\&#x22;multipart/form-data\&#x22;)\n  ) {\n    let form: FormData;\n    try {\n      form = await request.formData();\n    } catch {\n      return invalid(\&#x22;Malformed form body\&#x22;);\n    }\n    const entries: Entry[] = [];\n    form.forEach((value, key) => {\n      if (typeof value === \&#x22;string\&#x22;) entries.push([key, value]);\n    });\n    return entries;\n  }\n\n  return {\n    ok: false,\n    code: \&#x22;UNSUPPORTED_CONTENT_TYPE\&#x22;,\n    message: \&#x22;Use application/x-www-form-urlencoded, multipart/form-data, or application/json\&#x22;,\n    status: 415,\n  };\n}\n\n/** Text fields only: file parts in multipart bodies are dropped. Repeated keys are joined with \&#x22;, \&#x22;. */\nexport async function parseSubmission(request: Request): Promise<ParseResult> {\n  const entries = await readEntries(request);\n  if (!Array.isArray(entries)) return entries;\n\n  const fields: FormFields = {};\n  let turnstileToken: string | null = null;\n  let honeypotFilled = false;\n\n  for (const [rawKey, value] of entries) {\n    const key = rawKey.trim();\n    if (key === TURNSTILE_FIELD) {\n      turnstileToken = value.trim() || null;\n      continue;\n    }\n    if (key === HONEYPOT_FIELD) {\n      honeypotFilled ||= value.trim() !== \&#x22;\&#x22;;\n      continue;\n    }\n    if (!key || key.length > MAX_FIELD_NAME_LENGTH) {\n      return {\n        ok: false,\n        code: \&#x22;INVALID_FIELD_NAME\&#x22;,\n        message: `Field names must be 1-${MAX_FIELD_NAME_LENGTH} characters`,\n        status: 400,\n      };\n    }\n    const next = key in fields ? `${fields[key]}, ${value}` : value;\n    if (next.length > MAX_FIELD_LENGTH) {\n      return {\n        ok: false,\n        code: \&#x22;FIELD_TOO_LONG\&#x22;,\n        message: `Field \&#x22;${key}\&#x22; exceeds ${MAX_FIELD_LENGTH} characters`,\n        status: 400,\n      };\n    }\n    fields[key] = next;\n    if (Object.keys(fields).length > MAX_FIELDS) {\n      return {\n        ok: false,\n        code: \&#x22;TOO_MANY_FIELDS\&#x22;,\n        message: `Submissions are limited to ${MAX_FIELDS} fields`,\n        status: 400,\n      };\n    }\n  }\n\n  return { ok: true, fields, turnstileToken, honeypotFilled };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/turnstile.ts&#x22;,&#x22;title&#x22;:&#x22;turnstile.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { TURNSTILE_VERIFY_URL } from \&#x22;../constants/defaults\&#x22;;\nimport type { TurnstileSiteverifyPayload } from \&#x22;../types/form\&#x22;;\n\nexport async function verifyTurnstile(\n  secret: string,\n  token: string,\n  remoteIp?: string\n): Promise<{ success: boolean; errorCodes: string[] }> {\n  const res = await fetch(TURNSTILE_VERIFY_URL, {\n    method: \&#x22;POST\&#x22;,\n    headers: { \&#x22;Content-Type\&#x22;: \&#x22;application/json\&#x22; },\n    body: JSON.stringify({ secret, response: token, ...(remoteIp ? { remoteip: remoteIp } : {}) }),\n  });\n  if (!res.ok) {\n    throw new Error(`Turnstile siteverify returned HTTP ${res.status}`);\n  }\n  const payload = (await res.json()) as TurnstileSiteverifyPayload;\n  return { success: payload.success === true, errorCodes: payload[\&#x22;error-codes\&#x22;] ?? [] };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const TURNSTILE_VERIFY_URL = \&#x22;https://challenges.cloudflare.com/turnstile/v0/siteverify\&#x22;;\nexport const TURNSTILE_FIELD = \&#x22;cf-turnstile-response\&#x22;;\nexport const HONEYPOT_FIELD = \&#x22;_gotcha\&#x22;;\n\nexport const MAX_FIELDS = 30;\nexport const MAX_FIELD_LENGTH = 5_000;\nexport const MAX_FIELD_NAME_LENGTH = 100;\nexport const SUBMISSIONS_PAGE_SIZE = 50;\n\nexport const DEFAULT_FROM_EMAIL = \&#x22;forms@example.com\&#x22;;\nexport const ALLOWED_SCHEMES = [\&#x22;http:\&#x22;, \&#x22;https:\&#x22;] as const;&#x22;},{&#x22;path&#x22;:&#x22;src/types/form.ts&#x22;,&#x22;title&#x22;:&#x22;form.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export type FormFields = Record<string, string>;\n\nexport type ParsedSubmission = {\n  fields: FormFields;\n  turnstileToken: string | null;\n  honeypotFilled: boolean;\n};\n\nexport type ParseResult =\n  | ({ ok: true } & ParsedSubmission)\n  | { ok: false; code: string; message: string; status: 400 | 415 };\n\nexport type CreateFormBody = {\n  ownerEmail?: unknown;\n  allowedOrigin?: unknown;\n  redirectUrl?: unknown;\n};\n\nexport type FormRow = {\n  id: string;\n  owner_email: string;\n  allowed_origin: string | null;\n  redirect_url: string | null;\n  created_at: number;\n};\n\nexport type FormResponse = {\n  id: string;\n  ownerEmail: string;\n  allowedOrigin: string | null;\n  redirectUrl: string | null;\n  endpoint: string;\n  createdAt: string;\n};\n\nexport type SubmissionRow = {\n  id: number;\n  form_id: string;\n  fields: string;\n  ip_hash: string | null;\n  user_agent: string | null;\n  created_at: number;\n};\n\nexport type SubmissionResponse = {\n  id: number;\n  fields: FormFields;\n  ipHash: string | null;\n  userAgent: string | null;\n  createdAt: string;\n};\n\nexport type TurnstileStatus = \&#x22;passed\&#x22; | \&#x22;skipped\&#x22;;\n\nexport type SubmitResponse = {\n  ok: true;\n  id: number | null;\n  turnstile: TurnstileStatus;\n  emailed: boolean;\n};\n\nexport type TurnstileSiteverifyPayload = {\n  success: boolean;\n  \&#x22;error-codes\&#x22;?: string[];\n  hostname?: string;\n};&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Parses any form body, drops honeypot hits, and verifies Turnstile before handling the fields.\n */\nimport { parseSubmission } from \&#x22;../src/lib/body\&#x22;;\nimport { verifyTurnstile } from \&#x22;../src/lib/turnstile\&#x22;;\n\ninterface Env {\n  TURNSTILE_SECRET_KEY: string;\n}\n\nexport default {\n  async fetch(request: Request, env: Env): Promise<Response> {\n    if (request.method !== \&#x22;POST\&#x22;) return new Response(\&#x22;Method not allowed\&#x22;, { status: 405 });\n\n    const parsed = await parseSubmission(request);\n    if (!parsed.ok) {\n      return Response.json({ error: parsed.message, code: parsed.code }, { status: parsed.status });\n    }\n    if (parsed.honeypotFilled) return Response.json({ ok: true });\n\n    const ip = request.headers.get(\&#x22;CF-Connecting-IP\&#x22;) ?? undefined;\n    const { success } = await verifyTurnstile(\n      env.TURNSTILE_SECRET_KEY,\n      parsed.turnstileToken ?? \&#x22;\&#x22;,\n      ip\n    );\n    if (!success) {\n      return Response.json(\n        { error: \&#x22;Turnstile verification failed\&#x22;, code: \&#x22;TURNSTILE_FAILED\&#x22; },\n        { status: 403 }\n      );\n    }\n\n    // Store or forward parsed.fields here.\n    return Response.json({ ok: true, fields: parsed.fields });\n  },\n};&#x22;},{&#x22;path&#x22;:&#x22;examples/contact.html&#x22;,&#x22;title&#x22;:&#x22;contact.html&#x22;,&#x22;lang&#x22;:&#x22;txt&#x22;,&#x22;code&#x22;:&#x22;<!doctype html>\n<!--\n  Drop-in contact form for any static site.\n  1. Replace YOUR_WORKER and FORM_ID with the `endpoint` returned by POST /forms.\n  2. Replace YOUR_SITE_KEY with your Turnstile widget site key.\n  The widget injects a hidden `cf-turnstile-response` input; `_gotcha` is the honeypot.\n-->\n<html lang=\&#x22;en\&#x22;>\n  <head>\n    <meta charset=\&#x22;utf-8\&#x22; />\n    <title>Contact</title>\n    <script src=\&#x22;https://challenges.cloudflare.com/turnstile/v0/api.js\&#x22; async defer></script>\n    <style>\n      .hp {\n        position: absolute;\n        left: -10000px;\n      }\n    </style>\n  </head>\n  <body>\n    <form action=\&#x22;https://YOUR_WORKER.workers.dev/f/FORM_ID\&#x22; method=\&#x22;POST\&#x22;>\n      <label>Name <input name=\&#x22;name\&#x22; required maxlength=\&#x22;200\&#x22; /></label>\n      <label>Email <input name=\&#x22;email\&#x22; type=\&#x22;email\&#x22; required /></label>\n      <label>Message <textarea name=\&#x22;message\&#x22; required maxlength=\&#x22;5000\&#x22;></textarea></label>\n\n      <label class=\&#x22;hp\&#x22; aria-hidden=\&#x22;true\&#x22;>\n        Leave this empty <input name=\&#x22;_gotcha\&#x22; tabindex=\&#x22;-1\&#x22; autocomplete=\&#x22;off\&#x22; />\n      </label>\n\n      <div class=\&#x22;cf-turnstile\&#x22; data-sitekey=\&#x22;YOUR_SITE_KEY\&#x22;></div>\n      <button type=\&#x22;submit\&#x22;>Send</button>\n    </form>\n\n    <!-- Optional: submit with fetch() instead of a full-page post -->\n    <script>\n      document.querySelector(\&#x22;form\&#x22;).addEventListener(\&#x22;submit\&#x22;, async (event) => {\n        if (!window.fetch) return;\n        event.preventDefault();\n        const form = event.currentTarget;\n        const res = await fetch(form.action, {\n          method: \&#x22;POST\&#x22;,\n          body: new FormData(form),\n          headers: { Accept: \&#x22;application/json\&#x22; },\n        });\n        const data = await res.json();\n        form.replaceWith(res.ok ? \&#x22;Thanks! We'll be in touch.\&#x22; : `Error: ${data.error}`);\n      });\n    </script>\n  </body>\n</html>&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/static-form-backend)
  </Step>

  <Step>
    ### Apply migrations, set secrets, and configure email [#apply-migrations-set-secrets-and-configure-email]

    ```bash
    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.
  </Step>

  <Step>
    ### Create a form [#create-a-form]

    ```bash
    curl -X POST "https://your-worker.workers.dev/forms" \
      -H "Authorization: Bearer $ADMIN_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"ownerEmail":"me@example.com"}'
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
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
```

```bash
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 [#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 [#cloudflare-features-used]

* **[Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)** - Server-side token validation
* **[Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)** - Per-IP submission limits
* **[D1](https://developers.cloudflare.com/d1/)** - Forms and submissions
* **[Email Sending](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/)** - Owner notifications via `send_email`
