# 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.

*HTML form sample*

```
<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.

*fetch sample*

```
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](/docs/errors) 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:

*Formspree migration diff*

```
- <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.

*Web3Forms-style form*

```
<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 | email | Reply-To for the notification |
| Formspree | subject | Notification subject |
| Formspree | _gotcha | Honeypot flag |
| Web3Forms | access_key | Form key (only on the /f/_ address) |
| Web3Forms | email | 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 |

- `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 leads (soon)
- Delivery destinations (Sheets, Slack and others) (soon)
- File uploads (a form post carries text only) (soon)
- REST API for reading submissions (soon)
