The "Ghost Lead" Problem: How to Test Form Webhooks, Error States and API Endpoints Before Going Live
The visitor saw "Thank you". The conversion tag fired. The lead does not exist. This is the most expensive silent failure on the web, and it is entirely preventable.

A ghost lead is a submission the visitor believes succeeded and the business never receives. The pattern is always the same: the front end shows a success state on the click or on any response, the backend or the downstream integration fails, and nothing anywhere records the loss. The visitor does not follow up, because as far as they know they already did.
This is worse than a form that visibly breaks. A broken form gets reported within hours. A ghost lead goes unnoticed until someone compares ad spend to pipeline at month end and finds a gap nobody can explain.
Where the leads actually disappear
| Failure | What the visitor sees | Typical cause |
|---|---|---|
| Success shown on click | Thank you | Handler updates UI before awaiting the response |
| Backend 500 | Thank you | Response never checked with res.ok |
| Webhook timeout | Thank you | CRM call is fire-and-forget with no retry |
| CRM validation rejection | Thank you | Required field missing in the mapped payload |
| Spam filter | Thank you | Score threshold silently drops the submission |
| Email notification only | Thank you | Notification lands in spam, nothing is stored |
The last one deserves its own warning. A form whose only output is an email notification has no durable record. One spam-filter rule change and the leads stop arriving with no trace that they ever existed. Always write to a database or CRM first and treat email as a notification, not storage.
Test the endpoint, not the interface
Submitting the form in a browser tests the happy path through the UI. It tells you nothing about what the server did. Call the endpoint directly and read the actual response.
curl -i -X POST https://example.com/api/leads \
-H "Content-Type: application/json" \
-d '{
"name": "QA Test",
"email": "qa+launch@example.com",
"phone": "+919000000000",
"source":"pre-launch-check"
}'You are looking for three things: a 2xx status, a response body containing an identifier you can search for downstream, and a response time that is not several seconds. Then go and find that identifier in the CRM. If you cannot find it, the endpoint accepted the lead and lost it — which is a different bug from rejecting it, and a more dangerous one.
Run the failure cases too. These are the ones nobody tests:
# Missing required field — should return 4xx, not 500
curl -i -X POST https://example.com/api/leads \
-H "Content-Type: application/json" -d '{"name":"No Email"}'
# Malformed body — should return 400
curl -i -X POST https://example.com/api/leads \
-H "Content-Type: application/json" -d 'not json'
# Oversized payload — should be rejected cleanly
curl -i -X POST https://example.com/api/leads \
-H "Content-Type: application/json" \
-d "{\"name\":\"$(head -c 20000 /dev/zero | tr '\0' 'a')\"}"Fix the front end so it cannot lie
Most ghost leads are one missing check. The handler must await the response, inspect the status, and only then show success.
async function onSubmit(e) {
e.preventDefault()
setStatus('sending')
try {
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
// The check that prevents ghost leads:
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Server returned ${res.status}`)
}
const data = await res.json()
setStatus('done')
window.dataLayer?.push({ event: 'generate_lead', lead_id: data.id })
} catch (err) {
// Never silently swallow. Tell the visitor, and keep their input.
setStatus('error')
setError('We could not submit that. Please try again, or email us at hello@example.com.')
console.error('lead submit failed', err)
}
}Two details in that snippet matter as much as the status check. The error message gives the visitor an alternative route, so a failure becomes a lead through another channel rather than a lost one. And the form retains what they typed — clearing a form on error is how you turn one failure into a permanent abandonment.
Accessibility and mobile, because they are conversion features
On a lead form these are not compliance chores. They are the difference between a filled form and an abandoned one on a phone.
- Every input has a real
<label>tied to it withhtmlFor. A placeholder is not a label — it disappears the moment someone types. - Errors are announced:
aria-invalidon the field androle="alert"on the message, so screen readers and voice-control users get the same information. - Font size on inputs is at least 16px. Below that, iOS Safari zooms on focus and the visitor loses the page.
- Use the right
typeandinputMode—type="email",type="tel"withinputMode="numeric"— so the correct keyboard appears. - Add
autoCompleteattributes (name,email,tel) so browser autofill works. On mobile this alone measurably lifts completion. - The submit button is disabled while in flight and says so, or you will get duplicate submissions from impatient double-taps.
<label for="phone">Phone number</label> <input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" aria-invalid="true" aria-describedby="phone-error" style="font-size:16px" > <p id="phone-error" role="alert">Enter a 10-digit mobile number.</p>
The pre-launch form checklist
- Submit through the UI with a real address. Find the record in the CRM by that address.
- Call the endpoint with curl. Confirm 2xx and a returned identifier.
- Submit with a required field missing. Confirm a 4xx and a visible field-level message.
- Kill the network mid-submit (DevTools offline). Confirm an error state, not a success state.
- Confirm the conversion event fires after success and not on click.
- Check the form action URL is not pointing at staging.
- Test on a real phone: keyboards, autofill, no zoom on focus, button reachable without scrolling past it.
- Confirm any spam filter has a visible threshold and logs rejections somewhere you can read.
Steps 5 through 8 overlap with the wider launch sequence — the pre-launch checklist covers where form testing sits relative to policy and tracking checks, and the GTM guide covers firing the conversion from the success path rather than the click.
The HTML & Form Validation module checks form actions, label association, input types, required-field handling and accessible error wiring on any live URL.
Check my formsAdd a canary so you find out first
The strongest protection is not a better test — it is a scheduled submission. A cron job that posts a tagged test lead to your production endpoint every few hours, and alerts if the response is not 2xx or if the record does not appear downstream within a few minutes, turns a silent month-long leak into a notification.
Filter the tagged leads out of your reporting, keep the volume low, and you have continuous proof that the most revenue-critical path on your site still works. That is a couple of hours of setup against the cost of discovering the outage in a quarterly review.
