Docs
The whole integration surface is one URL. This page is everything most people need; the articles go deeper.
Quick start
- Create an account — you'll be asked where submissions should go and which domain your site lives on.
- Your form gets an endpoint like
normforms.com/f/x7Kp2mQ9RtLw. - Point any HTML form at it:
<form action="https://normforms.com/f/YOUR_TOKEN" method="POST"> <input type="text" name="name" placeholder="Your name"> <input type="email" name="email" placeholder="Your email"> <textarea name="message" placeholder="Your message"></textarea> <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off"> <button type="submit">Send</button> </form>
That's it — submissions arrive by email. No JavaScript, no SDK, no schema. Your dashboard has this snippet pre-filled with your token, plus a "Copy AI prompt" tab if an assistant is building your site.
The endpoint
- POST
/f/:token— accepts form-encoded or JSON bodies (up to 25 KB). - Any field names work. Add
<input name="budget">and it shows up in the email — nothing to configure. - Reserved fields:
_gotcha(the honeypot — never remove it) and_hosted_sigare control fields, never stored. - After submit: browsers are redirected to a thank-you page (or your custom redirect URL, configurable per form); JSON clients get
{"ok": true}. - GET the same URL renders a hosted, shareable form page — headline and intro text are editable in settings, QR code in your dashboard.
Spam protection (on by default)
Every submission passes a pipeline: honeypot, origin lock (only your registered domain may post), rate limiting, and an invisible challenge on hosted pages. Bots are never told why they failed — they get a normal success response and nothing is delivered. You don't configure any of this.
For spam that slips through, add your own blocklist entries in Settings (or click Block sender on any submission): banned phrases, email addresses, or domains. Matches go straight to the form's trash — never delivered, never counted against your quota — and the sender still sees a normal success.
Testing locally
Submit from localhost (any port) and it's automatically
test mode: delivered with a [Test] subject prefix and
never counted against your quota. Works with curl too:
curl -X POST https://normforms.com/f/YOUR_TOKEN \ -H "Origin: http://localhost:3000" \ -d "name=Norm&message=it works"
Delivery rules
- Submissions email to your form's destination address, with
Reply-Toset to the visitor so you can just hit reply. - If the destination isn't your account email, the recipient confirms once before anything is delivered (submissions wait, stored safely).
- Past your plan's monthly limit we never drop submissions — they're stored and held, delivered when you upgrade.
- Every email includes an unsubscribe link for the recipient.
- Submissions auto-delete after each form's retention window (90 days by default, configurable per form).
- Auto-reply (paid plans): optionally send the submitter a one-time automatic reply — your subject and message, plain text, with a mandatory footer explaining why they got it and an opt-out link. Follows the same rules as email: held submissions don't trigger it, test submissions never do.
Webhooks
Set a webhook URL in form settings and every delivered submission is also POSTed there as JSON (held submissions fire when released, so a webhook never leaks past your quota). Failures retry with backoff for about a day.
{
"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" }
}
}
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 a URL is saved). Verify before trusting:
# 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
Webhook URLs must be public https:// endpoints. Answer with
any 2xx status; redirects aren't followed. Retries re-POST the same
submission.id with a fresh timestamp and signature, so treat
submission.id as an idempotency key and ignore ids you've
already processed.
Plans
Free: 50 submissions/month, unlimited forms — use it for testing and development. Pro ($7/mo): 200. Growth ($15/mo): 2,000. Business ($50/mo): 20,000. Need more? Contact us. Full details on the pricing section.
Framework guides
Building with an AI assistant? Point it at https://normforms.com/llms.txt
— it has everything an agent needs to wire up a form.
Stuck? [email protected] — a human reads it.