Bay 04JSON in, JSON out
AJAX / fetch
Want to stay on the page and show your own message? Send the form with fetch and an Accept: application/json header. Instead of a redirect you get JSON back, with a proper HTTP status code. It works with any framework, or none.
Using React? @sendm8/react does this for you: a useForm hook that tracks sending, success and errors, with the same API as @formspree/react.
const form = document.querySelector("#contact");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
const result = await res.json();
if (result.ok) form.replaceWith("Thanks! It's on its way.");
else alert(result.error.message);
});<form id="contact">
<label>Your email <input name="email" type="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button>Send</button>
</form>const res = await fetch("https://sendm8.com/f/k3x9q2m7ab", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
email: "alex@moreno.dev",
message: "Hiya! Are you free in October?",
}),
});
const result = await res.json();
// { ok: true, id: "01K53N7XQ2R8M4DE7Q2K9F4X3M" }// npm install @sendm8/react
import { useForm, ValidationError } from "@sendm8/react";
export function ContactForm() {
const [state, handleSubmit] = useForm("k3x9q2m7ab");
if (state.succeeded) return <p>Thanks! It's on its way.</p>;
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<ValidationError field="email" errors={state.errors} />
<textarea name="message" required />
<ValidationError errors={state.errors} />
<button disabled={state.submitting}>Send</button>
</form>
);
}What comes back
Success
HTTP/1.1 200 OK
Content-Type: application/json
{
"ok": true,
"id": "01K53N7XQ2R8M4DE7Q2K9F4X3M"
}Error
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"ok": false,
"error": {
"code": "quota_exceeded",
"message": "This form has reached its monthly submission limit."
}
}ok is always there, so checking it is enough. The id is the submission’s id in your dashboard. On failure, error.message is a plain-English sentence you can show to your visitor as it is, and error.code (like quota_exceeded or rate_limited) stays the same for your code to check.
When the problem is a particular field, there’s also an errors array with one entry per field, shaped like Formspree’s, so you can show each message next to its input. Today that’s an email or _replyto that isn’t an email address (an empty one is fine; use required for that), and a field or file that’s too big. error.message still sums it up.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
{
"ok": false,
"error": {
"code": "invalid_fields",
"message": "Enter a valid email address."
},
"errors": [
{
"field": "email",
"code": "invalid_email",
"message": "Enter a valid email address."
}
]
}| Status | What it means |
|---|---|
| 200 | Stored. A 2xx always means the submission is safely saved. |
| 303 | Browser posts only: redirect to your thank-you page, or to the Turnstile challenge page. |
| 400 | We couldn’t read it: bad JSON, more than 100 fields, or a body over 64 KB without files. Or the endpoint is a throwaway inbox (disposable_endpoint), or a reserved domain like example.com that can’t receive mail (undeliverable_endpoint). |
| 403 | The page it came from isn’t on the form’s allowed domains (localhost always is, for testing), or a Turnstile check failed. |
| 404 | No form with that id. Check for a typo in the URL. |
| 410 | The form has been disabled by its owner. |
| 413 | A file is over 5 MB, or the files add up to more than 10 MB. |
| 422 | A field didn’t check out, like an email or _replyto field that isn’t an email address. The errors array says which. |
| 423 | The form is paused. Nothing is stored until it’s switched back on. |
| 429 | Too many too fast (10 a minute per visitor), or the form’s monthly cap is used up. |