Docs

Point a form at NormForms and the messages people send land in your inbox. That's the whole product. Writing code against the endpoint directly instead of using an HTML form? Skip straight to the reference for developers at the bottom.

Getting your form working

There are two ways in, and which one you want depends on whether you can paste HTML into your own site.

You can edit your site's HTML

Create a free account. You'll be asked where you want submissions emailed and which domain your site lives on (you can leave the domain blank and add it later). You'll land on a web address for your form that looks like normforms.com/f/x7Kp2mQ9RtLw. Paste this into your page, swapping in your own address for YOUR_TOKEN:

<form action="https://normforms.com/f/YOUR_TOKEN" method="POST">
  <label for="nf-name">Name</label>
  <input id="nf-name" type="text" name="name" autocomplete="name" required>

  <label for="nf-email">Email</label>
  <input id="nf-email" type="email" name="email" autocomplete="email" required>

  <label for="nf-message">Message</label>
  <textarea id="nf-message" name="message" required></textarea>

  <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <button type="submit">Send</button>
</form>

That's markup only, on purpose. Add your own classes and CSS, or it'll render as plain browser-default boxes in the middle of your nicely designed page. Keep the labels and the autocomplete attributes: they're what makes the form usable with a screen reader and what lets phones fill it in with one tap.

Don't delete the _gotcha line either: it's invisible, so real visitors never fill it in, and bots fill in everything they find.

You don't have to type any of this by hand. Open your form in the dashboard, go to Embed / Share, and the Embed HTML card has this snippet pre-filled with your own address and a Copy button. Right next to it is an AI prompt card you can paste into Claude, Cursor, or whatever's building your site.

You don't have a site to edit

Then skip the HTML entirely. Every form also gets its own page that NormForms hosts for you, at that same address: normforms.com/f/YOUR_TOKEN. Open it in a browser and it's a real working form, already styled. Nothing to build.

Share that link from a Canva site, a bio link, an email signature, a printed flyer, wherever. Your dashboard has a QR code for it too. Under Form settings you can set its headline, its intro text, a small tag above the headline, and your logo, so it reads like part of your site rather than a stranger's tool. If you'd rather not have a hosted page at all, untick Hosted page enabled and that address stops answering browsers.

No email arrived. What's going on?

Open your form in the dashboard and check the submissions list. Whether your test message is sitting there tells you which half of the problem you have.

The submission is there, but no email came. The message is safe. NormForms always stores a submission first and emails it second, so a slow or broken email never loses anything. Something's pausing delivery, and it's almost always one of these:

The submission isn't there at all. Then it never got stored, for one of two reasons:

Still stuck? Email [email protected] with your form's address and what you tried.

What can I put in the form?

Any fields you like. Add <input name="budget"> and budget shows up in your email, nothing to register or set up first. Rename a field, add three more, remove one: it all just works, and embedded forms never need to match the hosted page's fields.

Two things worth knowing. A handful of names are set aside for NormForms' own use, and using one of them on a visitor-facing field means that answer quietly vanishes from your email with no warning: don't name a field token, format, action, or controller. And a submission has to fit in about 25,000 characters, which is far more than anyone types into a contact form, so this only matters if you're trying to send something else through it (a file, for instance, which this isn't built for).

What happens right after someone hits Send?

Every notification email ends with an unsubscribe link for the recipient. Worth remembering that's your address on your own notification emails: click it yourself and you'll stop delivery to that form until you change the destination address in settings.

Who your emails come from

Every email a form sends, your own notifications and any auto-reply to the person who wrote in, arrives from that form's name followed by via NormForms. A form named "Advisor Logistics" sends as Advisor Logistics via NormForms.

So name the form after your business rather than "Contact form": it's what your notifications sort by in your inbox, and it's what someone who filled in your form sees when the auto-reply lands. The address underneath stays ours, because that's the domain the mail is authenticated against, and the via NormForms part isn't removable. An email that named your business alone over our address would be indistinguishable from someone impersonating you, which is exactly the thing mail providers are built to catch.

Replies work the way you'd want either way: reply to a notification and you're writing to whoever submitted the form; reply to an auto-reply and it reaches the form's destination address.

Changing the fields on your hosted page

Open your form and go to Design your form. The Hosted page fields card is where you add, edit, and reorder them. These only affect the hosted page, an embedded form on your own site still accepts whatever field names you send it.

