Was this helpful?

Build contact forms that actually reach you

For business ownersFor agencies and marketers
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.

FailureWhat happensWhy nobody notices
The automation was switched offThe 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 requestThe 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 looksThe 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 successThe 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

  1. The page postsThe form sends its fields to an address on the site's own domain, such as /api/forms/contact.
  2. The handler checksRequired fields, email format, length limits, the spam trap, and how often this address has submitted recently.
  3. 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.
  4. The handler emailsThe notification goes to the business and to a backup address, with the customer's email as the reply-to.
  5. The handler answersA clear success or error goes back to the page, with a status code that matches.
  6. 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

  1. Accept only form posts

    Answer a POST to the form's address and refuse other methods with a 405. If the handler has to live on a different domain than the page, allow only the site's exact origin in its CORS headers.

  2. 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 422 with a plain-language error when a check fails.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

  1. Post to the handler on the same domain

    Set the form's action to 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.

  2. 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.

  3. 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.

  4. Count the lead only when it is real

    Push the generate_lead event 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.

  5. 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" and type="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.

  1. 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.

  2. Find the log line

    The newest line in the submission log should be your test, with the time, the fields, and the source values.

  3. 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.

  4. 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.

  5. Test the refusals

    A malformed email address should return a 422, a GET should return a 405, and rapid repeat submissions should hit the limit.

  6. 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.

  7. 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 405 proves 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

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.

See how I do it

Last updated 2026-09-13 UTC. Written by Tucker Shively.