Skip to content
ContactForms

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.

  • POST stores a lead. OPTIONS answers the browser preflight with 204 and Allow: POST, OPTIONS. GET, PUT, PATCH and DELETE answer 405 method_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). POST answers are not cacheable (Cache-Control: no-store); only the OPTIONS preflight 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) and application/json. Anything else, including text/plain, answers 415 unsupported_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 413 payload_too_large). A field name is 1 to 100 characters (otherwise 422 validation_failed). A text value, including each item of a list, is at most 10,000 characters (otherwise 413 payload_too_large).
  • JSON must be an object. Values may be text, numbers, true/false or a list of those (up to 100 items); null becomes an empty value. A nested object answers 422 validation_failed naming 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 with sub_ (otherwise 422 validation_failed). It is not stored as a field. Reusing an _id with different content answers 409 id_conflict.
  • Missing required fields are not refused at intake: the lead is stored and flagged.
  • A flood can answer 429 rate_limited with Retry-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.1 and [::1], and only when that exact host is listed.
  • An empty list restricts nothing.
  • A post with no Origin header (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 Accept header decides, by quality value (q, default 1; q=0 excludes a type): HTML when text/html or text/* ranks above application/json, and on equal quality when it is listed first. JSON when application/json ranks higher, or Accept is 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.1 and [::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), plus body.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 and Retry-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):

ServiceNameMeaning
FormspreeemailReply-To for the notification
FormspreesubjectNotification subject
Formspree_gotchaHoneypot flag
Web3Formsaccess_keyForm key (only on the /f/_ address)
Web3FormsemailReply-To for the notification
Web3FormsreplytoReply-To for the notification
Web3FormssubjectNotification subject
Web3Formsfrom_nameSender name
Web3FormsbotcheckHoneypot flag
Web3FormsredirectRedirect after the post
  • replyto wins over email. Reply-To is used only when it is one valid address.
  • A filled honeypot (_gotcha or botcheck) is recorded as a flag; the lead is still stored and the sender still sees success.
  • A submitted redirect is 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, attachment and 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