In March 2025 a startup founder filled out my contact form to ask about a six-month consulting engagement. I never got the email. He moved on.
Four months later I bumped into him on a different thread and he replied with "thought you weren't interested."
I never figured out exactly what broke. SMTP credentials had rotated three weeks earlier and my Nodemailer wrapper was eating the auth error. My /api/contact endpoint returned 200. My uptime monitor stayed green. My error tracker had nothing to log. Twenty-eight submissions vanished into the void before I noticed.
That single missed submission was worth more than my AWS bill for the entire year.
TLDR
Your contact form is the only page on your site that directly touches revenue. The rest is content. The form is a cash register, and if you wrote your own /api/contact handler, there is a real chance it is leaking right now and you have no way to know.
This post is about why that happens, what a real contact endpoint needs, and a 5-minute test you can run before lunch.
Why devs misclassify the form
Most contact forms are written something like this:
app.post('/api/contact', async (req, res) => {
const { name, email, message } = req.body;
await transporter.sendMail({
from: 'site@mysite.com',
to: 'me@mysite.com',
subject: `Contact from ${name}`,
text: message,
});
res.status(200).json({ ok: true });
});
That code looks fine. It works on your machine. It works in staging. It works the first time you ship it.
Then it sits untouched for years, because you treat it like another endpoint. Just another POST handler. Same priority as /api/health or /api/status.
It is not the same. /api/health failing wakes you up. /api/contact failing is invisible. The user calling it does not refresh the page. They send the message, see the success animation, and assume you got it. Nobody DMs you on Twitter to say "hey, your contact form ate my message, you might want to check it."
A broken /api/contact is the worst kind of bug, because the only person who knows it broke is the person you most needed to hear from.
The silent failure list
Here is a partial inventory of ways my contact handler has actually broken in production across half a dozen projects:
- SMTP credentials rotated by the email provider. The handler returns 200, the email never sends. No exception, because Nodemailer logs the auth failure to stderr by default and your serverless platform discards stderr
- Resend free tier hits 3,000 emails. Submissions 3,001 through whenever-you-notice silently drop with a quota error you never read
- A dependency upgrade changes how multipart/form-data parses. iPhone Safari submits return 415, every other device works fine, you only test on Chrome
- DNS MX record swap during an infra migration. Mail delivered straight to spam for 11 days before anyone checks the recipient inbox
- A scraping bot fires the endpoint 4,000 times overnight. Real submissions get buried under spam. You stop opening the inbox because it is mostly junk
- Vercel cold start times out the first submission of the morning. User retries, gives up after the second try
Each of these felt instantaneous when it broke. Each took me days or weeks to spot.
The common thread: there is no error. Just an absence of an expected signal. And nobody monitors for absence.
Why error tracking cannot catch this
Sentry catches exceptions. A silent 200 with a missing email is not an exception. The handler did its job, by the strictest reading of the code. It returned a status code. The bug is what your handler did not do, and the absence of an action is invisible to a stack trace.
Your uptime monitor catches downtime. The endpoint responds 200, the page loads, the dashboard stays green. Green dashboard, broken revenue.
The only signal that exists is a real human sending a real message and noticing nothing came back. That signal is one customer follow-up away from you noticing. Which means you only notice when the customer cares enough to follow up. Most do not.
What a production contact endpoint actually needs
I wrote this list on a napkin in 2024 after another silent failure. It was embarrassing how short it was, and how much of it my homegrown handler did not have.
A delivery receipt. Did the email actually leave the server? Not "did the SMTP transaction return 200", but "did the message hit the recipient mailbox". Without this you are flying blind.
A dashboard showing every submission. Regardless of whether the email arrived. The submission and the notification email are two separate concerns. Treating them as one is how silent failures happen.
Spam protection that does not show CAPTCHA. Honeypot fields, timing checks, and rate limits handle 95% of bot traffic without ever interrupting a human. CAPTCHA on a contact form kills conversion. Do not ship it unless you have run out of other options.
Per-IP rate limiting on the endpoint. Bots flood. Without this, your inbox becomes useless and your real submissions get triaged into the trash by your own pattern-matching.
Notification redundancy. Email plus Slack, or email plus Telegram. If one channel breaks, the other still pings you. I learned this the hard way.
Audit log with timestamps, IP, and user agent. When something looks fishy (a submission that mentions a feature you do not ship, or a contact at 4 AM their local time), you want the metadata. When something looks lost, you want a record that proves the submission existed.
Replay capability. When a notification email goes missing, you should be able to forward it to yourself or to the right teammate from a dashboard. Not by writing a SQL query.
Auto-responder for the submitter. A short "we got your message, here is what happens next" email. Proves to the customer that the form worked, which means if they do not hear back from you they will follow up instead of assuming you ghosted them.
You can build all 8 yourself. I have. It is somewhere between 40 and 80 hours of work, depending on how careful you are about edge cases. Then you maintain it for the life of the project.
The build vs buy math
Here is the math I run every time someone asks why I do not just write my own:
Initial build: 40 to 80 hours, depending on how thorough you are
Ongoing maintenance: 10 to 20 hours a year for dependency upgrades, infra changes, and email provider migrations
Hidden cost: every silent failure costs you the value of a missed inbound lead, and you cannot measure this until after it happens
Against that:
Free tier of any decent form service: $0, up to roughly 50 submissions a month
Paid tier: $5 to $15 a month, unlimited
Time to first working submission: under five minutes
I have spent days arguing with engineers who insist they can do it in an afternoon. They are right, they can. The first time. The cost is not the first time. The cost is years 2 through 5 of every project they ship, multiplied by the fact that they will never spot the silent failures.
The stack choice
There is no single right answer. The right hosted form service depends on your stack and your budget.
- Formspree has been around since 2017, well-tested, decent free tier
- Basin is the same shape with simpler pricing
- Web3Forms is the cheapest option I know if you just need an inbox
- Getform has the best file upload support I have seen
- FormTo is the one I built (formto.dev), because I wanted self-host plus custom SMTP plus a dashboard I actually wanted to open every morning
The brand matters less than the fact that you stop trusting your own /api/contact and start trusting a service whose only job is to not lose your submissions. That single change moves the failure mode from "invisible" to "someone else's dashboard with a status indicator."
When NOT to use a hosted form service
Three real cases where rolling your own makes sense:
You have hard data residency requirements. If your industry forbids submission data crossing into US-based SaaS, you either self-host an open-source option (FormTo has a self-host build, Formspree does not) or you build your own. The decision then becomes self-host vs DIY, and self-host still wins on time.
Your form is one input to a complex pipeline. If submissions trigger a workflow that touches your auth, your billing, your CRM, and your internal Slack in real time, a hosted form adds a hop you have to coordinate. At that point your form is part of your product, not a marketing surface, and you should treat it like product code with the same rigor as your billing path.
You are at zero visitors and learning. If you are building your first SaaS and the contact form sees three submissions a year, the failure cost is small enough that the learning value of building it yourself wins. Build it badly, watch it break, then switch to a hosted service the day you actually start caring about leads.
If none of those describe you, the math is not close. Use a hosted service.
The 5-minute test you should run right now
Stop reading and do this:
- Open your live site in an incognito window
- Fill out your contact form with a Gmail address you do not normally check
- Submit it
- Open the Gmail inbox
Now verify four things:
- Did the email arrive at all?
- Did it land in inbox, not spam?
- Did it arrive in under 60 seconds?
- Is the from-address sane, or does it look like a default
noreplyyou forgot to configure?
If you cannot confidently say yes to all four, your form is leaking. Maybe a little, maybe a lot. You will not know until you look.
I run this test on every project I own once a quarter. It takes five minutes. It has surfaced two silent failures so far this year.
Back to the founder from March 2025
He never came back. I built him a perfectly normal contact form in 30 minutes one weekend in 2021 and assumed it would keep working. It did, until it did not, and then it lied to me about whether it was working.
The cost of a working contact form is between $0 and $15 a month. The cost of a broken contact form is every inbound lead you miss until the day a customer pings you on Twitter to ask why you ghosted them. Those numbers are not close.
Treat the form like the cash register it is. Use a service. Run the test.
When was the last time you tested your contact form in production, not in your dev environment? Be honest.
Top comments (0)