Nine times out of ten, a broken contact form is one of five problems: the button never actually submits, the fields have no name attributes, the action URL can't receive anything, the request gets blocked with a 405 or CORS error, or the form works fine and it's the email notification that's broken.
This is the checklist I use to figure out which one it is. Quickest checks first.
If you'd rather skip the manual work: paste your form's HTML into our free contact form checker and it names most of these defects automatically, then hands you a fixed version. The rest of this post is for understanding what actually went wrong, and for the failures no static checker can see.
Start with the Network tab
Before touching any code, open your browser's DevTools (F12), switch to the Network tab, and click submit once. What you see there tells you which section to jump to. This works even on WordPress or Squarespace where the markup isn't yours: the request doesn't care who wrote the form.
| What you see | The problem | Go to |
|---|---|---|
| No request at all | The form never submits | Nothing happens |
| A request with an empty payload | Missing name attributes | Submissions arrive empty |
| Your email app opens | A mailto: action | The action can't receive anything |
| A red request: 404, 405, CORS | The endpoint rejects it | The request is blocked |
| A green 200, but no email | Delivery is the problem | The email never arrives |

Nothing happens when you click submit
No request in the Network tab means the browser never tried. Four causes cover almost every case.
The button opts out of submitting. A <button> inside a form submits by default, but type="button" switches that off. It's a common leftover from component libraries:
<!-- This button will never submit the form -->
<button type="button">Send</button>
<!-- This one will -->
<button type="submit">Send</button>The button isn't inside the form. If your markup closes </form> before the button (easy to do with nested divs), clicking it does nothing. Move it inside, or wire it up explicitly with the form attribute: <button form="contact-form">.
JavaScript crashed before the request. If a script intercepts the submit, one error kills the whole flow. Check the Console tab. An error like this one means your script ran before the form existed in the DOM:
Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')Move the script below the form, or wrap it in a DOMContentLoaded listener. And while you're in there: if your handler calls event.preventDefault() and the fetch after it fails, the form does nothing at all. Native submission is the more reliable default unless you need inline behavior.
A hidden required field is blocking validation. If a required field is hidden with CSS (a collapsed accordion step, a display-none wrapper left over from a redesign), the browser refuses to submit and can't show the validation bubble either. Nothing visibly happens, but the console gives it away:
An invalid form control with name='phone' is not focusable.Either remove required from fields you hide or remove the hidden field entirely.
The page reloads and the submission arrives empty
This one is sneaky because everything looks like it works. The page navigates, maybe you even see a thank-you screen, and then the submission shows up with no data in it. Or the request payload in the Network tab is just empty.
The cause is almost always missing name attributes. Browsers build the payload exclusively from fields that have a name. Not id, not placeholder. No name, no data:
<!-- Submits absolutely nothing -->
<input type="email" id="email" placeholder="Your email">
<!-- Submits email=... -->
<input type="email" id="email" name="email" placeholder="Your email">I've seen forms run in production for weeks like this. Every submission arrived, every submission was blank, and nobody noticed because the thank-you page kept loading fine.
Two related gotchas while you're checking names: when two non-checkbox fields share one name, the browser sends both values and most backends keep only one. And if the form has no method="post", it submits with GET and your field values end up in the URL, where they get logged, truncated, and cached.
The action URL can't receive anything
The action attribute is where the browser sends the data. A surprising number of broken forms point it somewhere that was never going to work.
action="mailto:you@site.com" doesn't submit anything. It opens the visitor's email app with the data mangled into a draft, if it opens anything at all; on machines with no mail client configured, it silently does nothing. It also exposes your address to every scraper that reads your HTML.
action="http://localhost:3000/api/contact" works exactly once: on your machine.

A relative path like action="/api/contact" needs a server route answering at that path on your domain. If you deploy to a static host like GitHub Pages or Netlify without a function behind that route, the POST returns a 404 or 405.
The fix for all three is the same: point the action at a real endpoint that accepts form posts. That's your own server route if you have one, or a hosted form backend if you don't. With Form Plume that's one URL:
<form action="https://api.formplume.com/f/your-form-id" method="post">If you're building the form from scratch anyway, the HTML form generator outputs markup with all of this already correct.
The request is blocked: 404, 405, or CORS
A red request in the Network tab means the browser sent the data and the other end refused it.
404 means nothing answers at that URL. Check the action for typos, and on static hosts remember there is no server unless you deployed one.
405 Method Not Allowed is the static-host special: the file at that path exists, but the host only serves GET requests for it. You POSTed to a page, not an endpoint.
CORS errors only happen when JavaScript submits the form with fetch. The message looks like this:
Access to fetch at 'https://api.example.com/submit' from origin
'https://mysite.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.Native form submission (a plain action + method="post") is not subject to CORS at all, which is one good reason to prefer it. If you need fetch for inline success states, the endpoint has to send CORS headers. Your own server route needs to set them; form backends built for browser submission do it out of the box.
The form works and the email is what's broken
If the request returns 200, stop debugging the form. It's done, it delivered. The failure is somewhere in what happens next, and this is the most common "broken form" report of all: the form was never broken.
Check these in order:
- Where do submissions actually go? If your backend has a dashboard or a stored submissions list, look there first. Data present means the form and endpoint both work.
- The spam folder. Notification emails about "someone filled in a form" trip spam filters constantly, especially when the sender is your own domain without proper DNS records.
- The notification address. Typos in the configured recipient are more common than anyone admits.
- Sending limits. If notifications come from your own SMTP or a free email tier, you may have quietly run out.
If you built the email sending yourself with PHP mail() or a raw SMTP call, deliverability is now your full-time hobby. SPF, DKIM, DMARC, sender reputation, the lot. This is the part I'd genuinely outsource: a form backend stores every submission independently of email, so a delivery hiccup never loses the lead.
One more: file uploads arrive empty
If your form has a file input and the files come through as just a filename with no content, the form is missing enctype="multipart/form-data". Without it the browser submits the file's name and throws away the bytes. It needs method="post" too:
<form action="..." method="post" enctype="multipart/form-data">The two-minute version
Everything above that lives in the markup, a static check can catch. Paste your form into the contact form checker and it grades the form, names each defect, and produces a corrected version with names, labels, and a honeypot added. Then send one real submission from the deployed site to prove the endpoint and the delivery, because those two things no static checker can see.
That last step matters. The checklist for staying out of this post in the future is short:
- After every deploy that touches the form, submit it once from the production URL.
- Keep a honeypot field in place so spam doesn't bury the real submissions you're testing for.
- Don't build email delivery yourself unless you want to own SPF and DKIM forever.
