Change One Attribute and You're Collecting
Each form you create gets a uuid, and that uuid is its endpoint. Set the endpoint as the form's action and you're done. A form with no field schema accepts whatever the browser sends, so an existing form keeps working exactly as it did.
The response depends on how the request arrived. A browser form post gets a 302 to your redirect URL (or to a hosted thank-you page if you haven't set one). A request sent with content-type: application/json gets JSON back instead.
Add enctype="multipart/form-data" and a file input, and uploads are stored with the submission.
A contact form, no JavaScript anywhere:
<form method="POST" action="https://your-app.codehooks.io/f/YOUR-FORM-UUID">
<input name="name" placeholder="Your name" required> <input name="email" type="email" required> <textarea name="message"></textarea>
<!-- bots fill this in, you never see it --> <input type="text" name="_gotcha" hidden>
<button type="submit">Send</button></form>The same endpoint, called with JSON:
curl -X POST https://your-app.codehooks.io/f/YOUR-FORM-UUID \ -H 'content-type: application/json' \ -d '{"name":"Ada","email":"[email protected]"}'
# {"ok":true,"id":"...","submissionId":"..."}Try It Before You Deploy Anything
There's a working client at demo.formbackend.dev that posts cross-origin to a real backend at api.formbackend.dev. It prints the request it's about to send and the response it got back, so you can see the JSON reply and the redirect behaviour side by side. The disclosure at the bottom of the page carries the plain HTML form that does the same thing with no JavaScript.
The demo ships inside the template, at form-backend/example/.
What the Form Backend Does
Everything below runs on a live deployment today
One Endpoint Per Form
Every form gets its own POST /f/:formId address. Set it as the action on an ordinary HTML form and submissions start arriving.
Works Without JavaScript
A plain browser form post gets a 302 to your redirect URL or a hosted thank-you page. Send application/json instead and you get {"ok":true,"id":"..."} back.
JSON, Urlencoded and File Uploads
The same endpoint accepts application/json, application/x-www-form-urlencoded and multipart/form-data. A 2 MB upload round-trips byte for byte (verified by md5).
Optional Typed Validation
A form with no field schema accepts anything, so an existing form keeps working. Define fields and the server enforces types, required, min/max and select options.
Server-Side Domain Allowlist
The Origin or Referer hostname is checked before anything is stored, and matching is exact. Unlike CORS, this rejects the request rather than just hiding the reply.
Inbox API and CSV Export
List, search, filter, star, annotate and delete submissions over a JWT-protected API. Export to CSV with spreadsheet formula injection neutralised.
The Allowlist Is a Server-Side Check
Put hostnames in a form's allowedDomains and the server reads the Origin header (falling back to Referer) and compares the hostname against that list before it stores anything. A request from somewhere else gets a 403 and nothing is written.
That's a different thing from CORS, which only decides whether a browser lets script read the response. CORS headers are set here too, but they aren't what protects the form.
Matching is exact on the full hostname, so near-misses don't slip through. An empty list accepts any origin, which suits a public contact form and stops suiting you the moment you care where submissions come from.
Behaviour verified against a live deployment.
An Inbox You Query, and a CSV You Can Open
Submissions are documents in your Codehooks database. The admin API pages through them, searches across the submitted values, filters by status and date range, and lets you mark a submission read or archived, star it, attach an internal note, or delete it (which removes its uploaded files as well).
Logging in at /admin/login sets a JWT in an HttpOnly, Secure, SameSite=Strict cookie, and every admin route requires it. Uploaded files are served through an authenticated route only, never a public one.
The CSV export escapes values that a spreadsheet would otherwise treat as formulas, so a submitted string starting with = stays text when someone opens the file in Excel.
Log in, read the inbox, export it:
API=https://your-app.codehooks.io
# password login, JWT lands in a cookie jarcurl -c cookies -X POST "$API/admin/login" \ -H 'content-type: application/json' \ -d '{"password":"your-admin-password"}'
# search the inboxcurl -b cookies \ "$API/admin/api/forms/$FORM/submissions?search=ada&status=new"
# everything, as CSVcurl -b cookies -O \ "$API/admin/api/forms/$FORM/export.csv"A Self-Hosted Alternative to Hosted Form Services
Formspree, Getform, Basin and forminit all give you an endpoint to post to. This template gives you the endpoint and the database behind it.
Forms This Is Built For
One backend, one endpoint per form
Contact Forms on a Static Site
A site on Netlify, Vercel, GitHub Pages or S3 has nowhere to POST to. Point the form here and keep the site static.
name • email • messageApplications and Uploads
Job applications, support tickets and anything else that arrives with an attachment. Files are stored with the submission and served only to an admin.
multipart • cv.pdf • screenshot.pngSurveys and Signups
Define a field schema and the server enforces it, including select options and rating ranges. Export the answers as CSV when you want to analyse them.
rating • select • export.csvTypeScript, 103 Tests, No Build Step
Route registration lives in index.ts, and the decisions that are worth testing live in small modules under lib/: body parsing, multipart, validation, the allowlist and redirect rules, search, CSV. The tests run straight on the TypeScript with node --test, so there's nothing to compile before you can run them.
It's a small enough codebase for a coding agent to read in one go, which makes it a reasonable starting point when your forms need something the template doesn't do.
myforms/├── index.ts # routes only├── lib/│ ├── body.ts # json / urlencoded / multipart│ ├── multipart.ts # streaming parser│ ├── validation.ts # typed fields, strict mode│ ├── security.ts # allowlist, safe redirect│ ├── files.ts # filestore uploads│ ├── search.ts # inbox search + paging│ └── csv.ts # export, formula-safe├── test/ # 103 tests, node --test└── example/ # the live demo clientComing Next
This is the first release. The items below are planned and are not in the template today, so don't deploy it expecting them.
- Email notifications on new submissions
- Autoresponder to the person who submitted
- Outgoing webhooks and Slack or Discord notifications
- Spam scoring and honeypot enforcement
- AI triage of incoming submissions
- A visual admin dashboard on top of the inbox API
Until then, submissions are stored and read through the API, and you own the source if you want any of this sooner.
Related Templates
Other things you can deploy from the same CLI
Deploy Your Own Form Backend
One command to deploy, one attribute to change on your form, and the submissions are yours to query.
Form Backend FAQ
Common questions about the self-hosted form backend template
What is a form backend?
at an endpoint, and the service stores what people send. This template deploys that endpoint to your own Codehooks backend, so the submissions land in a database you control.Does it work without JavaScript?
302 to the redirect URL you configured, or to a hosted thank-you page if you haven't set one. Nothing on the page needs to run, and the form works with JavaScript disabled. If you'd rather submit with fetch, send content-type: application/json and the same endpoint replies with {"ok":true,"id":"..."} instead.Can I use it with my existing form?
action attribute. When you want the server to enforce types you can add a schema later, and strict mode on top of that rejects fields you didn't define.How do I stop other sites from posting to my form?
allowedDomains and the server checks the Origin (or Referer) header against that list before anything is stored. Matching is exact on the full hostname, so demo.example.com on the list does not admit evil-example.com or notdemo.example.com. An empty list accepts any origin, which is the right default for a public contact form and the wrong one once you care where submissions come from.How do I stop spam?
_gotcha is stripped from the stored data, so you can include a hidden input that only bots fill in and keep it out of your inbox. Spam scoring and automatic honeypot rejection are not built yet, and are on the list for the next release.Where are uploaded files stored?
/admin/api/submissions/:id/files/:fileId, which requires the admin session cookie, and are sent with content-disposition: attachment and x-content-type-options: nosniff. Deleting a submission deletes its files too.Can I export my data?
GET /admin/api/forms/:formId/export.csv returns every submission for a form as CSV. Submitted values are neutralised first, so a value like =cmd|... can't execute as a formula when the file is opened in Excel. The data is also plain documents in your own Codehooks database, so you can query it directly.How is this different from Formspree or Getform?
How is the admin API protected?
POST /admin/login issues a JWT in an HttpOnly, Secure, SameSite=Strict cookie, and every /admin/api/* route requires it. SameSite=Strict is doing real work here (it's what stops another page from reading your submissions with your cookie), so it's deliberately not relaxed.