A choices field needs at least one option, so saving one with nothing typed in is rejected. And since commas separate the choices, one choice can't contain a comma of its own.

How NormForms stops spam

Most of it is handled before you ever think about it. Every submission runs the same checks, in order: a size limit, a check on where it came from (skipped if you haven't saved a domain), the hidden honeypot field, a rate limit, Cloudflare's invisible challenge, and your own blocklist.

Whatever gets rejected sees the same normal-looking success a real visitor sees, and is never told which check caught it. Telling a spammer why they failed is how they learn to get past it.

For anything that slips through anyway, add your own blocklist entries in Settings, or click Block sender on any submission. An entry can be a phrase, an email address, or a domain, and it matches anywhere in the submission, not just the message field. Matches go straight to that form's Trash: never emailed, never sent to a webhook, never counted against your quota, and the sender still sees a normal success. Blocklist entries apply across every form on your account.

Entries can be too broad, so trash is recoverable. A message caught by your blocklist stays in that form's Trash for as long as the form's retention window keeps anything else, and the Export CSV button covers the Trash pile while you're viewing it. If you spot a real enquiry in there, remove the entry that caught it and it won't happen again. We keep the most recent 500 per form, and 5,000 across your account.

How matching works, precisely: it's a plain substring test, case-insensitive, run against every field value in the submission. No wildcards, no regular expressions, so @spam.ru catches [email protected], and viagra catches Viagra and also viagrande. Entries must be at least 4 characters (a one-letter entry would bin everything), up to 200, and 500 per account. Past 25 entries the list in Settings collapses and gains a search box, so finding the one you want to remove doesn't mean scrolling past the other 499. Field values are joined with a line break before matching, so an ordinary one-line entry can't match by straddling the end of one field and the start of the next.

The domain lock, and where Turnstile fits

Your site's domain, under Spam & security, is the one setting that guards a form embedded on your own site. It checks where the submission came from. It costs your visitors nothing and needs no extra markup, but a browser can be told what to say here, so think of it as stopping someone copying your form onto their own site and pointing it at your inbox, not as stopping a determined bot. Leave it blank and your form accepts submissions from anywhere, which is the default until you fill it in.

Put a plain hostname in it: example.com. A full URL is fine too, we'll take the hostname out of it. Subdomains of what you save are accepted, so example.com covers www.example.com and shop.example.com.

Turnstile, Cloudflare's invisible bot check, runs on your hosted page. It's always on there, you don't configure it, and your visitors never see it or click anything. It's there because a hosted page has no domain to lock, so it's the only thing standing between that page and a bot.

It is not available for forms on your own site, and there's nothing to paste into your HTML. Cloudflare only issues a pass for hostnames registered against the widget it belongs to, and ours is registered to normforms.com, so a widget embedded on your domain could never get one. We used to offer this as a checkbox. It didn't work, and it's gone.

So an embedded form is guarded by the domain lock, the hidden honeypot, the rate limit, and your blocklist. No JavaScript, no widget, nothing to keep working.

Try your form before you go live

Submit your embedded form from your own machine while you're still working on it locally. Submissions from localhost are automatically marked as a test: delivered with a [Test] subject line, and never counted against your monthly limit.

Submitting your own hosted page is not a test. It's a real submission from a real browser, so it's delivered normally and it does count. Handy for checking the whole path end to end, once.

Building against the endpoint directly? Test submissions are decided by the Origin header being localhost (any port), 127.0.0.1, ::1, or 0.0.0.0, so it works from curl too:

curl -X POST https://normforms.com/f/YOUR_TOKEN \
  -H "Origin: http://localhost:3000" \
  -d "name=Norm&message=it works"

Two ceilings exist only so an Origin header can't be used to flood us; ordinary testing never comes near them. Past 100 test submissions per account in a calendar month, they're still stored but stop being emailed, and upgrading doesn't release those. Past 300 on a single form in a month, they stop being stored entirely.

Where your submissions go, and how long you keep them

Every submission is stored first and emailed second, so it's in your dashboard whether or not the email ever lands.

When your destination stops accepting mail

Mail providers stop accepting from a sender when a message hard-bounces, or when someone marks one as spam. If that happens to your destination address, we stop trying: submissions are still accepted, stored, and readable here, but they aren't emailed until it's sorted out. We tell you by email at your account address, and put a notice on the form.

Change the destination in that form's settings and the new address gets a confirmation link, or write to us if it looks wrong and we'll get it lifted. Either way, everything held while it was in place is sent once delivery resumes.

Going over your plan's limit

Going over your limit doesn't drop anything. Submissions keep arriving, keep being stored, and stay fully readable in your dashboard and CSV export. Only the email pauses.

There is one ceiling on that, and it's deliberately far out: we keep storing up to ten times your plan's monthly allowance, then stop. On the free plan that's 500 submissions in a month against 50 emailed. We email you at eight times so it isn't a surprise, and again if you reach ten, and both show in your dashboard. Reaching it usually means a form is being hit by something automated rather than by people, and the domain lock and blocklist are the tools for that.

Separately, each account has a limit on how much it stores in total across all its forms. That one counts what's on disk right now, trash and recently deleted included, so it doesn't clear when the month rolls.

If you reach it we don't turn submissions away. We shorten how long your submissions are kept, to 90 days and then to 30 if that isn't enough, and the nightly clean-up deletes the oldest until you're back under. Your forms keep working and you keep getting emailed throughout. We tell you when this starts so you can choose what goes instead, by exporting what you want and shortening a retention window yourself. If you need to keep more than the limit allows, email us and we'll raise it.

Upgrading sends everything that was waiting. A new calendar month does not: it resets your usage counter so new submissions start being emailed again, but it doesn't reach back and deliver the ones already held. If 20 submissions piled up in July on the free plan, upgrading delivers them and simply waiting for August doesn't. Held submissions still age out under the form's retention window like everything else, so they won't sit there forever either way.

Having NormForms reply automatically

On paid plans, you can write a subject and a message, both plain text, and we'll send it once to whoever filled in your form.

Every auto-reply ends with a short footer you can't remove, saying why they got it and giving them a way to opt out. That's not branding: an auto-reply without it is a free tool for emailing strangers, and those get abused within days. Two more things worth knowing: it only fires when the submission has a field actually named email (an address typed into the message body doesn't count), and it never fires for test submissions.

Sending submissions somewhere else

That's what a webhook is for, getting submissions into Slack, a CRM, a spreadsheet, your own database. Give us a URL you control, and the moment a submission clears delivery we POST it there as JSON. Nothing to poll, no API key to manage.

If email is all you need, leave this blank. Everything else on this page works exactly the same either way.

Set the URL in form settings. Held submissions fire their webhook once released, so nothing ever leaks past your quota. Here's the body you'll receive:

{
  "form": { "token": "x7Kp2mQ9RtLw", "name": "Contact" },
  "submission": {
    "id": 42,
    "received_at": "2026-07-09T21:14:03Z",
    "source": "embedded",
    "data": { "name": "Norm", "email": "[email protected]", "message": "hi" }
  }
}

received_at is UTC, ISO 8601. source is one of embedded, hosted, or test. We never send the visitor's IP address or their request headers.

Each request carries X-NormForms-Timestamp (unix seconds) and X-NormForms-Signature, a hex HMAC-SHA256 of "#{timestamp}.#{raw_body}" keyed with your form's signing secret, shown in settings once you've saved a webhook URL. Verify against the exact raw bytes you received, before any framework parses them: re-serializing parsed JSON produces a different byte string and the signature won't match.

# Ruby example. Any language's HMAC works the same way.
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
ok = Rack::Utils.secure_compare(expected, signature) &&
     (Time.now.to_i - timestamp.to_i).abs < 300  # reject replays

That 300-second window is your own choice on the receiving end, we don't require or enforce one, but rejecting old timestamps is what stops someone capturing a request and replaying it at you later.

Rules for your receiving endpoint:

What it costs

Free: 50 submissions a month, unlimited forms, good for testing and development. Pro: $7/month for 200. Growth: $15/month for 2,000. Business: $50/month for 20,000. Need more? Email us. Full details on the pricing section of the home page.

Guides for what you built your site with

Building with an AI assistant? Point it at https://normforms.com/llms.txt, that page has everything an assistant needs to wire a form up correctly, honeypot included.

Reference for developers

The endpoint

Submitting with fetch, no page reload

A plain HTML form works everywhere and needs nothing below. But if your site is a React, Vue, Svelte or Astro island and you'd rather handle the submit yourself, save your domain under Spam & security first, then:

const res = await fetch("https://normforms.com/f/YOUR_TOKEN", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name, email, message, _gotcha: "" })
});
const { ok } = await res.json();

