How we tested
We posted the same JSON (a name, an email and a message) to each service with a key we made up, so nothing was stored or delivered. Then we sent 30 of those posts back to back to see where a rate limit starts. The rules for reading each answer were written down before the first request. Requests went out with curl from a connection in Turkey on 29 October 2026, 14:12 to 14:26 UTC. A browser check from a page on example.com confirmed the error bodies. FormSubmit answered everything with Cloudflare error 521, an outage on its side that was still there 30 minutes later, so its row is empty. Body formats, CORS and server posts are on the form endpoint page.
What a wrong key returns
| Service | HTTP status | success | Machine-readable code | Body (shortened) |
|---|---|---|---|---|
| Formgong | 404 | false | code: "unknown_access_key" | {"success":false,"code":"unknown_access_key","message":"That access key does not exist."} |
| Formspree | 404 | None | errors[0].code: "FORM_NOT_FOUND" | {"error":"Form not found","errors":[{"code":"FORM_NOT_FOUND",…}]} |
| Web3Forms | 400 (browser) | false | None | {"success":false,"data":{…your fields…},"message":"Invalid Form ID/Access Key!…"} |
| Forminit | 200 | false | error: "FORM_NOT_FOUND", code: 404 | {"success":false,"error":"FORM_NOT_FOUND","code":404,"message":"This form has been deleted…"} |
| Basin | 400 | None | None | {"error":"Target form endpoint does not exist…"} |
| FormSubmit | No answer (521) | |||
| Formcarry | 500 | None (status: "error") | code: 401 | {"code":401,"status":"error","title":"Form is invalid",…} |
| Formspark | 404 | None | None | Plain text: This form does not exist anymore |
| Static Forms | 401 | false | None | {"success":false,"error":"Invalid API key"} |
| Splitforms | 404 | false | code: "invalid_access_key" | {"success":false,"message":"Invalid access_key…","code":"invalid_access_key"} |
Three things in this table break naive client code:
- The status and the body disagree. Forminit sent HTTP 200 with
"code":404in the body. Formcarry sent HTTP 500 with"code":401, although its docs say the two match. Code that checks onlyres.oktreats Forminit's error as a sent message. Code that retries on 5xx keeps retrying Formcarry's wrong key. - Not every error is JSON. Formspark answered a JSON request with a plain-text line, so
await res.json()throws. - There is no shared field. Five of the nine have a
successboolean. Formspree, Basin, Formcarry and Formspark do not. Only four give a stable code you can branch on.
What a successful post returns, according to the docs
| Service | Documented success answer | Where |
|---|---|---|
| Formgong | {"success":true,"id":"…","message":"…"}, HTTP 200 | Our code |
| Formspree | Not shown in the docs we read | |
| Web3Forms | {"success":true,"body":{"data":{…},"message":"Email sent successfully!"}} | API reference |
| Forminit | {"success":true,"redirectUrl":"…","submission":{…}} | Submit form API |
| Basin | Not shown in the docs we read | |
| FormSubmit | Not shown in the docs we read | |
| Formcarry | {"code":200,"status":"success","title":"Thank You!",…}; docs say the HTTP status matches code | llms.txt |
| Formspark | HTTP 200 and "the submitted fields as a JSON object" | AJAX docs |
| Static Forms | Unknown: its docs blocked our reader | |
| Splitforms | {"success":true,"message":"Submission received"}, HTTP 200 | Docs |
Formspree, Basin and FormSubmit do not show their success body in the pages we read, and Static Forms' docs would not load for us. For those four, test with a real form before you rely on any field. Web3Forms' docs put data and message under body, but the error we received had them at the top level, so read both places.
Rate limits: what 30 quick posts got
| Service | First 429 | Documented limit | Median answer time |
|---|---|---|---|
| Formgong | None in 30 | 20 per 10 s per IP (our code) | 0.72 s |
| Formspree | Request 21 | 20 per minute | 0.26 s |
| Web3Forms | None (all 30 got 403, treated as a server) | 429, "wait for one hour" | 0.25 s |
| Forminit | None in 30 | 1 per 5 s per IP | 0.33 s |
| Basin | None in 30 | Not stated in the docs we read | 0.42 s |
| FormSubmit | No answer (521) | Not stated in the docs we read | |
| Formcarry | None in 30 | 1 per 15 s per form and IP | 0.16 s |
| Formspark | None in 30 | Not stated in the docs we read | 0.27 s |
| Static Forms | Request 10 (from its hosting firewall) | Unknown: docs blocked our reader | 0.37 s |
| Splitforms | Request 7 | 6 per minute, 20 per 15 min per form and IP | 1.01 s |
Formspree cut us off at request 21, exactly its documented 20 a minute. Splitforms stopped us at request 7, its documented 6 a minute. Static Forms was stopped by its hosting provider's firewall at request 10, not by the app. None of the three 429 answers carried a Retry-After header, so your code has to pick its own wait. Formgong sends Retry-After: 10 on its burst limit; our 30 posts stayed under it.
Forminit and Formcarry document one post per 5 and 15 seconds, yet 30 posts with a wrong key never triggered either limit. Our guess is that they count only posts to a real form; we could not check that without one.
Formgong was the second slowest of the nine to answer a wrong key, at 0.72 seconds. That is our own number to improve.
A fetch client that copes with all of them
This is the check we would put in front of any of these APIs. It reads the body as text first, so a plain-text error cannot throw. It treats a 2xx as success only when the body does not say otherwise, and it waits on 429. Swap the URL for your service's endpoint, and put its key where its docs say: in the URL or as a form field.
async function send(form) {
let res;
try {
res = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
} catch {
return { ok: false, message: "Network error. Please try again." };
}
const text = await res.text(); // some services answer errors in plain text
let data = null;
try { data = JSON.parse(text); } catch {}
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After")) || 30;
return { ok: false, message: `Too many messages. Try again in ${wait} s.` };
}
// A 2xx alone is not enough: one service answered a broken form with 200.
const ok = res.ok && data !== null && data.success !== false && data.status !== "error";
return { ok, message: data?.message ?? text };
}Before you ship, send the form once with a wrong key. If your page says "thank you", the check is not doing its job. Our free form checker runs the same test on a live page.
What this test does not show
We saw each answer once, from one place, with made-up keys. Answer times from your visitors' countries will differ, and a limit that counts only real submissions will not show up here. The success column is what the docs say, not what we measured. When we run a round with real forms, it goes on this page.
Tell us what to test next
Is a service missing, or did one of them answer you differently? Write to support@formgong.com and tell us what you saw. We read every message, and the next round of this test starts from what readers ask for.
Questions
What is a contact form API?
An HTTPS endpoint that accepts the fields of a contact form, usually as JSON or form data, stores the message and sends it to you. Your code reads the HTTP status and a JSON body to know whether the message was accepted.
How do I know a contact form API call worked?
Check the status and the body together. On 29 October 2026 Forminit answered a wrong key with HTTP 200 and success: false, and Formcarry with HTTP 500 and code 401 in the body. Treat a 2xx as success only when the body does not say success: false or status: "error".
Do contact form APIs have rate limits?
Yes. In 30 back-to-back posts on 29 October 2026, Formspree answered 429 at request 21, Static Forms at 10 and Splitforms at 7. Forminit and Formcarry document per-IP limits that a wrong key did not trigger. None of the 429 answers carried a Retry-After header.
Is there a free contact form API?
Every service in this test can be used for free, within a monthly or one-off allowance. Our test of 13 form backends lists each free plan and what happens to a message over the limit.
Sources
Checked on 29.10.2026 against these public pages: Formspree: system limits, Formspree: submit forms with JavaScript, Web3Forms API reference, Forminit: submit form API, Forminit llms.txt, Basin docs, FormSubmit documentation, Formcarry llms.txt, Formspark: AJAX, Static Forms, Splitforms docs, MDN: CORS preflight request, Formgong API reference (llms.txt).