Deploy a form
Put a working contact form on any site in one command. The gateway handles spam protection, storage and email notification — you just declare the form. New to terminals? Try the step-by-step walkthrough with a form builder that writes the command for you.
How it works
One gateway serves every site. A form is a definition stored in a database — not code — so adding or changing one is a data write that takes effect immediately, with no redeploy.
A page posts its fields to https://forms.flowsmith.online/f/<site>/<form>. The gateway validates, stores the submission, and emails a notification to the form's recipient. You define the form once; the markup to paste into the page is generated for you.
Authentication
Managing forms is done with a service token sent as a header:
Authorization: Bearer <FORMS_ADMIN_TOKEN>
CLI — onboard-form.mjs
The script wraps the API so you never touch raw HTTP. It needs Node 18+ and the repo it ships in. Don't have Node yet? See Set up your computer. (No tooling at all? The step-by-step guide does the same job with a single curl line — nothing to install.)
It reads two environment variables:
| Variable | Required | Meaning |
|---|---|---|
FORMS_ADMIN_TOKEN | required | Your service token. |
FORMS_BASE | optional | Gateway base URL. Default https://forms.flowsmith.online. |
Commands
| Command | What it does |
|---|---|
list | List all form definitions. |
get <site> <form> | Show one definition. |
put <site> <form> [flags] | Create or update a form (idempotent). |
snippet <site> <form> | Print the ready-to-paste HTML for the page. |
delete <site> <form> [--hard] | Deactivate a form (--hard removes it entirely). |
Flags for put
| Flag | Required | Meaning |
|---|---|---|
--recipient <email> | required | Where notifications go. Must be a verified destination in our email routing. |
--origins <a,b> | optional | Comma-separated list of sites allowed to post (apex + www). CORS allowlist. |
--require <a,b,c> | optional | Comma-separated required field names (e.g. name,email,message). |
--subject "…" | optional | Subject line of the notification email. |
--label "…" | optional | Human-readable name shown in the admin. |
--sender "Name <from@…>" | optional | From override. Defaults to the gateway sender. |
--no-turnstile | optional | Disable the Turnstile check (integration testing only). |
Example — onboard a whole site in one line
FORMS_ADMIN_TOKEN=… node scripts/onboard-form.mjs put madhouse contact \
--recipient [email protected] \
--origins https://madhouse.vip,https://www.madhouse.vip \
--require name,email,message
Then get the markup to paste into the page:
FORMS_ADMIN_TOKEN=… node scripts/onboard-form.mjs snippet madhouse contact
HTTP API
If you'd rather call it directly (or from an AI agent), the endpoints under /admin/api/forms mirror the CLI. All require the Authorization: Bearer header.
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/api/forms | List all definitions |
| GET | /admin/api/forms/{site}/{form} | One definition |
| PUT | /admin/api/forms/{site}/{form} | Create or update (idempotent) |
| DELETE | /admin/api/forms/{site}/{form} | Deactivate (?hard=1 to remove) |
| GET | /admin/api/forms/{site}/{form}/snippet | Ready-to-paste HTML |
site and form are slugs: lowercase letters, digits and hyphens.
PUT body
{
"recipient": "[email protected]",
"origins": ["https://madhouse.vip", "https://www.madhouse.vip"],
"subject": "New MADHOUSE enquiry",
"label": "MADHOUSE — Contact",
"require_turnstile": true,
"active": true,
"schema": { "fields": [
{ "name": "name", "required": true },
{ "name": "email", "required": true },
{ "name": "message", "required": true }
] }
}
Response: { "ok": true, "created": true|false, "site": "…", "form": "…" }
Put it on a page — two ways
Dynamic embed (auto-updating)
Paste a placeholder and one script. The fields are pulled live from Flowsmith and rendered using your page's own styling (we output plain inputs). Edit the fields in the admin (or via the API) and every embedding page updates itself — nothing to re-paste.
<div data-kh-embed="madhouse/contact"></div>
<script src="https://forms.flowsmith.online/assets/js/kh-forms-embed.js" defer></script>
Needs JavaScript. Under the hood it reads a public, non-sensitive definition at GET /f/<site>/<form>/def (fields, Turnstile sitekey, endpoint — never the recipient).
Multilingual, automatically. The embed renders in the page's language — it reads the page's <html lang> (override with data-kh-lang="de", fallback the visitor's browser, then English). Labels, the button, status messages and locale-true example placeholders (a native-looking name, phone, company) all come back translated, and the form flips to right-to-left for languages like Arabic and Hebrew. Around 100 ISO language codes are recognised; any not yet translated render correctly in English until their words are filled in. On a multi-language site the form simply switches with the page.
Static snippet (fixed)
The generated HTML (from the CLI snippet command or the admin's Snippet button). Works without JavaScript; the fields are fixed at paste time — to change them, paste the new snippet.
Form fields
Any field your form posts is stored and shown in the admin. A few names are special:
| Field | Meaning |
|---|---|
email | Validated as an email; used as the Reply-To so you can reply straight from your inbox. |
message | Rendered as a textarea in the generated snippet. |
company_website | The honeypot — keep it in the markup, hidden, always empty. Bots fill it; real users don't. |
_redirect | Optional hidden field; for no-JS posts the gateway redirects here on success. |
Mark fields required either with --require (CLI) or the schema body. Required fields are enforced both in the browser and on the server.
Onboarding recipe
- Define the form —
put <site> <form> --recipient … --origins …. It's live immediately. - Get the markup —
snippet <site> <form>. Paste the two script tags once per page and the form where it should appear. - Allowed origins — the
--originsyou set are the CORS allowlist; include apex and www. - Turnstile hostname — add the site's hostname to the kh-forms Turnstile widget (currently a one-off manual step in Cloudflare).
Two common patterns
One form, many pages
Define a form once and paste the same snippet on as many pages as you like — home, about, footer. They all post to the same /f/<site>/<form>, land in one inbox and group together in the admin. Just list every hostname those pages live on in origins (apex + www, and any subdomain).
A different form for one section
When a section needs its own structure — say an academy with different fields or a different recipient — define a second form under the same site with its own slug:
node scripts/onboard-form.mjs put photorobot academy \
--recipient [email protected] \
--origins https://photorobot.com,https://academy.photorobot.com \
--require name,email,course,message
A site can have any number of forms; each has its own fields, recipient and snippet. If the section lives on a different subdomain, add that subdomain to origins.
What you get for free
Every form on the gateway is protected the same way, without you configuring anything:
- Cloudflare Turnstile — invisible bot challenge on each submission.
- Honeypot — a hidden field that silently drops bot submissions.
- Rate limiting — per-IP, across all forms, to blunt floods.
- Origin allowlist — only your listed sites may post.
- Required-field validation — enforced in the browser and again on the server.
- Stored + emailed — every submission lands in the database and as an email; delivery status is recorded.