{
  "slug": "form-engine",
  "title": "Form Engine",
  "description": "Collect form submissions with zero third-party dependencies. Define forms in config, render at build time, receive submissions in the admin inbox.",
  "category": "guides",
  "order": 5,
  "locale": "en",
  "translationGroup": null,
  "helpCardId": null,
  "content": "## How it works\n\nThe CMS admin itself is the form backend. Static sites POST cross-origin directly to the admin API. Submissions are stored as JSON files. The admin has an inbox with unread badges. No Formspree, no Netlify Forms, no vendor lock-in.\n\n```\nStatic site  →  POST /api/forms/contact  →  CMS admin\n                                                ↓\n                                    _data/submissions/contact/*.json\n                                                ↓\n                                    Email + webhook notification\n                                                ↓\n                                    Admin inbox with badge\n```\n\n## Define a form\n\nAdd a `forms` array to your `cms.config.ts`:\n\n```typescript\nimport { defineConfig } from '@webhouse/cms';\n\nexport default defineConfig({\n  collections: [/* ... */],\n  forms: [\n    {\n      name: 'contact',\n      label: 'Contact Form',\n      fields: [\n        { name: 'name', type: 'text', label: 'Name', required: true },\n        { name: 'email', type: 'email', label: 'Email', required: true },\n        { name: 'company', type: 'text', label: 'Company', placeholder: 'Optional' },\n        { name: 'message', type: 'textarea', label: 'Message', required: true },\n      ],\n      successMessage: 'Thanks! We will get back to you within 24 hours.',\n      notifications: {\n        email: ['hello@example.com'],\n        webhook: 'https://hooks.slack.com/services/T.../B.../xxx',\n      },\n      spam: {\n        honeypot: true,   // default\n        rateLimit: 5,     // max per IP per hour, default\n      },\n    },\n  ],\n});\n```\n\n## Field types\n\n| Type | HTML element | Notes |\n|------|-------------|-------|\n| `text` | `<input type=\"text\">` | General text |\n| `email` | `<input type=\"email\">` | Browser validates format |\n| `textarea` | `<textarea>` | Multi-line, 4 rows default |\n| `select` | `<select>` | Requires `options: [{ label, value }]` |\n| `checkbox` | `<input type=\"checkbox\">` | Boolean |\n| `number` | `<input type=\"number\">` | Numeric |\n| `phone` | `<input type=\"tel\">` | Phone keyboard on mobile |\n| `url` | `<input type=\"url\">` | URL format |\n| `date` | `<input type=\"date\">` | Native date picker |\n| `hidden` | `<input type=\"hidden\">` | Use `defaultValue` to set |\n\nAll fields support:\n- `required` — browser + server validation\n- `placeholder` — hint text\n- `validation.pattern` — regex (e.g. `\"^[A-Z]\"` for must-start-with-capital)\n- `validation.minLength` / `maxLength`\n\n## Three ways to use forms\n\n### 1. Build output (zero effort)\n\n`cms build` generates `forms/<name>/index.html` in your output directory. The page contains a styled, accessible `<form>` with async submit, honeypot, and a JS-free fallback.\n\nLink to it from your site: `<a href=\"/forms/contact/\">Contact us</a>`.\n\nSet `CMS_ADMIN_URL` to your production admin URL so the form action points at the right place:\n\n```bash\nCMS_ADMIN_URL=https://cms.example.com cms build\n```\n\n### 2. Embeddable widget (one script tag)\n\nDrop this on any page — even pages not built by the CMS:\n\n```html\n<script src=\"https://cms.example.com/api/forms/contact/widget.js\"></script>\n<div id=\"webhouse-form-contact\"></div>\n```\n\nThe script fetches the form schema, renders a styled form, handles submission via `fetch`, and shows success/error inline. ~4 KB, zero dependencies, includes honeypot.\n\n### 3. Custom HTML (full control)\n\nBuild your own `<form>` and POST to the API:\n\n```html\n<form action=\"https://cms.example.com/api/forms/contact\" method=\"POST\">\n  <input name=\"name\" required>\n  <input name=\"email\" type=\"email\" required>\n  <textarea name=\"message\" required></textarea>\n  <!-- Honeypot: bots fill this, humans don't see it -->\n  <div style=\"position:absolute;left:-9999px\">\n    <input name=\"_hp_email\" tabindex=\"-1\" autocomplete=\"off\">\n  </div>\n  <button type=\"submit\">Send</button>\n</form>\n```\n\nThe endpoint accepts both `application/json` and `application/x-www-form-urlencoded`. For JSON:\n\n```javascript\nfetch('https://cms.example.com/api/forms/contact', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ name: 'Jane', email: 'jane@x.com', message: 'Hi' }),\n});\n```\n\n## Spam protection\n\nTwo layers enabled by default:\n\n1. **Honeypot** — a hidden field (`_hp_email`) that's invisible to humans but bots auto-fill. If the field has any value, the submission is silently accepted (returns 200) but never stored — so bots think they succeeded.\n\n2. **IP rate limiting** — max 5 submissions per IP per hour (configurable via `spam.rateLimit`). Returns 429 when exceeded. IP addresses are hashed (SHA-256, truncated to 8 hex chars) before any storage — GDPR-friendly.\n\nOptional third layer: **Cloudflare Turnstile**. Set `TURNSTILE_SECRET_KEY` in your admin's `.env`, add the Turnstile script to your form page, and include the `cf-turnstile-response` field in the POST body. The endpoint validates the token server-side.\n\n## Admin inbox\n\nOpen **Forms** in the sidebar (below Interactives). Each form shows its unread count.\n\nClick a form to open the inbox:\n\n- **Status dots**: blue = new, grey = read, faded = archived\n- **Filter tabs**: All / New / Read / Archived\n- **Detail panel**: click any submission to see all fields\n- **Actions**: Archive, Delete (with inline confirm)\n- **CSV export**: download button in the action bar\n\nThe sidebar badge shows the total unread count across all forms.\n\n## Notifications\n\nConfigure per form in `cms.config.ts`:\n\n```typescript\nnotifications: {\n  email: ['hello@example.com', 'sales@example.com'],\n  webhook: 'https://hooks.slack.com/services/...',\n}\n```\n\n**Email**: uses Resend (`RESEND_API_KEY` env var) or falls back to console log. From address: `CMS_EMAIL_FROM` or `forms@webhouse.app`.\n\n**Webhook**: POSTs the full submission as JSON to the configured URL. Works with Slack, Discord, Zapier, Make, or any custom endpoint.\n\n**F35 event**: every submission also fires a `form.submitted` event through the site's webhook system, so existing Discord/Slack integrations receive it automatically.\n\n## CORS\n\nThe public `/api/forms/*` endpoints accept cross-origin requests from:\n\n- The site's `previewSiteUrl` (from Site Settings)\n- `localhost:3000`, `localhost:3009`, `localhost:3011` in development\n\nProduction sites should set their `previewSiteUrl` in Site Settings to match the domain the form lives on.\n\n## API reference\n\n### Public\n\n| Method | Path | Description |\n|--------|------|-------------|\n| POST | `/api/forms/[name]` | Submit form |\n| GET | `/api/forms/[name]/schema` | Form field definitions |\n| GET | `/api/forms/[name]/widget.js` | Embeddable script |\n\n### Admin (authenticated)\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/api/admin/forms` | List forms + unread counts |\n| GET | `/api/admin/forms/[name]/submissions` | List submissions |\n| GET | `/api/admin/forms/[name]/submissions/[id]` | Single submission |\n| PATCH | `/api/admin/forms/[name]/submissions/[id]` | Update status |\n| DELETE | `/api/admin/forms/[name]/submissions/[id]` | Delete |\n| GET | `/api/admin/forms/[name]/export` | CSV download |\n\n## Chat integration\n\nThe CMS chat assistant can list forms and submissions:\n\n- *\"Show me all forms\"* → `list_forms`\n- *\"Any new contact submissions?\"* → `list_form_submissions`\n\n## Data persistence\n\nSubmissions live in `_data/submissions/<form>/` — the same `_data/` directory that stores users, team files, and backup metadata. This directory is **gitignored by default**, which means:\n\n- Submissions survive server restarts and redeployments (the directory persists on the volume).\n- Submissions are **not** included in git-based backups. Use [F27 Backup & Restore](/docs/backup-restore) with a cloud target (S3, pCloud) to include `_data/` in automated backups.\n- [F122 Beam](/docs/beam) teleports content but **not** `_data/`. Export submissions via CSV before beaming if you need to transfer them.\n- On Docker deploys (Fly.io), mount a persistent volume at the site root so `_data/` survives container recreation.\n\nIf you need submissions in version control (e.g. for audit), add `!_data/submissions/` to your `.gitignore`.\n\n## See also\n\n- [Webhooks](/docs/webhooks) — receive form.submitted events in Discord, Slack, or custom endpoints\n- [Deployment](/docs/deployment) — ensure `CMS_ADMIN_URL` is set for production form action URLs\n",
  "excerpt": "How it works\n\nThe CMS admin itself is the form backend. Static sites POST cross-origin directly to the admin API. Submissions are stored as JSON files. The admin has an inbox with unread badges. No Formspree, no Netlify Forms, no vendor lock-in.\n\n\nStatic site  →  POST /api/forms/contact  →  CMS admi",
  "seo": {
    "metaTitle": "Form Engine — webhouse.app Docs",
    "metaDescription": "Collect form submissions with zero third-party dependencies. Define, render, and receive — all built into the CMS.",
    "keywords": [
      "webhouse",
      "cms",
      "forms",
      "contact form",
      "submissions",
      "inbox",
      "honeypot",
      "spam"
    ]
  },
  "createdAt": "2026-04-09T00:00:00.000Z",
  "updatedAt": "2026-04-09T00:00:00.000Z"
}