Build contact forms that actually reach you
On this page
This page shows how to build a contact form that either delivers the lead or tells the visitor it did not. The form posts to a small handler on a server you control. The handler checks the submission, writes it to a log, sends the notification, and reports back honestly. It is the pattern I put on every site I build, because the common alternatives break without telling anyone.
How forms fail without anyone noticing
A broken form rarely looks broken. The page loads, the fields work, and the button responds. The only symptom is a quiet inbox, which is easy to mistake for a slow week.
| Failure | What happens | Why nobody notices |
|---|---|---|
| The automation was switched off | The form posts to a third-party automation or webhook service. The workflow gets switched off, or the account behind it lapses, and submissions go nowhere. | Nothing on the page changes. |
| The browser blocks the request | The form posts to a different domain than the page. The browser first asks that server for permission (a preflight request), and if the answer lacks the right headers, the browser refuses to send. | Tests from the command line still work, because only browsers enforce the rule. |
| The email goes where nobody looks | The notification lands in spam, in a former employee's inbox, or at the address of whoever built the site. | The visitor saw a thank-you, and technically the form did its job. |
| The page fakes success | The script shows the thank-you message without checking whether the server received anything, or the form was never connected to anything at all. | Every test looks like a success. |
All four share one weakness: nothing independent records the submission. If the email never arrives, there is no trace that a customer tried, and nothing to raise an alarm on.
The pattern: a handler you control
- The page postsThe form sends its fields to an address on the site's own domain, such as
/api/forms/contact. - The handler checksRequired fields, email format, length limits, the spam trap, and how often this address has submitted recently.
- The handler logsThe submission is written to a log before anything else can fail, so a record exists even if the email does not go out.
- The handler emailsThe notification goes to the business and to a backup address, with the customer's email as the reply-to.
- The handler answersA clear success or error goes back to the page, with a status code that matches.
- The page tells the truthA thank-you appears only on a confirmed success. Anything else shows an error with the phone number.
The server in the middle is the point. It holds everything secret, such as mail credentials and API keys, so none of it appears in the page, and it drops spam before it reaches an inbox. Posting to the site's own domain also means there is no cross-origin request for a browser to block. For my clients, the backup copy of every notification comes to me, so a lead still gets seen if the business's inbox has a problem.
Before you begin
- A server you control that can run a small script, in PHP or Node, answering on the website's own domain.
- A mail service the server can send through, with the sending domain verified.
- The address that should receive leads, confirmed with the owner, and a second address for the backup copy.
- The list of fields. My default is name, email, phone, and message.
Build the handler
- Accept only form posts
Answer a
POSTto the form's address and refuse other methods with a405. If the handler has to live on a different domain than the page, allow only the site's exact origin in its CORS headers. - Validate every field
Check that required fields are present and the email address is shaped like one. Cap the message length (I use 5,000 characters), the number of fields, and the size of each value, and reject messages with more than two links. Send back a
422with a plain-language error when a check fails. - Clean the single-line fields
Strip line breaks and control characters from any field that can reach an email header, such as the name in the subject line and the email in the reply-to. A line break there lets a spammer inject headers of their own, including extra recipients. Keep line breaks in the message body, where a customer may have written a list.
- Add a honeypot
Include a hidden field, such as
website, that people never see and bots fill in. When it has a value, return a normal success response and discard the submission: no email and no log line. Hide the field from keyboard navigation, screen readers, and browser autofill, not only from sight. - Limit repeat submissions
Cap how often one IP address can submit. My handler allows five an hour and answers the next attempt with a message that gives the business's phone number.
- Write the log line first
Append one line per submission with the time, the fields, the IP address, and the browser's user agent. Keep the log where it cannot be downloaded from the website, because it holds customers' personal details.
- Send the notification
Email the business's address and the backup address, and set the reply-to to the customer's email so the owner can answer with one tap. Mail credentials live in the server's configuration, never in the page and never in the site's code repository.
- Return an honest answer
Respond with
{"ok":true}once the submission is logged and the mail service has accepted the message. Otherwise respond with{"ok":false,"error":"..."}and an error status.
on request to /api/forms/contact:
if method is not POST: respond 405
fields = parse the request body
if fields.website is not empty: # honeypot filled, so a bot
respond 200 {"ok": true} # look normal, keep nothing
if a required field is missing, the email is malformed,
the message is over 5,000 characters, or it has more than 2 links:
respond 422 {"ok": false, "error": "Please check the form."}
if this IP address already submitted 5 times in the last hour:
respond 429 {"ok": false, "error": "Too many attempts. Please call the office."}
name, email, phone = remove line breaks and control characters
message = keep line breaks, remove other control characters
append one line to the submission log: time, fields, IP, user agent
send the email to the business and the backup address, reply-to = email
if the mail service refused the message:
respond 500 {"ok": false, "error": "Not sent. Please call the office."}
respond 200 {"ok": true}
Wire up the page
- Post to the handler on the same domain
Set the form's
actionto the handler's address and send the fields with a short script that waits for the answer. If browsers without JavaScript can submit too, redirect them to a thank-you page and accept only a relative path for that redirect. A handler that redirects anywhere it is told can bounce visitors from the business's domain to a phishing page. - Carry the source with the lead
Add hidden fields for
gclid,fbclid,utm_source,utm_medium,utm_campaign, and the landing page, filled from values the site saved on the visitor's first page view. The handler logs and emails them with the submission, so the lead arrives already tied to the ad or search that produced it. How attribution works explains why. - Build all four states
Idle. Sending, with the button disabled so a double tap does not send twice. Success, where a thank-you replaces the form only after the handler confirms. Error, which keeps what the visitor typed and shows the phone number.
- Count the lead only when it is real
Push the
generate_leadevent to the data layer after the handler confirms success, not when the button is clicked, so GA4 and the ad platforms only count leads that exist. - Make it comfortable on a phone
Full-width fields, each with a visible label, a font size of at least 16 pixels so iPhones do not zoom in on the field, input types such as
type="tel"andtype="email"so the right keyboard appears, and a large submit button.
<form id="lead-form" action="/api/forms/contact" method="post">
... name, phone, email, and message fields, each with a label ...
<div class="trap" aria-hidden="true">
<input name="website" tabindex="-1" autocomplete="off">
</div>
<input type="hidden" name="gclid">
<input type="hidden" name="utm_source">
<button type="submit">Send request</button>
<p class="form-status" role="status"></p>
</form>
<style>.trap { position: absolute; left: -9999px; }</style>
<script>
const form = document.getElementById("lead-form");
const button = form.querySelector("button");
const statusEl = form.querySelector(".form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
statusEl.textContent = "Sending...";
try {
const res = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form)))
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error);
form.innerHTML = "<p>Thanks. Your request came through.</p>";
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: "generate_lead" });
} catch (err) {
statusEl.textContent = "That did not go through. Please call " + BUSINESS_PHONE + ".";
button.disabled = false;
}
});
</script>
Verify it works
A 200 response proves nothing on its own. Check what actually happened.
- Send a real submission through the live page
On a phone, fill in the form the way a customer would and mark the message as a test. The thank-you should appear.
- Find the log line
The newest line in the submission log should be your test, with the time, the fields, and the source values.
- Find both emails
The notification should be in the business's inbox and the backup inbox, not in spam. Hit reply and confirm it addresses the customer's email.
- Trip the honeypot
Send a request with the hidden field filled in. It should return success and add no log line and no email. A honeypot that still logs is a honeypot that does nothing.
- Test the refusals
A malformed email address should return a
422, aGETshould return a405, and rapid repeat submissions should hit the limit. - Break it on purpose
On a staging copy, point the form at an address that does not exist and submit. The page must show the error with the phone number, never the thank-you.
- Look for leaks
View the page source and confirm no keys or passwords appear. Request the log and configuration files by URL and confirm they return a 404 or a 403.
curl -s -X POST https://example.com/api/forms/contact \
-H "Content-Type: application/json" \
-d '{"name":"QA test","email":"you@example.com","message":"test"}'
# Expect {"ok":true}. Then confirm the log line and the email both exist.
# Repeat with "website":"x" in the body: expect {"ok":true}, no log line, no email.
Keep it working
- Monitor the endpoint. Point an uptime monitor at the handler. A plain request that returns
405proves the handler is running without sending a lead, so set the monitor to accept that response if it can. - Retest after every change. Send a real submission after every deploy and after any change to DNS, the mail service, or the receiving inbox, and confirm the log line and the email both exist.
- Reconcile the log. The log is an independent record of every lead. Compare it against the inbox or the CRM from time to time: a log line with no matching lead downstream is a lead lost after the form did its job.
- Treat silence as a signal. When a form that normally brings in leads logs nothing for longer than usual, test it before assuming the phones are just quiet.
Troubleshooting
What's next
- Pre-launch checklist for a small business website: every check to run before a small business website goes live, from placeholder facts and noindex tags to form tests, mobile, speed, and ownership.
- How marketing attribution works for a local business: the plain-language model behind tracking: how a call or form gets tied back to the ad, search, or post that started it, and where most setups lose the thread.
- The 10-minute check customers run on your business: what a customer checks before calling a local business, as a checklist you can run on yourself, with what good looks like and a quick fix for each.
Rather have me do this for you?
I build fast, mobile-first websites with lead capture wired in from the first day, and you own the domain, hosting, and every file.