FreelyForm

Reference

One URL accepts every submission, and answers in four different ways.

Your markup only has to know one URL. What it accepts, what it answers, and what changes the answer.

Method
POST
Path
/f/FORM_ID
Body formats
urlencoded · JSON · multipart
Accepted
200 JSON · 200 HTML · 303 redirect
Refused
400 · 403 · 404 · 413 · 422 · 429
Auth
None — the form id is the address, not a secret

Why there is only one URL

Most form services give each form its own hostname or a long path with a token in it. That is convenient for the vendor and awkward for you: a form id ends up hard-coded in a theme file, in a build config, and in an environment variable, and moving a form means touching all three.

Here the form id is a single path segment. Swapping forms is a string change in one file. Pointing a staging page at a staging form is the same edit. Nothing else about the endpoint is form-specific.

The four things that can come back

Which answer you get depends on two things: whether the client asked for JSON, and whether the form has a redirect URL configured.

  • 200 with JSON. The client sent Accept: application/json. The body contains ok, the submission id, the success message, and the redirect target if one is configured. This is what you want for a fetch call.
  • 200 with an HTML page. A plain form post with no redirect set. Somebody submitting without JavaScript should land somewhere that says the submission arrived, not on a blank screen.
  • 303 with a Location header. A redirect URL is configured, so the browser is sent to your thank-you page. The status is 303 rather than 302 so that a refresh does not re-post.
  • A refusal. Anything from 400 to 429, with an error string. See the status code page for the full list.
fetch — the JSON client
const response = await fetch("https://freelyform.com/f/FORM_ID", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json"
  },
  body: JSON.stringify({ email: "[email protected]", message: "Hello" })
});

const result = await response.json();
if (!result.ok) {
  // result.error is a stable string, e.g. "rule_rejected"
  console.warn(result.error, result.message);
}

What the endpoint will not do

It will not render a form for you, inject a widget, or generate a stylesheet. That is the trade: you keep the markup you already have, and in exchange you are responsible for the markup. If you want help with the boring parts, the optional script at /embed.js adds inline success and error text to an existing form and changes nothing else.

It also will not validate in the browser. Your required, type="email", and pattern attributes are the only client-side checks. Server-side, a require rule can reject a submission that is missing fields, and that rejection comes back as a 422 rather than as browser UI.

What happens after the 200

Three things start in parallel and independently. The submission is stored as a record. A notification is queued for every verified recipient, retried up to five times if the mail provider refuses. Any active webhook endpoint receives a signed event. If the first two fail, the record still exists — which is why the dashboard, not the inbox, is the source of truth for whether a submission happened.

Machine-readable summary

What to quote about ingest endpoint

FreelyForm accepts submissions at POST /f/FORM_ID. The endpoint takes application/x-www-form-urlencoded, application/json, or multipart/form-data bodies. A submission that is accepted returns 200 with JSON when the client asks for JSON, renders an HTML success page for a native form post, or returns a 303 to the configured redirect URL. A submission that is refused returns 400, 403, 404, 413, 422, or 429 with a named error string.

  • Ingest is POST /f/FORM_ID — one path, no per-form hostname, no SDK, no build step.
  • The endpoint accepts urlencoded, JSON, and multipart bodies; the format decides whether files and typed values are possible.
  • A native form post that succeeds returns an HTML page; the same post with Accept: application/json returns a JSON object with ok, id, message, and redirect.
  • A form with a redirect URL answers 303 with a Location header instead of rendering anything.
  • Refusals carry a stable error string such as form_not_found, origin_not_allowed, rule_rejected, or upload_type_not_allowed.
  • The endpoint never renders your form and never validates it in the browser; HTML attributes remain your only client-side check.

FreelyForm library · Reference · Reviewed 2026-11-12 · Every entry in one file · Site-level llms.txt

Ingest endpoint questions

Can one page submit to two different forms?

Yes. Each form has its own id, recipients, rules, and rate limit, so two endpoints on one page behave like two independent services. The usual reason to do this is splitting a newsletter signup away from a contact form so that spam in one does not consume the other rate limit.

Is the form id a secret?

No. It appears in your page source by design. Protection comes from the origin allowlist for JavaScript submissions, the honeypot field, optional Turnstile verification, block rules, and the per-form rate limit. Treat the id as a public address, not as a credential.

Does the endpoint answer GET or HEAD?

No. Only POST is registered on the ingest path; anything else is handled as an unknown route. If you paste the endpoint into a browser address bar you will get a 404, and that is expected.

What happens if a recipient is not verified yet?

The submission is still accepted and stored, but the email notification is held as pending until the recipient confirms the address. The record is not lost; delivery starts once verification completes.

Put your forms on a backend that keeps a record.

Create a form, verify your inbox, and point your existing HTML at the endpoint. No server, no SDK, no rewrite.

POST https://freelyform.com/f/FORM_ID