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
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 });
});
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);
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"
}
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>
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;
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 ..."
}
}
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)