DEV Community

Cover image for How to Handle Form Submissions in Node.js and Express (Without Writing Spam Logic)
Alen | SmartForm AI
Alen | SmartForm AI

Posted on Originally published at usesmartform.com AI-assisted

How to Handle Form Submissions in Node.js and Express (Without Writing Spam Logic)

The first Express contact form I shipped was about 140 lines. Multer for multipart parsing, express-validator for the field checks, a honeypot field I hoped bots would fill, a Redis rate limiter, and a half-finished isSpam heuristic that mostly flagged legitimate customers from Singapore.

Then a bot filled the form 400 times in a night. It was a "SEO services" spam that slipped past because I'd hardcoded a keyword blocklist instead of classifying the message.

✅ The short version

Post your Express form to a form backend endpoint and the spam filtering, intent classification, and notifications happen on their side. Or point a webhook at your Express app and receive already-processed submissions. Your Express code becomes "receive data" instead of "defend against spam."

What an express form backend actually costs you

Here's the realistic dependency list for a production Express form endpoint:

npm install express multer express-validator express-rate-limit dotenv
Enter fullscreen mode Exit fullscreen mode

And the code that comes with it:

// The version you'll write yourself. Every project, every time.
const multer = require("multer");
const { body, validationResult } = require("express-validator");
const rateLimit = require("express-rate-limit");

const upload = multer({ dest: "uploads/" });

const formLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
});