The domain lock is what makes this work: we send Access-Control-Allow-Origin back for that exact origin and nothing else. Leave the domain blank and the browser blocks the read, because there's no allowlist for us to honour and we won't send *.

What counts as an accepted origin

We read Origin, falling back to Referer if it's missing, and compare the host against the domain saved in that form's settings. If the domain field is blank, any origin is accepted, including none at all.

Subdomains count downwards, and only downwards. Save example.com and submissions from www.example.com and shop.example.com are accepted too. Save shop.example.com and only that host is: example.com is rejected. Matching is on the host alone, so scheme and port are ignored, and a lookalike like notexample.com never matches.

A leading www. is the one exception, because it isn't really a different site. Save www.example.com and we treat it as example.com, so your apex works too. We store it that way so the settings field shows what the lock actually covers.

If submissions do get turned away by the lock, your form says so: how many, and which domain they came from. Visitors still see a normal thank-you page, because telling a bot which check it failed is how you help it, but you are not left guessing.

Watch out for preview deployments. Netlify deploy previews, Cloudflare Pages branch previews, GitHub Pages project sites and ngrok tunnels all serve from a hostname that isn't your domain, so with a domain lock saved, a form you test on a preview URL is silently dropped, exactly like any other unrecognised origin. Nothing is stored and nothing is counted, but nothing arrives either, and it looks like the form is broken. Test from localhost instead, or from the hosted page, or clear the domain field while you're testing.

