Languages and runtimes
A JavaScript contact form without a backend
If you want an inline success message instead of a page navigation, you need one fetch call. That is the entire integration - no SDK, no dependency, no build step. The endpoint accepts both FormData and JSON, and returns JSON whenever you ask for it with an accept header.
Setup
Copy your endpoint
Create a form in the dashboard.
Intercept the submit event
preventDefault, then POST the FormData. Sending the accept: application/json header is what keeps the browser on the page.
Handle the response
A success looks like { ok: true, id: 'sub_…' }. A validation failure returns 422 with an issues array.
JavaScript examples
Swap in your own endpoint and these run as-is.
Progressive enhancement: the form still posts natively if this script fails to load.
const form = document.querySelector("#contact");
const result = document.querySelector("#result");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
try {
const res = await fetch(form.action, {
method: "POST",
headers: { accept: "application/json" },
body: new FormData(form),
});
const data = await res.json();
if (data.ok) {
result.textContent = "Thanks, we got it.";
form.reset();
} else {
result.textContent = data.message ?? "Something went wrong.";
}
} catch {
result.textContent = "Network error. Please try again.";
} finally {
button.disabled = false;
}
});Posting JSON instead, for when the data does not come from a <form> at all.
const res = await fetch("https://send.webforms.to/wft_your_key", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
name: "Ada Lovelace",
email: "ada@example.com",
message: "JSON works too.",
_subject: "New enquiry",
}),
});
const data = await res.json();
// { ok: true, id: "sub_…", message: "Thanks! …", tookMs: 42 }Send an Idempotency-Key if you retry, so a flaky connection cannot create duplicates.
const key = crypto.randomUUID();
async function submit(body, attempt = 0) {
const res = await fetch("https://send.webforms.to/wft_your_key", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
"idempotency-key": key,
},
body: JSON.stringify(body),
});
if (res.status >= 500 && attempt < 3) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
return submit(body, attempt + 1);
}
return res.json();
}Worth knowing
- Reusing an Idempotency-Key across retries returns { ok: true, duplicate: true } instead of storing the submission twice.
- Validation failures come back as 422 with an issues array of { field, message } so you can render errors next to the right input.
JavaScript form FAQ
No. The ingest endpoint allows cross-origin POSTs from any page, because that is how a form on your domain reaches it. Restrict which domains may submit from the form's settings in the dashboard.
Both are accepted. FormData is easier when the data comes from a real form and handles checkbox groups automatically. JSON is cleaner when you have built the object yourself.
Send an Idempotency-Key header and reuse the same value for retries of one logical submission.
Related guides
Ready to wire up your JavaScript form?
Free for 300 submissions a month. Endpoint in under a minute, no card required.
Start free