Most contact spam does not come from a person sitting on your website. It comes from a program somewhere else on the internet that found your form address and started posting to it. That is the cheap kind. It is also most of what you get.
You do not need a puzzle for a human to solve. You need a number the page was issued, and a rule that says: if the form comes back without that number, do not send the email. Users never see it. Programs that never loaded your page never get it. That alone removes the bulk of what lands in a small-business inbox these days. The leftovers — people who did visit the site and typed junk on purpose — are a smaller pile, and a language model can sort those.
The short version
If you have somewhere to run a little code — an AWS account, a Cloudflare Worker, a small server, anything that can make a page and receive a post — do this.
1. When someone opens the contact page, generate a random number or token on the server.
2. Put that value in the form. A hidden field is enough. You can also fetch it with a short script when the page loads.
3. Remember the value on the server for a short time, tied to that visit or that session.
4. When the form is submitted, check that the number came back and that it matches what you issued.
5. If it is missing, wrong, expired, or already used, do not send the email. Drop the request. Log it if you want. Do not bother a human.
6. If it matches, send the email, or hand the message to the next filter.
That is a flavor of CAPTCHA that the user does not fill out. The "test" is whether the submitter ever stood on your page long enough to receive a token. Remote spam scripts fail that test. They POST straight at the endpoint with name, email, and a pitch for fake SEO. They never asked you for a number. You never give them one. The email never leaves.
Why this works on about ninety percent
Contact-form spam has two main shapes.
The first shape is industrial. A bot list has your form URL. It fires submissions from datacenter IPs, residential proxies, or rented boxes. It never renders your HTML. It never runs your JavaScript. It does not care what your page looks like. It only cares that somewhere a POST accepts fields. Those bots are the ones that scale. One campaign can hit tens of thousands of small sites. In the last couple of years that traffic got louder, and a lot of it is written by models so the pitch mentions your actual services instead of "buy cheap watches."
Those bots fail a page-issued token, every time, unless someone rebuilds them to load your page first. Most of them will not. Loading a page per target is slower and costlier than spraying endpoints. Your random number does not have to be clever. It has to be secret until the page loads, short-lived, and checked before the mail goes out.
The second shape is a person on your site typing nonsense, or a sharper bot that does load the page, steals the token, and submits. That is the remaining slice. It is real. It is smaller. Treat it second, not first. Kill the industrial stream with a token. Then sort what is left.
What the token actually is
Call it a one-time form ticket.
The server makes it when it serves the contact page, or when a small endpoint is asked for a fresh ticket. Something random and long enough that nobody guesses it — a UUID, or thirty-plus characters of secure random. Store it where your code can find it again for a few minutes: a session, a signed cookie, DynamoDB, Redis, a short-lived row. Mark when it was issued. Optionally mark the IP or a fingerprint if you care. When the form posts, require that exact ticket. Then burn it so the same ticket cannot be replayed all afternoon.
You can put the ticket in a hidden input:
Or you can leave the HTML empty and let a few lines of JavaScript request a ticket and drop it into the field after the page loads. The second version blocks a slightly larger set of lazy scrapers that copy static HTML and never run scripts. Either way, the rule is the same: no valid ticket, no email.
Do not put the check only in JavaScript. A bot that posts to your action URL never runs your script. The check has to live on the server, in the same place you would have called send mail.
What this is not
It is not a puzzle. The visitor does not click traffic lights. They do not type warped letters. They do not prove they are human in a way a screen reader hates. They fill name, message, send. The ticket travels with them.
It is not a honeypot, though you can stack a honeypot on top. A honeypot is a field humans never see and bots often fill. Useful. Different. Your ticket is positive proof the page issued something. A honeypot is negative proof the submitter was greedy about fields.
It is not Cloudflare Turnstile or reCAPTCHA by itself. Those are fine products when you need a bigger hammer. Many sites can start with a ticket and never buy a captcha vendor. If you already have an AWS-like place to host a function, you already have enough surface to issue and check a number.
It is also not magic against a bot that loads your page like a browser. Once the HTML is fetched and the ticket is in the form, a determined script can submit it. That is the ten percent problem, not the ninety. Do not pretend the ticket ends all spam. Pretend it ends the spam that never visited.
The rest: humans, and bots that act like them
After the ticket, what remains looks like a real submission. A name. An email. A message that may be a real lead, a confused person, a competitor, or a pastelike pitch.
Those you can send to a model. Not to reply. To classify.
Pass the message text — and only what you need — to something like ChatGPT or another LLM with a tight instruction: is this a real customer inquiry, or spam / solicitation / empty noise? Return a label and a short reason. If it is spam, drop it or file it. If it is real, send the email or open the ticket. If it is unsure, put it in a short human queue.
That pass costs almost nothing compared with a person reading every fake SEO pitch. It also fails open or closed depending on how you set it. Prefer fail closed on obvious spam language, and fail to a human on anything that smells like a job, a quote, or a complaint. You are not asking the model to be your receptionist. You are asking it to be the intern who pulls junk out of the inbox before you see it.
Privacy note, because this is an IT audience: do not ship more personal data to a third-party model than the filter needs. Message body is usually enough. Strip phone numbers if your policy says so. Say in your privacy text that inquiries may be checked by automated systems. Boring. Necessary.
A minimal shape that works
Page load or ticket endpoint
- Create a random ticket.
- Store it with an expiry measured in minutes, not days.
- Return it to the page.
Form post
- Read the ticket from the body.
- If missing, reject.
- If unknown, expired, or already used, reject.
- Mark it used.
- Optionally reject if the post arrived faster than a human could type — one second after the ticket was issued is a giveaway.
- Optionally reject if a honeypot field has any value.
- Run the message through the model filter if you want the second stage.
- Only then send mail, or better, open a thread somewhere that is not a naked inbox.
If you have been thinking about an sms: link instead of a form at all, that is a stronger front door for a lot of businesses. A bot farm cannot tap Text us from a phone number it does not control. Keep the form if you still need one for partners, accessibility, or longer messages. Just stop letting the form endpoint accept anonymous posts from the open internet with nothing issued by your page.
What to tell yourself so you do not overbuild
The goal is not a fortress. The goal is to stop paying attention to mail that was never a customer.
A random number in the form, checked on the server, removes the programs that are not at your website. That is most of the noise. A model pass cleans a large share of what is left. A human still sees the odd edge case. That is fine. You were never going to automate judgment on a real complaint.
Start with the ticket. Ship it in an afternoon if you already have a place to put code. Measure how many posts die before send. Add the model when the leftovers still waste time. Add a vendor captcha only if someone is loading your page on purpose to spam you at volume.
It really is that simple at the first layer. Make a number. Put it in the form. If the form comes back without it, do not send the email. Users never notice. Remote spam does.