Disclosure first: we build Formgong, one of the seven services below. That is why we wrote down the tests and the pass rules before sending a single request, and why every answer below is quoted as the service returned it.
A contact form on a static or AI-built site usually ends like this:
const response = await fetch(endpoint, { method: "POST", headers: { Accept: "application/json" }, body });
if (response.ok) showThankYou();
So the form is only as honest as the form backend's answer. If the backend replies 200 OK to a key that does not exist, the visitor sees "Thank you", the owner gets nothing, and nobody notices until a customer asks why they never got a reply. In an earlier test of 105 AI-built sites, 27 forms showed success and sent nothing.
We sent the same broken requests to seven form backends on 9 October 2026 to see which ones would catch the mistake.
The tests
Each service got made-up keys only, so nothing was delivered anywhere:
- Placeholder key: the sample key from the service's own docs, the one tutorials and AI builders paste and people forget to replace.
- Unknown key: a random key of the right shape.
- Missing key: no key field at all (only for services where the key is a field, not part of the URL).
- Browser JSON post: can a page on another site read the answer (CORS)?
- Server post: the unknown key again, from a server instead of a browser.
Tests 1–4 ran in a normal Chrome 154 tab on https://example.com, with fetch, JSON and Accept: application/json. Test 5 ran with curl. The rule for tests 1–3 was simple: a wrong key must not get a 2xx status. For test 5, the answer had to be about the key, not a blanket refusal of server calls.
Results
| Service | Placeholder key | Unknown key | Missing key | Browser (CORS) | Server post |
|---|---|---|---|---|---|
| Formspree | ✅ 404 | ✅ 404 | n/a | ✅ | ✅ 404 |
| Web3Forms | ❌ 200, success: true
|
✅ 400 | ✅ 400 | ✅ | ❌ 403 for every server call |
| Forminit (formerly Getform) | ❌ 200 | ❌ 200 | n/a | ✅ | ✅ (200, but about the form) |
| Basin | ✅ 400 | ✅ 400 | n/a | ✅ | ✅ 400 |
| FormSubmit | n/a* | ❌ 200 | n/a | ✅ | ❌ 200, unrelated message |
| Formcarry | ✅ 500 | ✅ 500 | n/a | ✅ | ✅ 500 |
| Formgong | ✅ 404 | ✅ 404 | ✅ 400 | ✅ | ✅ 404 |
* FormSubmit's placeholder is your@email.com, a real domain, and a first submission makes FormSubmit email that address. We did not send mail to a stranger.
Would your form show “Thank you” for a wrong key?
This is the part that matters for real code. Two common success checks, applied to what each service actually returned for a wrong key:
| Service | if (response.ok) |
if (data.success) |
|---|---|---|
| Formspree | No | No |
| Web3Forms, placeholder key | Yes | Yes |
| Web3Forms, unknown key | No | No |
| Forminit | Yes | No |
| Basin | No | No |
| FormSubmit | Yes | Yes |
| Formcarry | No | No |
| Formgong | No | No |
With Web3Forms' sample key, FormSubmit with any wrong address, and Forminit with a status check, a misconfigured form looks like a working one.
What each service answered
Web3Forms: success for the sample key, and no server calls
With YOUR_ACCESS_KEY_HERE, the sample key from Web3Forms' installation docs, the answer was:
{"success": true, "message": "It works. Please insert your actual form_id or access_key to receive emails."}
HTTP 200 and success: true. The message asks you to insert a real key, but the code above never shows the message to anyone. A random key was handled well: 400 and "Invalid Form ID/Access Key! Please double check for extra space!".
From curl, the same request got 403: "This method is not allowed. Use our API in client side or contact support with server IP address (Pro plan is required)". Web3Forms' docs say so too: "Server side usage requires paid plan + server IP whitelisting". If your form posts through a Next.js route, a Server Action or a Supabase Edge Function, it does not work on the free plan.
Forminit (formerly Getform): 200 and “This form has been deleted”
Getform.io now redirects to Forminit. For both the placeholder and a random form ID, Forminit answered HTTP 200 with:
{"success": false, "error": "FORM_NOT_FOUND", "code": 404, "message": "This form has been deleted and is no longer available. Please contact support if you believe this is an error."}
The body is honest (success: false, FORM_NOT_FOUND), but the HTTP status is 200, so response.ok is true. And the message says the form was deleted, for an ID that never existed, which sends you to support instead of to your typo.
FormSubmit: 200, and "success": "false" as a string
For a random form address, FormSubmit answered HTTP 200 with:
{"success": "false", "message": "Email address 5f208483772f23807fae5d4f589e6af4 is not formatted correctly."}
"false" in quotes is a non-empty string, and a non-empty string is truthy in JavaScript, so if (data.success) passes. From curl, the answer was a different 200: "Make sure you open this page through a web server, FormSubmit will not work in pages browsed as HTML files." That message is about local HTML files, not about the request we sent.
Formcarry: right message, wrong status class
Formcarry rejected every wrong ID, which is what counts, with "Form is invalid… Make sure you are using the correct form ID". The status was 500 though, and the body said "code": 401. A 500 tells monitoring tools and retry logic that Formcarry's server broke, when the mistake was in the request.
Formspree and Basin: honest
Formspree answered 404 Form not found everywhere, including from a server. Basin answered 400 with a clear explanation everywhere. Neither will make a broken form look like a working one.
Formgong
404 with "code": "unknown_access_key" for the placeholder and for a random key, 400 with "code": "missing_access_key" when the key was missing, the same answers from a server, and the message in the visitor's browser language (our test tab was set to Ukrainian, so that is what came back).
Free plans, prices and where data lives
Taken from each service's own pricing and privacy pages on 9 October 2026:
| Service | Free submissions / month | Cheapest paid plan | Data stored |
|---|---|---|---|
| Formgong | 300 | $9 / month ($86 billed yearly) | EU |
| Web3Forms | 250 | priced by location (we were shown Turkish lira) | US |
| Forminit | 100 (1 form) | $19 / month | EU (AWS) |
| Formspree | 50 | $15 / month ($10 billed yearly) | US |
| Basin | 50 (1 form) | $15 / month ($12.50 billed yearly) | Canada, with files in the US |
| Formcarry | 50 (1 form) | $6 / month ($5 billed yearly) | EU |
| FormSubmit | "unlimited" | not stated | not stated |
What to take from this
- Whatever you use, test the wrong key once. Put a wrong key in your form, submit, and check that the page shows an error. It takes a minute and it catches the most common way a form silently stops working.
-
Check both the status and the body, and compare
successwith=== true, not truthiness:
const data = await response.json().catch(() => ({}));
if (response.ok && data.success === true) showThankYou();
else showError(data.message || `Error ${response.status}`);
- If you post from a server, rule out services that block server calls on the free plan.
Among the seven, Formspree, Basin and Formgong never let a wrong key look like success. Of those three, Formgong has the largest free plan (300 submissions a month against 50), the lowest paid price ($9 against $15) and keeps data in the EU. You can try it with the same wrong-key test above.
How we tested
One Chrome 154 tab on https://example.com sent tests 1–4 with fetch, six seconds apart to stay under per-IP limits. curl 8.7.1 sent test 5. Each service got its own random key per run; nothing was delivered. An earlier run from an automated (Playwright) Chrome got a Cloudflare bot challenge from Web3Forms instead of its API, so we discarded that run's Web3Forms row and re-ran every service from a normal browser; the other six services gave the same status codes and errors in both runs. The pass rules were fixed before the first request and not changed after.
Top comments (0)