The gap between those two numbers — submit events vs leads that arrived — is the most under-investigated metric in marketing operations. The dashboard says "1,247 form fills." The CRM has 891 records. Nobody runs that subtraction because nobody owns both sides.
We just rebuilt the form-capture system for this very site after auditing it under that lens. Four failure modes, in order of how often they bite. None of them are exotic. All of them are easy to ship past.
Inputs without name attributes
The form submits. The endpoint logs "received." The payload is empty. new FormData(form) only captures inputs that have name="...". Inputs with only placeholder or only a wrapping <label> are invisible to it.
The fix: every <input>, <select>, <textarea> gets an explicit name. Bonus: matching id + for= on the label gives you accessibility for free, and autocomplete attributes (name, email, organization, tel) double mobile completion rates.
// Broken — placeholder only, no name
<input class="form-input" placeholder="First Last" required>
// Working — name + id + label binding + autocomplete
<label for="apply-name">Your name</label>
<input id="apply-name" name="name" autocomplete="name" required>
The endpoint returns ok: true on failure paths
The client posts to /api/lead. The function tries to email the lead via Resend or Postmark or wherever. Resend isn't configured / the API key is rotated / the From domain is unverified. The function does the friendly thing: it returns { ok: true, delivered: false } so the user sees a clean confirmation. The lead is in a log file. Nobody reads log files.
The fix is a contract: only return ok: true when the lead reached a human-reviewable destination. Inbox, Slack, GHL pipeline, anywhere a human will see it. Otherwise return ok: false with HTTP 502 and let the client handle the failure visibly.
// Bad: looks UX-friendly, costs you the lead
if (!env.RESEND_API_KEY) {
console.log("lead", payload);
return Response.json({ ok: true, delivered: false });
}
// Good: truth in the contract
if (!env.RESEND_API_KEY) {
console.log("lead NOT delivered", payload);
return Response.json({ ok: false, reason: "resend_not_configured" },
{ status: 502 });
}
The client redirects to thank-you.html on every error path
Same anti-pattern, client-side. The submit handler wraps its fetch in try/catch and, in the catch branch, redirects to thank-you anyway "so the user isn't stranded." The user isn't stranded. The user is told it worked when it didn't. Adam never sees the lead.
The fix: on real error, show real error. Inline UI inside the form: "Submission didn't go through" + a "Try again" button + an "Email us instead" button that opens a pre-filled mailto: with the user's form data in the body. The user owns the recovery; you stop losing the lead silently.
// On failure: show error UI with retry + mailto fallback
} catch (err) {
renderError(form, status, payload, err);
// payload also goes to localStorage so the user can recover
// if they navigate away mid-error
}
No mailto fallback
You did everything else right. The API endpoint is down for 90 seconds because Cloudflare rolled out a bad config. You show a clear error. The user hits "Try again" twice. It still fails. Now the user — who had real intent — closes the tab.
The fix is one button: Email us instead. It opens mailto:hi@yourdomain.com with ?subject= and ?body= pre-populated from the user's form data. Even if your whole stack is down, the lead lands in your inbox because the recovery path is the user's own email client.
function buildMailto(payload) {
const lines = Object.entries(payload)
.filter(([k]) => !["submitted_at","form_type"].includes(k))
.map(([k, v]) => `${k}: ${v}`).join("\n");
const subject = `[${payload.form_type}] ${payload.email}`;
const body = `My submission didn't go through. Details:\n\n${lines}`;
return `mailto:hi@yourdomain.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
The audit
Run this once a week on every form on your site:
- HTML: every input in every form has a
nameattribute, anid, a matching<label for="...">, and anautocompletehint. - Server: trace every code path through your form endpoint. Anywhere the function might return success without the lead actually reaching a human, that's a leak. Convert it to a real error.
- Client: the catch branch of your submit handler does not redirect to
/thank-you. It renders an error UI. The error UI has both a retry button and a mailto button. - Storage: the form payload is written to
localStorageon submit, cleared on confirmed success. If the user reloads mid-error, the form pre-fills from the draft. - Reconciliation: bucket your CRM arrivals against your analytics submit events weekly. The two numbers should converge within a few percent. If they don't, you have one of the four failure modes above.
What we ship on every audit
The Scale Audit deliverable includes the form-capture rebuild as a default: explicit names + a11y label binding + autocomplete hints, a server contract that returns ok: false when the lead failed to land, a client error UI with retry + mailto fallback, and a 7-day reconciliation script that flags any gap between analytics and CRM. The full rebuild is usually a half-day of work and recovers leads that were silently disappearing for months.
Pull last week's form-submit events from your analytics with one query. Pull last week's CRM records with another. Diff. If your funnel converts 2.4% normally and your reconciliation gap is 18%, you're not converting 2.4% — you're converting closer to 2.9% and losing 0.5pp to silent failures. That's your "free" lift. Want us to run it on your stack? Apply for the audit.
The one-line summary
Every form on your site is a contract. Inputs need names. Servers need to be honest about failure. Clients need to surface real errors. Users need a fallback that survives every layer being down. Get all four right and the gap between submit events and CRM arrivals closes.
Then watch your conversion rate go up without acquiring a single new visitor.