webhouse.appwebhouse.appdocs

Collect form submissions with zero third-party dependencies. Define forms in config, render at build time, receive submissions in the admin inbox.

How it works

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

Static site  →  POST /api/forms/contact  →  CMS admin

                                    _data/submissions/contact/*.json

                                    Email + webhook notification

                                    Admin inbox with badge

Define a form

Add a forms array to your cms.config.ts:

typescript
import { defineConfig } from '@webhouse/cms';

export default defineConfig({
  collections: [/* ... */],
  forms: [
    {
      name: 'contact',
      label: 'Contact Form',
      fields: [
        { name: 'name', type: 'text', label: 'Name', required: true },
        { name: 'email', type: 'email', label: 'Email', required: true },
        { name: 'company', type: 'text', label: 'Company', placeholder: 'Optional' },
        { name: 'message', type: 'textarea', label: 'Message', required: true },
      ],
      successMessage: 'Thanks! We will get back to you within 24 hours.',
      notifications: {
        email: ['hello@example.com'],
        webhook: 'https://hooks.slack.com/services/T.../B.../xxx',
      },
      spam: {
        honeypot: true,   // default
        rateLimit: 5,     // max per IP per hour, default
      },
    },
  ],
});

Field types

TypeHTML elementNotes
text<input type="text">General text
email<input type="email">Browser validates format
textarea<textarea>Multi-line, 4 rows default
select<select>Requires options: [{ label, value }]
checkbox<input type="checkbox">Boolean
number<input type="number">Numeric
phone<input type="tel">Phone keyboard on mobile
url<input type="url">URL format
date<input type="date">Native date picker
hidden<input type="hidden">Use defaultValue to set

All fields support:

  • required — browser + server validation
  • placeholder — hint text
  • validation.pattern — regex (e.g. "^[A-Z]" for must-start-with-capital)
  • validation.minLength / maxLength

Three ways to use forms

1. Build output (zero effort)

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.

Link to it from your site: <a href="/forms/contact/">Contact us</a>.

Set CMS_ADMIN_URL to your production admin URL so the form action points at the right place:

bash
CMS_ADMIN_URL=https://cms.example.com cms build

2. Embeddable widget (one script tag)

Drop this on any page — even pages not built by the CMS:

html
<script src="https://cms.example.com/api/forms/contact/widget.js"></script>
<div id="webhouse-form-contact"></div>

The script fetches the form schema, renders a styled form, handles submission via fetch, and shows success/error inline. ~4 KB, zero dependencies, includes honeypot.

3. Custom HTML (full control)

Build your own <form> and POST to the API:

html
<form action="https://cms.example.com/api/forms/contact" method="POST">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <!-- Honeypot: bots fill this, humans don't see it -->
  <div style="position:absolute;left:-9999px">
    <input name="_hp_email" tabindex="-1" autocomplete="off">
  </div>
  <button type="submit">Send</button>
</form>

The endpoint accepts both application/json and application/x-www-form-urlencoded. For JSON:

javascript
fetch('https://cms.example.com/api/forms/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Jane', email: 'jane@x.com', message: 'Hi' }),
});

Spam protection

Two layers enabled by default:

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

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

Admin inbox

Open Forms in the sidebar (below Interactives). Each form shows its unread count.

Click a form to open the inbox:

  • Status dots: blue = new, grey = read, faded = archived
  • Filter tabs: All / New / Read / Archived
  • Detail panel: click any submission to see all fields
  • Actions: Archive, Delete (with inline confirm)
  • CSV export: download button in the action bar

The sidebar badge shows the total unread count across all forms.

Notifications

Configure per form in cms.config.ts:

typescript
notifications: {
  email: ['hello@example.com', 'sales@example.com'],
  webhook: 'https://hooks.slack.com/services/...',
}

Email: uses Resend (RESEND_API_KEY env var) or falls back to console log. From address: CMS_EMAIL_FROM or forms@webhouse.app.

Webhook: POSTs the full submission as JSON to the configured URL. Works with Slack, Discord, Zapier, Make, or any custom endpoint.

F35 event: every submission also fires a form.submitted event through the site's webhook system, so existing Discord/Slack integrations receive it automatically.

CORS

The public /api/forms/* endpoints accept cross-origin requests from:

  • The site's previewSiteUrl (from Site Settings)
  • localhost:3000, localhost:3009, localhost:3011 in development

Production sites should set their previewSiteUrl in Site Settings to match the domain the form lives on.

API reference

Public

MethodPathDescription
POST/api/forms/[name]Submit form
GET/api/forms/[name]/schemaForm field definitions
GET/api/forms/[name]/widget.jsEmbeddable script

Admin (authenticated)

MethodPathDescription
GET/api/admin/formsList forms + unread counts
GET/api/admin/forms/[name]/submissionsList submissions
GET/api/admin/forms/[name]/submissions/[id]Single submission
PATCH/api/admin/forms/[name]/submissions/[id]Update status
DELETE/api/admin/forms/[name]/submissions/[id]Delete
GET/api/admin/forms/[name]/exportCSV download

Chat integration

The CMS chat assistant can list forms and submissions:

  • "Show me all forms"list_forms
  • "Any new contact submissions?"list_form_submissions

Data persistence

Submissions 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:

  • Submissions survive server restarts and redeployments (the directory persists on the volume).
  • Submissions are not included in git-based backups. Use F27 Backup & Restore with a cloud target (S3, pCloud) to include _data/ in automated backups.
  • F122 Beam teleports content but not _data/. Export submissions via CSV before beaming if you need to transfer them.
  • On Docker deploys (Fly.io), mount a persistent volume at the site root so _data/ survives container recreation.

If you need submissions in version control (e.g. for audit), add !_data/submissions/ to your .gitignore.

See also

  • Webhooks — receive form.submitted events in Discord, Slack, or custom endpoints
  • Deployment — ensure CMS_ADMIN_URL is set for production form action URLs
Tags:FormsSchemaWebhooks
Previous
SEO & Visibility
Next
Richtext Editor
JSON API →Edit on GitHub →