A visitor fills in your form and clicks Send. Sometimes they see “Thank you for your message. It has been sent.” Sometimes the button spins forever, or they get “There was an error trying to send your message. Please try again later.” Either way, nothing reaches your inbox, and you only find out when a customer asks why you never replied.
A form submission is a chain: the browser sends the data, something may filter it on the way, the form plugin checks and saves it, and then WordPress hands an email to a mail server. A break anywhere looks the same from the outside. This guide shows how to find the broken link in a few minutes, so you fix the right thing. Mail that leaves WordPress and then disappears is covered in depth in the guide on WordPress not sending email; this guide gets you to that point and covers everything before it.
What is happening
Most form plugins submit with JavaScript in the background (AJAX) to /wp-admin/admin-ajax.php or a REST route under /wp-json/. The plugin then runs validation and anti-spam checks, saves an entry if it stores entries, and calls wp_mail() for each notification. The message you see on screen is chosen by the plugin based on the result.
| Stage |
What breaks there |
Where you see it |
| Browser |
JavaScript error, script delayed or combined by a speed plugin |
Console tab; no request appears in the Network tab |
| Network edge |
Cloudflare or server firewall blocks the POST |
Network tab shows 403 with an HTML block page |
| Security check |
Expired nonce or token from a cached page, failed CAPTCHA |
Response reports spam, invalid nonce, -1 or 403 |
| Plugin logic |
Validation, spam filtering, PHP error |
Response JSON; PHP error log |
| Notification |
Wrong recipient, conditional logic, bad From address |
Entry exists but no email; mail log |
| Delivery |
Mail server rejects or spam-folders the message |
Mail log, bounce, spam folder |
One trap deserves a mention up front: Contact Form 7 shows the same default text, “There was an error trying to send your message”, when mail fails and when the submission is judged to be spam. The screen cannot tell you which happened. The response in the Network tab can.
Before you start
You need an administrator login, a desktop browser with developer tools (Chrome, Edge or Firefox), and ideally SSH or your hosting panel log viewer.
No backup is needed for the diagnostic steps. Back up before editing server configuration or adding code.
How to run commands: SSH into the server and change into the site folder, the one containing wp-config.php. WP-CLI may be called wp or wp-cli on your host. curl runs on your own computer; macOS and Linux include it, and Windows 10 and later include curl.exe, or use WSL. The diagnostic tools can check headers from a browser.
Step 1: Decide which half failed
Submit a test with a unique word in the message, such as TESTQUOKKA, and note the time. Then look for that entry inside WordPress:
- Gravity Forms, Fluent Forms, Formidable, Ninja Forms and WPForms (paid tiers) have an Entries screen under the plugin menu.
- Contact Form 7 does not store submissions on its own. Install Flamingo from the same author and it saves every message under Flamingo, Inbound Messages.
- WPForms Lite does not show full entries in the dashboard, so rely on Step 2 and the email log instead.
- Page builder forms such as Elementor Pro store submissions under their own Submissions screen if that option is on.
If the entry is there, the submission worked and the notification failed: skip to Step 5. If it is missing, or the plugin cannot store entries, carry on with Step 2.
Step 2: Read the response in the Network tab
Open the form page, press F12, select the Network tab and check Preserve log. Submit the form. Click the request that appears (usually admin-ajax.php or feedback) and read the Status column, then the Response panel.
| What you see |
What it means |
Go to |
| No request appears at all |
JavaScript never sent it |
Step 3 |
200, JSON with "status":"mail_sent" (Contact Form 7) or a success flag |
Plugin accepted it and wp_mail() returned true |
Step 5, then delivery |
200, "status":"mail_failed" |
wp_mail() returned false |
Step 6 |
200, "status":"spam" |
Anti-spam check rejected it: CAPTCHA, Akismet, honeypot or nonce |
Step 4 |
200, "status":"validation_failed" |
A field failed server-side rules, often a hidden or conditional field |
Check the invalid_fields list in the JSON |
400 with body 0 from admin-ajax.php |
No code handled that action: the plugin is inactive, or a filter unhooked it |
Plugin and PHP error log |
403 with body -1 |
WordPress nonce check failed |
Step 4 |
| 403 with an HTML page |
A firewall blocked the POST |
Step 4, firewall section |
| 500 |
PHP fatal error during processing |
PHP error log at that time |
Copy the response text. It is the single most useful thing you can send a developer or your host.
Step 3: JavaScript errors and speed plugins
If no request appears, open the Console tab and reload. A red error before you click Send, such as jQuery is not defined or wpcf7 is not defined, means the form script did not load or loaded in the wrong order.
The usual cause is a speed plugin that delays, defers or combines JavaScript. Test by loading the page with the plugin bypass parameter or with optimization switched off for that page. The guide on menus, sliders and forms broken by speed plugins covers the bypass parameters, exclusions and how to find the script that needs excluding. Theme or plugin conflicts are the other cause: disable plugins one at a time on a staging copy until the error stops.
Step 4: Expired tokens on cached pages, and firewalls
Why caching breaks forms after a few hours
Many forms include a WordPress nonce, a short-lived security token. WordPress nonces are valid for 12 to 24 hours by default. For logged-out visitors every person gets the same nonce during that window, so a cached page works for a while. If the page cache keeps the page longer than the nonce lives, every visitor gets an expired token and every submission fails, often with a 403 and -1, or a “spam” result.
The pattern is distinctive: the form works right after you clear the cache, then breaks the next day. Some plugins use their own time-based anti-spam token, which can fail the same way.
Contact Form 7 only checks its nonce for logged-in users. That is why an editor who is logged in can see failures that anonymous visitors never see, if a cache serves them a stale copy.
Confirm the page is cached
Run this on your own computer:
curl -sI https://example.com/CONTACT_PAGE/ | grep -iE "age|cache|expires"
example.com/CONTACT_PAGE/ is the full URL of your form page. -s hides progress and -I fetches only headers. grep -iE filters for caching headers without caring about case. Headers such as cf-cache-status: HIT, x-cache: HIT, x-litespeed-cache: hit or a large age value (seconds since the copy was made) mean the page comes from a cache. An age above 43200, which is 12 hours, is in the danger zone for nonces.
Fix it
- Exclude the form page URLs from your page cache plugin and from any CDN cache rule. This is the most reliable fix.
- Or keep the cache lifespan under 12 hours so pages are rebuilt before their nonces expire.
- If the form appears on every page (footer or popup), use the form plugin’s option to load or refresh the token with AJAX, if it has one.
- Clear every cache layer after the change, then test in a private window.
Expired nonces inside wp-admin, with “The link you followed has expired”, are a different scenario covered in their own guide.
Firewall blocks
A 403 response with an HTML page rather than JSON means something in front of WordPress refused the POST. Cloudflare block pages include a Ray ID; ModSecurity pages often say “Not Acceptable” or “Forbidden” with no WordPress styling. Words in the message such as SQL keywords, script tags or long URLs can trigger a rule. The 403 Forbidden guide shows how to find the rule ID and ask for a scoped exclusion rather than turning the firewall off.
Step 5: Check notification routing
If the entry exists but no email arrived, open the form notification (or Mail) settings and check each field. Most “lost” form emails are routing mistakes, not delivery failures.
Send To
Look for typos, an old staff address, or a merge tag such as {admin_email} that resolves to the site admin email in Settings, General, which may be a developer or an old address. Put a real address in directly while testing.
From versus Reply-To
| Header |
Should be |
Why |
| From |
An address on your own domain, such as website@example.com |
Receiving servers check SPF, DKIM and DMARC against the From domain. A visitor address in From fails those checks and is often dropped silently. |
| Reply-To |
The visitor email field, using the form merge tag |
Clicking Reply in your mail app goes to the visitor, without pretending the message came from them. |
If your From field currently uses the visitor email merge tag, that alone can explain mail that works for some senders and vanishes for others, typically anyone with a Gmail, Yahoo or Outlook address.
Conditional logic and disabled notifications
Gravity Forms, WPForms, Fluent Forms and Formidable can send a notification only when a field matches a value, for example only when “Department” equals “Sales”. Check that each notification is active and that its conditions match your test. A renamed dropdown option can quietly break a condition that worked for years.
Step 6: Prove whether WordPress handed the email over
Install an email logging feature (many SMTP plugins include one, or a dedicated mail logging plugin). Submit a test, then check the log. If the email appears with a success status, WordPress did its part and the problem is delivery. If it shows an error, or nothing at all appears, the problem is inside WordPress.
You can also test wp_mail() directly on the server:
wp eval 'var_dump( wp_mail( "YOU@example.com", "wp_mail test", "Sent from WP-CLI" ) );'
YOU@example.com is an inbox you can check. wp eval runs the PHP inside WordPress, so mail plugins and SMTP settings apply. Good output is bool(true) and the email arriving. bool(false) means WordPress could not hand the message to the mail system. bool(true) with no email means the message left WordPress and was lost later. Both of those cases continue in the guide on WordPress not sending email, which covers SMTP setup, PHP mail and authentication records.
Advanced: trace one submission end to end
When the simple checks disagree, follow a single submission through every log using its UTC timestamp.
Log the reason wp_mail fails
WordPress fires the wp_mail_failed action with an error object whenever sending fails. Create a file in wp-content/mu-plugins/, for example wp-content/mu-plugins/log-mail-failures.php, with this content:
<?php
add_action( 'wp_mail_failed', function ( $error ) {
error_log( 'wp_mail_failed: ' . $error->get_error_message() );
} );
There are no placeholders. Files in mu-plugins load automatically. After the next failed submission, the PHP error log will contain a line such as wp_mail_failed: Could not instantiate mail function. or an SMTP error like SMTP Error: Could not authenticate. The first points to PHP mail being unavailable on the server; the second to wrong SMTP credentials. Delete the file when you are done.
Match the request in the access log
grep "POST ENDPOINT_PATH" ACCESS_LOG | grep "DD/Mon/YYYY:HH:MM"
ENDPOINT_PATH is the URL path from the Network tab, such as /wp-admin/admin-ajax.php. ACCESS_LOG is your access log path from the hosting panel, or /var/log/nginx/access.log or /var/log/apache2/access.log on your own server. DD/Mon/YYYY:HH:MM is the minute of your test in the log format, such as 07/Nov/2026:14:32; note that many servers log in UTC. Good output is one line with status 200. No line means the request never reached the server (a CDN blocked it or JavaScript failed). A 403 or 500 here matches the firewall and PHP error cases above.
Check the mail server log
If you manage the server, search the mail log for your test recipient. On Exim systems it is often /var/log/exim_mainlog; on Postfix, /var/log/mail.log or /var/log/maillog. A delivery line shows the message was accepted by the next server; a rejection line includes an SMTP code that the bounce codes guide explains. On shared hosting, ask your host to search it for you.
When to contact your host
Open a ticket when the request is blocked before WordPress, when wp_mail() returns false, or when the mail log shows rejections. Send:
- The form page URL and the endpoint path.
- The UTC time of a test submission and the test recipient address.
- The HTTP status and response body from the Network tab.
- Any Cloudflare Ray ID or firewall rule ID.
- Matching lines from the PHP error log and the
wp_mail_failed message.
- Your From, Reply-To and Send To settings, and what changed recently.
If the whole site is failing, not just the form, start at the Emergency Room.