Test

Contact form API: status codes, errors and rate limits of 10 services

Most contact form APIs look the same in the docs: POST your fields, get JSON back. They stop looking the same when something goes wrong. We run one of them, Formgong, and on 29 October 2026 we sent ten of them the same broken requests to see what a developer actually has to handle.

Short answer: A contact form API is one HTTPS URL: you POST the fields and read a status code and a body. On 29 October 2026 the answers to a wrong key differed a lot. Of 9 services that answered, 2 sent a status that contradicts their own body, 1 replied in plain text, and 3 rate-limited within 30 requests.

Formgong·

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

JSON post with Accept: application/json and a made-up key, 29 October 2026. Web3Forms from the browser; it blocks curl as a server call.
ServiceHTTP statussuccessMachine-readable codeBody (shortened)
Formgong404falsecode: "unknown_access_key"{"success":false,"code":"unknown_access_key","message":"That access key does not exist."}
Formspree404Noneerrors[0].code: "FORM_NOT_FOUND"{"error":"Form not found","errors":[{"code":"FORM_NOT_FOUND",…}]}
Web3Forms400 (browser)falseNone{"success":false,"data":{…your fields…},"message":"Invalid Form ID/Access Key!…"}
Forminit200falseerror: "FORM_NOT_FOUND", code: 404{"success":false,"error":"FORM_NOT_FOUND","code":404,"message":"This form has been deleted…"}
Basin400NoneNone{"error":"Target form endpoint does not exist…"}
FormSubmitNo answer (521)
Formcarry500None (status: "error")code: 401{"code":401,"status":"error","title":"Form is invalid",…}
Formspark404NoneNonePlain text: This form does not exist anymore
Static Forms401falseNone{"success":false,"error":"Invalid API key"}
Splitforms404falsecode: "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":404 in the body. Formcarry sent HTTP 500 with "code":401, although its docs say the two match. Code that checks only res.ok treats 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 success boolean. 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

A success cannot be measured with made-up keys, so this column quotes each service's docs as we read them on 16 October 2026. Formgong's row comes from our own code.
ServiceDocumented success answerWhere
Formgong{"success":true,"id":"…","message":"…"}, HTTP 200Our code
FormspreeNot 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
BasinNot shown in the docs we read
FormSubmitNot shown in the docs we read
Formcarry{"code":200,"status":"success","title":"Thank You!",…}; docs say the HTTP status matches codellms.txt
FormsparkHTTP 200 and "the submitted fields as a JSON object"AJAX docs
Static FormsUnknown: its docs blocked our reader
Splitforms{"success":true,"message":"Submission received"}, HTTP 200Docs

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

30 JSON posts back to back, each sent when the previous answer arrived; stopped at the first 429. Time is the median per request from Turkey, 29 October 2026. Documented limits read on 16 October 2026.
ServiceFirst 429Documented limitMedian answer time
FormgongNone in 3020 per 10 s per IP (our code)0.72 s
FormspreeRequest 2120 per minute0.26 s
Web3FormsNone (all 30 got 403, treated as a server)429, "wait for one hour"0.25 s
ForminitNone in 301 per 5 s per IP0.33 s
BasinNone in 30Not stated in the docs we read0.42 s
FormSubmitNo answer (521)Not stated in the docs we read
FormcarryNone in 301 per 15 s per form and IP0.16 s
FormsparkNone in 30Not stated in the docs we read0.27 s
Static FormsRequest 10 (from its hosting firewall)Unknown: docs blocked our reader0.37 s
SplitformsRequest 76 per minute, 20 per 15 min per form and IP1.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.

JavaScript
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).

← Formgong