What does a form backend do that my host does not?
A static host serves files; it has nowhere to put a POST. A form backend accepts the request, validates it server-side, filters bot traffic, stores the result, and notifies you.
A form backend is the part nobody wants to write twice: accept a POST from a page you do not control, decide whether the body is usable, throw away the bot traffic, keep what is left, and tell someone it arrived. SubmitHarbor does those five things behind one URL.
Send a flat object. String and number values are read and trimmed; anything else in the body is ignored rather than stored.
const response = await fetch("https://www.submitharbor.com/api/submit/sh_your_project_key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message }),
});
const result = await response.json();
if (!response.ok) {
// result.error is a plain sentence, safe to show the visitor.
showError(result.error);
} else if (result.filtered) {
// A honeypot was populated. Say thank you anyway.
showThanks();
} else {
showThanks();
}{
"ok": true,
"stored": true,
"mode": "firebase",
"id": "9f2c0f1e4b7a48d2",
"notificationSent": true
}stored tells you whether the submission was written down, and mode tells you why. A configured project returns firebase; the shared demo returns preview and echoes the validated submission back instead of keeping it.
The same endpoint reads application/x-www-form-urlencoded and multipart form data, so a native HTML form and a server-side script both work without a JSON wrapper.
curl -X POST https://www.submitharbor.com/api/submit/sh_your_project_key \
-d "email=ada@example.com" \
-d "message=Please send the pricing sheet." \
-d "name=Ada"Field names may contain letters, numbers, underscores, and hyphens. Names outside that set are refused rather than silently renamed, so what you send is what you later read in the inbox and in a CSV export.
Every failure is a status code plus a single sentence. Nothing is swallowed and turned into a false success.
HTTP/1.1 422 Unprocessable Content
{ "ok": false, "error": "Enter a valid email address." }Four outcomes are worth branching on in an integration. The rest are configuration problems you fix once rather than handle in code.
| Response | Meaning | When you get it |
|---|---|---|
| 201 Created | Accepted | The submission passed validation. The JSON body reports whether it was stored and includes an X-RateLimit-Remaining header. |
| 200 OK | Filtered | A honeypot field was populated. The response reports filtered: true and stored: false so a bot cannot tell it was caught. |
| 422 Unprocessable Content | Validation failed | A field limit was exceeded, a field name was unusable, or email and message did not pass their checks. The error string is safe to show a visitor. |
| 429 Too Many Requests | Rate limited | More than 20 submissions per minute from one address to one endpoint. A Retry-After header is included. |
Treat a filtered response the same as a success in your interface. Telling a bot it was caught only teaches whoever wrote it what to change.
Worth reading before you build on it. These limits apply today.
A static host serves files; it has nowhere to put a POST. A form backend accepts the request, validates it server-side, filters bot traffic, stores the result, and notifies you.
Both, on the same endpoint. It reads the Content-Type and parses JSON objects, URL-encoded bodies, and multipart form data.
The submitted fields, the sending origin, a shortened user-agent string, the time received, and a read or unread state. Honeypot and redirect fields are consumed rather than stored.