Send leads to a form URL
Draft. This page describes the form route as it is implemented in the code (app/f/[key]). The production host names are not live yet, so the addresses below do not work yet.
The endpoint
Every form has a public key that looks like f_8Kq2xLm9 (f_ plus 8 letters or digits). Send a POST to /f/<form key>. The lead is stored first; the route only answers success after Convex confirms the store.
POSTstores a lead.OPTIONSanswers the browser preflight with 204 andAllow: POST, OPTIONS.GET,PUT,PATCHandDELETEanswer 405method_not_allowed.- A path that is not a form key answers 404
form_not_found. - Every response allows cross-origin reads (
Access-Control-Allow-Origin: *; no credentials are involved).POSTanswers are not cacheable (Cache-Control: no-store); only theOPTIONSpreflight is cached, for 600 seconds.
HTML form
Point the form's action at your form URL. A browser post is redirected (303) to a thank-you page.
<form action="https://contactforms.dev/f/f_8Kq2xLm9" method="post">
<label>Name <input name="name" required></label>
<label>Email <input type="email" name="email" required></label>
<label>Message <textarea name="message"></textarea></label>
<button type="submit">Send</button>
</form>
fetch with JSON
Send a JSON object. The answer is JSON too: a JSON body always gets a JSON answer, and your client is never redirected.
const res = await fetch("https://contactforms.dev/f/f_8Kq2xLm9", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ name: "Priya", email: "priya@example.com", message: "Hello" }),
});
const data = await res.json();
// stored: { success: true, id: "sub_...", message: "Stored", body: { message: "Stored" } }
// refused: { success: false, error: "...", message: "...", fix: "...", docs_url: "...", body: { message: "..." } }
Rules and limits
- Content types:
application/x-www-form-urlencoded,multipart/form-data(text parts only) andapplication/json. Anything else, includingtext/plain, answers 415unsupported_media_type. - A file part in a multipart post answers 422
file_upload_unsupported. - Body size: at most 64 KiB on the wire; larger answers 413
payload_too_large. At most 50 fields per post (more answers 413payload_too_large). A field name is 1 to 100 characters (otherwise 422validation_failed). A text value, including each item of a list, is at most 10,000 characters (otherwise 413payload_too_large). - JSON must be an object. Values may be text, numbers, true/false or a list of those (up to 100 items);
nullbecomes an empty value. A nested object answers 422validation_failednaming the field. A repeated name in a form post stays repeated. _id(optional, send it once): your own submission id for safe retries, 8 to 64 letters, digits,-or_, not starting withsub_(otherwise 422validation_failed). It is not stored as a field. Reusing an_idwith different content answers 409id_conflict.- Missing required fields are not refused at intake: the lead is stored and flagged.
- A flood can answer 429
rate_limitedwithRetry-After. This page states no request counts because none are published yet.
Origins
A form can list allowed domains. The check stops other websites' visitors from posting through your form; it is not authentication, because a script can send any Origin.
- Entries are hostnames (
example.com) or subdomain wildcards (*.example.com, which does not match the bare domain). - https origins match by host on any port. http is accepted only for
localhost,127.0.0.1and[::1], and only when that exact host is listed. - An empty list restricts nothing.
- A post with no
Originheader (a server or script) follows a per-form switch; the default allows it. Origin: null(sandboxed iframe,data:page) is refused on a form with a list unless the owner allowed it.- A refused origin answers 403
origin_not_allowed.
What you get back
- JSON answer when the body was JSON. Otherwise the
Acceptheader decides, by quality value (q, default 1;q=0excludes a type): HTML whentext/htmlortext/*ranks aboveapplication/json, and on equal quality when it is listed first. JSON whenapplication/jsonranks higher, orAcceptis missing or names neither (for example*/*). - Stored, JSON: 200
{ "success": true, "id": "sub_...", "message": "Stored", "body": { "message": "Stored" } }. - Stored, browser: 303 to the form's configured redirect, or to a redirect you submit if it is https and its host is one the site has listed (listed loopback hosts
localhost,127.0.0.1and[::1]may use http), otherwise to/f/<key>/thanks?ref=sub_.... - Refused, JSON: the status from the error catalog and the standard error body (
success: false,error,message,fix,docs_url), plusbody.message. - Refused, browser, visitor-fixable: 303 to
/f/<key>/error?e=<code>&s=<seconds>. Our own failures (5xx) show the error page itself with the real status andRetry-After. - Nothing is reported stored unless it was: if it is unknown whether a store happened, the answer is 503
unavailable, never a false success.
From Formspree or Web3Forms
Change the address; keep your field names. Formspree:
- <form action="https://formspree.io/f/xxxxxxxx" method="post">
+ <form action="https://contactforms.dev/f/f_8Kq2xLm9" method="post">
Web3Forms: either post to /f/<form key>, or keep the form and post to /f/_ with access_key set to your form key. The key is never stored as data. Web3Forms clients read success and body.message; both are present.
<form action="https://contactforms.dev/f/_" method="post">
<input type="hidden" name="access_key" value="f_8Kq2xLm9">
<input name="name">
<input type="email" name="email">
<textarea name="message"></textarea>
<button type="submit">Send</button>
</form>
These field names are understood (checked against each service's documentation on 20 Oct 2026):
| Service | Name | Meaning |
|---|---|---|
| Formspree | Reply-To for the notification | |
| Formspree | subject | Notification subject |
| Formspree | _gotcha | Honeypot flag |
| Web3Forms | access_key | Form key (only on the /f/_ address) |
| Web3Forms | Reply-To for the notification | |
| Web3Forms | replyto | Reply-To for the notification |
| Web3Forms | subject | Notification subject |
| Web3Forms | from_name | Sender name |
| Web3Forms | botcheck | Honeypot flag |
| Web3Forms | redirect | Redirect after the post |
replytowins overemail. Reply-To is used only when it is one valid address.- A filled honeypot (
_gotchaorbotcheck) is recorded as a flag; the lead is still stored and the sender still sees success. - A submitted
redirectis used only if it is https and its host is on the site's allowed domains (a listed loopback host may use http); with no list it is ignored. - Not mapped, kept as plain data:
_replyto,_subject,_next,_cc,ccemail,webhook,attachmentand every other underscore field. No post can set a recipient, destination, template or sender address.
Not built yet
- Email notifications for new leadsSoon
- Delivery destinations (Sheets, Slack and others)Soon
- File uploads (a form post carries text only)Soon
- REST API for reading submissionsSoon