If a domain is saved, a request with neither header is silently dropped. That's the default state for a new account, since signup collects a domain. A server-to-server POST, a Zapier or n8n step, or a bare curl call sends no Origin and no Referer at all, which looks identical on the wire to a rejected one: a normal-looking success with nothing stored. If you're integrating from a backend rather than a browser, either leave the domain field blank or set an Origin header yourself that matches it.

Testing from localhost bypasses the origin check entirely, by design. That's what makes local testing painless, and it's also why a domain lock problem only ever shows up once you're live.

Reserved field names

Eight names never reach your stored data or your email, stripped before a submission is saved:

controller  action  format  token  _method  _gotcha  _hosted_sig  cf-turnstile-response

_gotcha is the honeypot, _hosted_sig marks a post as coming from our hosted page, and cf-turnstile-response is Cloudflare's token, none of which you'd give a real field anyway. _method is Rails' way of spelling other HTTP verbs in a form. controller, action, format, _method and token are the ones that bite: they're ordinary, plausible field names (a support form's own ticket token, for instance), and using one silently loses that answer with no error anywhere. Prefix it if you need it, order_token instead of token. You don't have to catch this yourself any more: when one of those four arrives in a submission body, the dashboard says so on that submission ("1 field dropped (token)"), so a lost answer shows up the same day rather than months later. _gotcha only needs to arrive empty, an absent or blank value is fine; only a non-empty value trips the honeypot.

Field names ending in [], like topics[], or using bracket nesting are accepted and arrive as arrays or nested objects in the stored data and in a webhook's data field, not just flat strings.

If you repeat a plain name, like two checkboxes both called topic, only the last one survives. That's how form encoding works, and the fix is to name them topic[]. We tell you when it happens: the submission says which names were repeated.

File uploads aren't supported. If your form has a file input, the submission still arrives and the field reads [file not supported: cv.pdf] so you know something was attached and can ask for it. The file itself isn't kept.

Spam pipeline, in order

Payload size, origin check, honeypot, rate limit, Turnstile (hosted page only), the per-form test-submission ceiling, an empty-or-blank-required-field check, then your blocklist.

Rate limits: per form, 10 submissions a minute on Free and Pro, 20 on Growth and 200 on Business, so a launch day isn't throttled by the plan you chose for a contact form. Per IP address it's 10 a minute, 60 an hour and 200 a day on every plan, because one visitor has no reason to send hundreds however busy your form is.

A visitor who trips either one gets a plain page telling them to wait a minute and try again, and their message is still in the form when they go back. Same for a message over the 25 KB limit. The spam checks are the ones that stay silent.

Webhooks: headers and delivery

Requests carry Content-Type: application/json, User-Agent: NormForms-Webhook/1.0, and the two X-NormForms-* signature headers described above. Your endpoint has 5 seconds to accept the connection and 10 to answer; a slower response is treated as a failure and retried, so build your handler to respond fast and do slow work afterward if you need to, otherwise you'll see the same submission more than once.

Stuck? [email protected], a human reads it.