app.post("/api/contact", formLimiter, upload.single("attachment"), [
  body("email").isEmail().normalizeEmail(),
  body("message").trim().isLength({ min: 10, max: 2000 }),
  body("website").optional({ values: "falsy" }), // the honeypot
], async (req, res) => {
  const errors = validationResult(req);
  if (errors.array().length) {
    return res.status(422).json({ ok: false, errors: errors.array() });
  }

  // ...and now the part nobody can skip: deciding if it's spam.
  const message = req.body.message.toLowerCase();
  const spamWords = ["seo services", "crypto", "backlinks"];
  if (spamWords.some((w) => message.includes(w))) {
    return res.json({ ok: true }); // silently drop
  }

  await db.insert({ ...req.body, created_at: new Date() });
  await mailer.send({ to: "sales@example.com", text: req.body.message });
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

That code works. It's also the part of your app most likely to break silently — the spam filter is a blunt instrument, the rate limiter needs Redis to be useful across instances, and multer writes attacker-controlled filenames to disk if you forget to sanitize them.

Option 1: Node.js form submission via POST from your Express route

The endpoint is a plain public HTTP endpoint. No SDK, no special client library.

// server.js
const express = require("express");
const app = express();
app.use(express.json());

const FORM_ID = process.env.SMARTFORM_FORM_ID;
const API_KEY = process.env.SMARTFORM_API_KEY; // workspace-level key (optional)

app.post("/api/contact", async (req, res) => {
  const { name, email, message } = req.body;

  if (!email || !message) {
    return res.status(422).json({ ok: false, error: "email and message required" });
  }

  const payload = {
    name,
    email,
    message,
    // Reserved fields — see the docs
    _next: "https://yourapp.com/contact?sent=1",
    _subject: `New contact form message from ${name || "someone"}`,
    _gotcha: "", // honeypot — leave empty for real users
  };

  const headers = { "Content-Type": "application/json", Accept: "application/json" };
  if (API_KEY) headers["X-Api-Key"] = API_KEY;

  const upstream = await fetch(
    `https://api.usesmartform.com/api/v1/f/${FORM_ID}`,
    { method: "POST", headers, body: JSON.stringify(payload) }
  );

  if (!upstream.ok) {
    return res.status(502).json({ ok: false, error: "upstream form service rejected the request" });
  }

  const result = await upstream.json();
  res.json({ ok: true, submissionId: result.submission_id });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Two things worth noting.

First, the workspace API key in X-Api-Key is optional. Without it, requests go through the same per-IP rate limit that protects browser-submitted forms (100 submissions per IP per hour). With it, server-to-server traffic skips the IP limit — which matters if your Express app sits behind a shared NAT or a corporate proxy.

Second, the response comes back with is_spam and intent:

{
  "success": true,
  "submission_id": "sub_9f2c1a8e4b",
  "is_spam": false,
  "intent": "sales"
}
Enter fullscreen mode Exit fullscreen mode

So you can branch on it if you want. Most people don't — they return { ok: true } to the browser and let the webhook handle the routing.

💡 Why spam submissions don't cost you quota

SmartForm AI's honeypot catches the obvious bot traffic before it reaches the AI classifier, and the classifier catches the rest. Neither path burns a submission from your monthly quota. That's the difference between a form backend and a raw POST endpoint — you're not renting the spam problem, you're forwarding it.

Option 2: A nodejs contact form where the browser POSTs directly

If your Express app serves a page with a contact form, you may not need a proxy route at all. Point the form's action at the endpoint:

<form action="https://api.usesmartform.com/api/v1/f/YOUR_FORM_ID" method="POST">
  <input name="name" type="text" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>

  <!-- Honeypot: hidden from humans, tempting to bots -->
  <div style="position:absolute;left:-9999px" aria-hidden="true">
    <label>Leave this empty<input name="_gotcha" tabindex="-1" autocomplete="off" /></label>
  </div>

  <!-- Where the browser lands after a successful submit -->
  <input type="hidden" name="_next" value="https://yourapp.com/contact?sent=1" />

  <button type="submit">Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Your Express server never touches the form. The tradeoff: the endpoint URL is visible in the HTML (which is fine — form_id is a random 8-character string, not a secret), and you lose the chance to add your own server-side logic to the request path.

Option 3: Receive processed submissions via webhook (express webhook form handler)

This is the pattern I use when the form data needs to end up somewhere in my app — creating a ticket, enriching a CRM record, posting to a chat channel. You configure a webhook URL on the form in the dashboard, and SmartForm AI POSTs the finished submission to your Express server.

// routes/webhooks.js
const express = require("express");
const crypto = require("crypto");
const router = express.Router();

// Express needs the raw body to verify the HMAC signature.
router.post("/webhooks/smartform",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get("X-SmartForm-Signature") || "";
    const expected = crypto
      .createHmac("sha256", process.env.SMARTFORM_WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    const provided = signature.replace(/^sha256=/, "");
    const valid =
      provided.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));

    if (!valid) {
      return res.status(401).json({ error: "invalid signature" });
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    const { submission, form } = payload;

    if (submission.is_spam) {
      // Quietly archive. Don't page the on-call for spam.
      return res.status(200).json({ received: true });
    }

    console.log(`[${form.name}] ${submission.intent_label}: ${submission.ai_summary}`);
    res.status(200).json({ received: true });
  }
);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

The express.raw() middleware is the detail people miss. If express.json() runs first, it consumes and re-serializes the body, and the HMAC check fails because the bytes changed. Register the raw parser on the webhook route before any global JSON parser.

The payload itself carries everything the AI pipeline produced:

{
  "event": "submission.created",
  "submitted_at": "2026-11-01T09:14:22.481Z",
  "form": { "id": "a1b2c3", "form_id": "kX9mN2pQ", "name": "Website contact" },
  "submission": {
    "id": "9f2c1a8e4b",
    "data": { "name": "Alice", "email": "alice@example.com", "message": "..." },
    "is_spam": false,
    "spam_confidence": 0.04,
    "intent_label": "sales",
    "intent_confidence": 0.91,
    "is_high_value": true,
    "ai_summary": "Enterprise pricing inquiry from Acme Inc. for 50 seats.",
    "ip_address": "203.0.113.9",
    "user_agent": "Mozilla/5.0 ..."
  }
}
Enter fullscreen mode Exit fullscreen mode

That ai_summary and intent_label are the payoff. A "route this to sales vs support" decision that would have been a string-matching guess in your own code is already made, on a model that reads the actual message.

Which pattern should you pick?

You need Use
Just store the submission and notify yourself Browser POSTs straight to the endpoint
Custom logic before the submission is recorded Express proxy route (Option 1)
Send the data into another system Webhook into your Express app (Option 3)

You can also combine them: browser → Express proxy → endpoint, plus a webhook back into Express for the enrichment step. The proxy handles rate limiting and field normalization; the webhook gives you the AI verdict.

The part you don't have to write

The reason I stopped maintaining my own spam filter wasn't that reCAPTCHA is bad — it's that spam detection is a moving target. The keyword list that worked last quarter now blocks two real customers a week. The honeypot that caught 90% of bots last year now catches 40%.

SmartForm AI runs that classification server-side on every submission, and the part that matters for cost: spam doesn't count against your submission quota. You can be as aggressive with the filter as you want without your bill going up, which is exactly what you can't say about a filter you host yourself, where every rejected request still costs you a CPU cycle.

If you want to see the shape of the payloads before wiring anything up, the playground lets you fire a submission at the endpoint and read the response and webhook body back.

For the Python side of the same problem — Django and Flask specifically — I wrote a guide to that too. And if you want to push these submissions somewhere other than your app, the webhook automation guide covers Slack, Notion, and Telegram.

Top comments (0)

⌂ 换网址