
FormTo
Open-source, self-hosted form backend for HTML forms
A form POST looks like one HTTP request. It is actually 9 things firing in sequence and parallel, with at least 4 ways to silently lose data.
I have been building a Formspree alternative for the last few months. After tracing the request lifecycle end to end (and finding three bugs in my own code along the way), I wrote up exactly what happens between the browser hitting Submit and the email landing in the owner's inbox.
This is useful if you are building anything that takes user input over HTTP. Most of the work is invisible until it breaks.
Here is the full pipeline for POST /f/:endpoint, in execution order.
1. RATE LIMIT CHECK
Per-IP per-endpoint key in Redis. 5 requests per 60 seconds. Sync. Returns 429 if exceeded. The key shape matters: if you key only on IP you throttle every form on your site when one gets attacked. If you key only on endpoint you throttle real users when one IP goes wild. Stack both.
2. AUTOMATION FINGERPRINT
Check the user agent against known bot signatures (curl, python-requests, headless browsers, common scraper UAs). Returns 429 if matched. Sync. Most spam I see fails here before doing any real work.
3. MULTIPART PARSING AND FILE UPLOAD
Stream the body to a temp file, validate MIME by reading the first 8 bytes (magic numbers, not the Content-Type header which is client-controlled), check the size limit per plan, then upload to object storage. Files hit disk first because the streaming parser does not buffer. Forget the cleanup step in the finally block and you fill the disk in a week.
4. HONEYPOT CHECK
17 invisible field names (website, _gotcha, honeypot, url, phone, fax, and so on) plus a hidden timestamp. If any honeypot is filled or the form was submitted in under 3000ms after page load, return a fake 200 with a fake submission ID. Bots think it worked. They do not retry. This single layer catches around 80% of my spam at zero CPU cost.
5. CAPTCHA VERIFICATION
Only if the form has it enabled. Calls Turnstile or reCAPTCHA v3. Sync HTTP. reCAPTCHA returns a score 0 to 1, anything under 0.5 is blocked. The trap: this call sits on the hot path, so a Google API hiccup adds 800ms to every submission. Set an aggressive timeout and fail open or fail closed deliberately, do not let the network decide for you.
6. ADAPTIVE SPAM SCORE
Heuristic stack: temp-email domain, link density in the message body, missing Origin header, burst rate per client (8 per 300s) and per email address (4 per 600s), plus a SHA256 of (form + client fingerprint + body + files) cached 60s for dedupe. Score above 5 blocks. This catches what honeypots miss without ever showing a CAPTCHA to a human.
7. SUBMISSION INSERT
INSERT into the submissions table. Fields: form_id, endpoint, data (JSONB), file_urls, metadata (IP, user agent, referrer, spam risk score, matched signals). The metadata is the part you will wish you had during incidents. Save more than feels necessary. Also: enforce close-after-N-submissions inside a DB trigger with row locking, otherwise two concurrent submitters race past the limit.
8. EMAIL NOTIFY (fire and forget)
Async. Resend API. Custom HTML template if the form owner configured one, default otherwise. The dangerous detail: this runs after the response is sent. If you await it you add 300ms to every submission and pin the request to one process. If you do not await it and it fails, the user never knows their notification did not ship. I log every failure and alert on a threshold. A proper queue is the real answer.
9. FANOUT (Slack, Discord, Telegram, webhook)
All async, all fire-and-forget. Webhooks get an HMAC-SHA256 signature header (timestamp.payload, signed with a per-user secret) plus a 10s timeout. No retry on the hot path, retries go to a separate worker. The integrations have cruel limits you only discover in production: Slack caps message fields at 8, Discord at 15, Telegram at 4000 characters total. These exist because spammers POST forms with 200 fields and your formatting breaks if you assume small.
THE RESPONSE
201 Created. JSON body: success, submissionId, optional redirect URL. The redirect is server-validated, not trusted from the form data. javascript: and data: protocols live there if you forget.
WHAT I LEARNED REWRITING THIS
Three things that are not obvious until you ship them.
First, the order matters more than the layers. Run cheap checks (rate limit, honeypot, fingerprint) before expensive ones (CAPTCHA, storage, DB). Bots get rejected in 5ms, humans never notice.
Second, fire and forget is a lie. If you do not track what fired and what failed, every silent failure looks like a happy path. I add a status field to every async job: pending, sent, failed. Look at the failed ones once a week.
Third, the response shape is part of the API even when the API is public. Bots watch what you return. Returning a 200 with a fake ID for honeypot hits is more effective than returning a 400, because failure tells them to mutate and retry. Lying is a feature.
The full code is open source if you want to see the actual implementation. github.com/lumizone/formto. The interesting files are routes/public.js for the pipeline and middleware/rateLimit.js for the spam stack.
What is the dumbest spam your forms have caught, and which layer caught it?
Every contact form I have ever owned has lost leads silently. The bugs do not crash. They do not show errors. They return 200 OK and drop the message into a hole. Last month I audited five production setups, including my own. Here are the seven failure modes I found, ranked by how many real submissions they swallowed before anyone noticed.
The from address gets soft-bounced into spam
The form sends notifications from no-reply@yourdomain.com. Gmail's algorithm sees that pattern and routes the email to spam, sometimes to a folder you never check. The submission exists in your database. You never get the notification. Three weeks later you find six leads you forgot to respond to.
How to detect: Search your spam folder for "contact form" or "new submission". If anything is there, you have this bug.
Fix: Use a real from address like forms@yourdomain.com, not no-reply@. Set up SPF, DKIM, and DMARC properly. Send yourself a test submission and verify it lands in the inbox, not promotions, not spam.
Reply-To is missing, so replies bounce
You get a notification. You hit reply. The reply goes back to forms@yourdomain.com instead of the actual lead. They never hear from you. They go to a competitor.
This bug is invisible from your side. The reply leaves your outbox. You feel like you handled the lead. Two weeks later the lead has signed with someone else.
How to detect: Reply to one of your own form notifications. Check whether the recipient is your form sender address or the actual submitter.
Fix: Always set Reply-To to the submitter's email when sending notifications. Sanitize it first (strip newlines, validate format) to avoid header injection.
The serverless function times out at 5 seconds
Your form is wired through a Vercel function. The function does: validate, save to database, send email, send Slack notification, render confirmation. Each step takes 1.5 seconds. The function times out before the email or Slack call lands. The submission is in the database, but you never got pinged.
How to detect: Check your function logs for timeout errors. Look for submissions in the database that don't have a corresponding notification log entry.
Fix: Move slow operations (email, webhook, Slack) into a background queue or fire-and-forget pattern. Return success to the user immediately after the database write. Process notifications in a worker, not in the request handler.
The email API rate-limited you silently
Most email providers cap free tiers at 100 sends a day. You launch a campaign, get 200 submissions, the email API starts returning 429 on submission 101. Submissions keep saving. Notifications stop. You only notice when a customer asks "did you get my message?" three days later.
How to detect: Check the response code from your email API call. If you ignore it (most code does), you have this bug.
Fix: Log every notification send. Set up an alert when the email API returns non-2xx. Better: queue notifications and retry on failure rather than dropping them.
Double-click on submit creates duplicate submissions, one of which fails
User clicks submit. The button doesn't disable. They click again 200ms later. Two requests fire. The first creates the database row. The second hits a unique constraint and 500s. The user sees the error message and assumes the form is broken. They leave.
The first submission did save. You got one notification. The user thinks it failed and emails you separately. You think they got the message twice. Confusion.
How to detect: Open your form, click submit twice quickly, check the network tab. Are both requests sent? Does the second one error?
Fix: Disable the submit button on click. Server-side, deduplicate by submitter email + timestamp window (e.g., same email in 10 seconds = treat as one).
Privacy browsers strip the honeypot timestamp, get treated as bots
Your spam filter checks a hidden timestamp field to detect bots that POST too fast. Brave, Firefox with strict tracking protection, and Tor Browser strip or zero out hidden form fields they don't recognize. Real users on those browsers fail the timing check and get dropped.
I found this one because my own dev account uses Brave. I submitted a test form, got a fake-success response, never saw it in the inbox. Took two days to figure out the browser was the culprit.
How to detect: Submit your form from Brave, Firefox with strict tracking, and a fresh Tor session. Check whether the submission appears.
Fix: If the timestamp is missing or parses to zero or negative, skip the timing check. Don't fail closed. Fall back to honeypot fields and rate limit alone.
HTML or unicode in the submitter's data breaks the email body
Someone fills the form with raw HTML tags in their name field. Your notification email renders that as literal HTML. The body fails to render in Gmail. You see "(no content)" or a broken layout. The actual message is hidden.
Less spectacularly: an emoji or CJK character in the message breaks the encoding header, Gmail rejects the email, you get a bounce hours later.
How to detect: Submit your form with the name field set to a string containing HTML tags. Check whether the notification email renders bold or shows escaped text. The latter is what you want.
Fix: HTML-escape every user-supplied value before injecting it into the email template. Set the Content-Type header explicitly to text/plain or text/html with charset utf-8. Test with non-Latin characters and emoji.
How many of these were silently happening to me
When I audited my own production setup six months ago, I had three of these seven bugs running quietly. The biggest one was #4 (Resend rate limit). I had been losing notification emails for two weeks during a small traffic spike. Customers thought I was ignoring them. I thought I had no leads. The database told a different story when I finally looked.
The fix that mattered most was not a single bug. It was logging every notification attempt with a status, so the next time something silently failed, I could see it instead of trusting that 200 OK actually meant the message reached me.
What I do now
I run a contact form on every site I ship. Each one goes through a setup checklist that includes:
Send a test submission from at least three browsers including Brave
Verify the notification lands in the inbox, not promotions
Reply to the notification and confirm the reply reaches the submitter
Wait until the submission count exceeds the free tier limit, then test again
Log every notification API call with status, retry failures, alert on consecutive errors
After getting tired of building this same setup five times, I open-sourced the form backend. The repo is at https://github.com/lumizone/formto. It handles all seven of the above failure modes by default, so if you want to skip the homework you can self-host it or use the hosted version.
What is the silent failure mode that bit you hardest? Curious which of these you have hit, and which I missed.
1 Like
Comment
Only 1 page on your site touches money
Every lead, every demo request, every "are you hiring?" message my SaaS gets flows through one HTML form. The rest of my site is decoration.
If that form breaks for 24 hours, I lose 100% of my inbound pipeline.
I shipped my own /api/contact handler across 6 different projects over 4 years before I clued in. Wired up Nodemailer or Resend. Hit it once in dev. Watched the email land. Shipped it. Moved on.
That endpoint then sat untouched for months while my SaaS quietly leaked leads.
THE SILENT FAILURE LIST
Things that have actually broken my contact form in production:
- SMTP credentials rotated by my email provider. Endpoint returned 200, email never sent
- Resend free tier hit 3,000 emails. Submissions 3,001 through whenever-I-noticed disappeared
- A dep upgrade changed how multipart/form-data parsed. iPhone submits returned 415, desktop worked fine
- DNS MX record swap during a Cloudflare migration. Mail delivered straight to spam for 11 days
- A bot script hammered my form 4,000 times. Real leads got buried under noise. I stopped opening the inbox
- Vercel cold start timeout on the first submission of the day. User retries, gives up
Each of these felt instant when it broke. Each took me days or weeks to notice. Nobody complains about a form that ate their message. They assume you ghosted them.
THE THING NOBODY MEASURES
Your error tracker does not catch this. Sentry needs an exception. A silent 200 with a missing email is not an exception.
Your uptime monitor does not catch this. The endpoint responds 200. The page loads. Green dashboard, broken revenue.
You catch it when a customer DMs you on Twitter asking why you never replied. By then you have lost months.
WHAT A PRODUCTION CONTACT ENDPOINT ACTUALLY NEEDS
Sit down and write the list. I did this on a napkin in 2024 and got embarrassed.
1. Delivery receipt (did the email actually leave?)
2. A dashboard that shows every submission, regardless of whether the email arrived
3. Spam filter that does not throw CAPTCHA at humans
4. Rate limiting per IP per endpoint
5. Notification redundancy (email plus Slack or Telegram, so if one breaks you still see it)
6. Audit log (timestamps, IP, user agent)
7. Replay (when an email goes missing, can you retrieve and re-send it?)
8. Auto-responder for the submitter (proves to them you got it)
You can build all 8 yourself. I have. It is 60+ hours of work and 100% of your time maintaining it for the next 3 years. Every framework upgrade can break it. Every infra change can break it. Every email provider edge case can break it.
Or you pay $0 to $9 a month and someone else owns it.
THE STACK CHOICE
Pick any hosted form service that gives you a dashboard. Formspree, Basin, Web3Forms, Getform, FormTo (the one I built). The brand matters less than the fact that you can SEE submissions arriving instead of trusting that emails went out.
I built formto.dev because none of the existing tools had self-host plus custom SMTP plus a dashboard I wanted to look at every morning. Honestly, any of them beats your DIY /api/contact handler from 2022.
HOW TO TEST RIGHT NOW
Open your live site. Fill out your contact form. Use a Gmail account you do not normally use.
Did the email arrive? Did it land in inbox, not spam? Did it arrive in under 60 seconds? Was the from-address sane?
If you cannot confidently say yes to all four, you have a leak.
When was the last time you tested your contact form in production, not your dev environment?
1 Like
Comment
Over the last 30 days, FormTo blocked 4,652 spam submissions across the platform. Zero of them ever saw a CAPTCHA.
I built FormTo because I refused to ship CAPTCHA. Public studies put its hit on form conversion at 20 to 40 percent. It punishes the humans you want to keep, and modern bots solve CAPTCHA with AI for less than a cent per attempt.
So I stacked four cheap layers instead:
Honeypot fields. 19 hidden bait names like website, url, phone_number. Real users never see them. Bots fill everything. This single layer catches the large majority.
Timing check. Real users take at least three seconds to fill a form. Most bots POST within 200 ms. A hidden timestamp field rejects anything faster than 3 seconds.
Blocklist. Disposable email domains, known spam patterns in the local part, IP ranges from the StopForumSpam open API. Updates weekly.
Rate limit. 5 submissions per minute per IP per endpoint. Real users never hit it. Brute force fails fast.
The numbers from the last 30 days:
```
Spam attempts: 4,652
Blocked: 4,652 (100%)
CAPTCHA shown: 0
False positives: 4
```
The four false positives were the same person submitting twice on the same form because the success message was unclear. Fixed in the next deploy.
If you run forms anywhere, the honeypot layer alone is worth the fifteen minutes to add. The other three catch the long tail.
FormTo is open source if you want the full implementation, including the list of 19 honeypot field names: github.com/lumizone/formto.
What is your spam stack today?
1 Like
Comment
Most form tools charge you a monthly fee to receive an email when someone fills out a contact form.
I get it — it's a service, there's infra involved. But $20–$40/mo for basic form handling felt like too much, especially across multiple projects.
So I built FormTo. Open-source form backend, MIT-friendly, self-hostable in minutes.
Here's what the setup looks like:
1. Clone and configure One repo, one .env file. Point it at your Supabase project, drop in your Clerk keys for auth, done.
2. Deploy Works on any Node-friendly host. Railway, Render, Fly.io, your own VPS. If you can run a Fastify app, you can run FormTo.
3. Connect your form Standard POST request to your endpoint. No SDK required. Works with any frontend — HTML, React, whatever.
Submissions hit your database directly. You own the data. You control retention, notifications, routing.
The stack: Fastify for the API, Supabase for storage, Clerk for auth, Polar.sh for the hosted version if you don't want to self-host.
It's free if you host it yourself. There's a paid hosted tier if you'd rather not deal with infra.
Repo is public. Still early, but the core works and it's in production on a few of my own projects.
If you've ever thought "I just need a form endpoint, why is this so complicated" — this is for you
1 Like
Comment
Looking for opinions from anyone who's run an open-core or commercial open-source SaaS.
Background: FormTo is a form backend (think Formspree alternative). Self-hosted via Docker, cloud version coming for people who don't want to manage a VPS. I open-sourced it under AGPL-3.0 because that's what Plausible, Cal.com, Grafana, Sentry, Posthog all use — it forces SaaS competitors who fork it to also open-source their changes, while individual self-hosters are unaffected.
The theory: AGPL keeps big players from just hosting my code as a service and undercutting me, while still being genuinely free for the people who want to self-host. The classic "free for self-hosted, pay for hosted" playbook.
The thing I keep going back and forth on:
Will indie devs and small agencies — my actual cloud target — get scared off by AGPL? Even if they're never going to read the license, "open-source" might register as "free, why would I pay" and they self-host instead of subscribing. Plausible obviously made it work. But Plausible is also a niche where compliance/privacy matters and self-hosting analytics is actual work.
A form backend is much simpler. "docker compose up -d", point a domain at it, done. The friction to self-host is way lower than self-hosting analytics. Which makes me wonder if the cloud version is going to struggle.
Three options I'm considering:
1. Stay AGPL, accept the cloud is for people who explicitly don't want to deal with servers. Smaller TAM but cleaner narrative.
2. Switch to source-available (BSL or similar) — keeps the "you can see the code" trust signal but blocks competitive hosting. Loses the open-source goodwill though.
3. Open-core — keep the core AGPL but make some features cloud-only (team management, advanced webhooks, analytics). Felt slimy to me but it's what most successful OSS SaaS actually do.
For anyone who's been through this — what did you actually see in practice? Did the OSS audience and the paying audience overlap or were they totally different people?
(Repo if relevant: github.com/lumizone/formto)
1 Like
Comment
honestly this started as pure frustration.
i kept building landing pages and small sites for clients, and every single time the form question came back: where do submissions actually go? options were always the same boring set:
formspree → $20/mo for 1k submissions, pricing wall hits you fast
typeform → great UX but $25/mo+ and overkill for a contact form
mailchimp embed → ugly, slow, weirdly broken on mobile
DIY backend → 2 hours every time, spam protection always missing
WordPress → only if you're already on WP, and even then the plugins are messy
what got me was that this is a SOLVED problem. it's a form. it has 5 fields. why am i paying $240/year for that.
so i built formto.dev. it's exactly one thing: a free form backend. you point any HTML form's action URL to it, you get submissions in a dashboard, email notifications, spam protection, webhooks. that's it.
three things i decided early that i think matter:
1. open-source (AGPL-3.0). every form backend is closed-source. if formspree goes down or 10x the price tomorrow, you have no exit. AGPL means anyone can self-host it on a $5 VPS and own their data. for client work where form submissions = leads = revenue, that matters.
2. self-hostable AND cloud, same code. docker compose + sqlite for self-host, supabase + postgres for cloud tier. drizzle ORM handles both. same product either way, you pick where it runs.
3. free tier has to be actually usable. formspree gives you 50 submissions/mo free which means you can't actually use it for anything real. mine is more generous because the whole point is being the boring infrastructure that just works.
tech stack for the curious: fastify, supabase (postgres + auth), clerk for SaaS user auth, drizzle ORM, resend for emails, redis for rate limiting, react 19 dashboard, docker for self-host. all the boring choices, none of them sexy, all of them work.
the part i'm self-deprecating about: i thought building it would take a weekend. it took 2 months. spam protection alone was a week. email deliverability another week (resend made this 10x easier than what i had before). the dashboard UI got rebuilt 3 times because i kept changing my mind on whether it should be minimal or feature-rich (ended up minimal, simple wins).
still figuring out:
pricing for the cloud tier ($9 personal / $24 pro / $59 business but honestly no idea if those numbers are right)
whether to push hard on self-hosted or cloud as the primary path
how to compete on SEO against formspree/getform which have been around 8+ years
curious for the OS founders here — how do you balance pushing self-hosted (good for community, bad for revenue) vs cloud (good for revenue, less differentiation)?
(formto.dev + GitHub repo linked there if anyone wants to poke around or self-host. also accepting brutal feedback on the dashboard, that's the part i'm least sure about)
1 Like
Comment
I run a small AI automation agency. FormTo started as a side project — a form backend service for developers, alternative to Formspree and Basin.
Most founders would build in private, polish the MVP, then launch on Product Hunt. I did the opposite. I open-sourced the entire codebase on GitHub before I had a single paying customer.
Here's what that taught me.
Open source forced me to write code I wasn't embarrassed by.
When I knew the repo was public, I stopped writing throwaway hacks. Every commit was something I'd be okay with a stranger reading. The codebase ended up cleaner than anything I'd built in private.
It didn't bring users. Not directly.
I expected GitHub stars to translate into signups. They didn't. Developers who star a repo aren't necessarily customers — they're often other developers curious about the implementation, not people who'd pay for the hosted version.
But it made every sales conversation easier.
When a potential customer asked "what if you disappear" or "what if the service shuts down" — I just pointed at the repo. They can self-host. That single fact closed conversations that would've stalled otherwise.
Free tier matters more than I thought.
I launched with a $0 tier (50 submissions/month). Almost everyone signs up on free first. The conversion to paid happens later, when their form actually starts getting traffic. If I'd launched paid-only, most of those people would never have tried it.
What I'd do differently:
Launch on Product Hunt earlier. I kept polishing because the product "wasn't ready." It was ready three months before I admitted it was ready. Perfectionism just delayed the feedback I needed.
Also: validate the pricing earlier. I picked tier prices ($9 / $24 / $49) based on competitor research, not on talking to actual users. Some of those tiers might be wrong. I won't know until more people are on them.
Anyone else here open-sourced before launch? Curious whether it paid off for you long-term or just made the early days harder.
1 Like
Comment
A friend of mine builds websites for small businesses. Every client needs a contact form. Every time it's the same problem -- Formspree's free tier runs out, clients don't want to pay $20/mo, so he ends up hacking together some webhook-to-Google-Sheets workaround.
I kept hearing this from other devs too. And the problem is so stupidly simple -- receive a POST request, save it, send a notification. That's it. That's the whole product.
So I built FormTo. Self-hosted form backend. You set the action attribute on your HTML form, run docker compose up -d, and submissions show up in a dashboard. Notifications go to email, Telegram, Slack, or any webhook.
Where I'm at right now:
Just open-sourced it on GitHub (AGPL-3.0)
$0 revenue, 0 users (launched this week)
Solo dev, no funding, no co-founder
Built with React, Fastify, PostgreSQL, Caddy
The monetization plan:
The open-source version is fully featured and always will be. No artificial limits, no "upgrade to unlock" tricks.
The money comes from a hosted cloud version. Same product, but I handle the server, backups, HTTPS, updates. Target audience: freelancers and small agencies who want FormTo but don't want to manage a VPS. Thinking $9-15/mo per workspace.
Basically the same playbook as Plausible, Umami, Cal.com -- free self-hosted, paid cloud.
Why I think this can work:
Formspree charges $20/mo and up. Basin, Formcarry -- similar range.
The self-hosted crowd is big and growing. If even 1% of self-hosters convert to cloud because they get tired of maintenance, that's the business.
Form backends are boring infrastructure. People pay for boring infrastructure because it just needs to work.
What I'm figuring out:
Where to find the first 10 paying users for the cloud version
Whether to launch cloud on its own domain or keep everything under formto.dev
How to grow GitHub stars without being spammy about it
Would love to hear from anyone who's done the open-source + paid cloud model. What worked, what didn't?
2 Likes
1 Comment
1 Comment
-
1
Nice approach.
One thing I’ve been running into while building is that AI tools don’t always agree when reviewing code.
Sometimes one flags an issue, another says it’s fine.
It makes debugging and validation a bit tricky.
Curious if you’ve seen that too while building this.
About
Every web dev I know has the same problem -- clients need contact forms but nobody wants to pay $20/mo for Formspree or Basin. The alternatives are janky workarounds with webhooks and Google Sheets. FormTo exists so you


Comment