A form POST looks like one HTTP request. It is actually 9 things firing in sequence and parallel, with at least 4 ways to silently lose data.
I have been building a Formspree alternative for the last few months. After tracing the request lifecycle end to end (and finding three bugs in my own code along the way), I wrote up exactly what happens between the browser hitting Submit and the email landing in the owner's inbox.
This is useful if you are building anything that takes user input over HTTP. Most of the work is invisible until it breaks.
Here is the full pipeline for POST /f/:endpoint, in execution order.
1. RATE LIMIT CHECK
Per-IP per-endpoint key in Redis. 5 requests per 60 seconds. Sync. Returns 429 if exceeded. The key shape matters: if you key only on IP you throttle every form on your site when one gets attacked. If you key only on endpoint you throttle real users when one IP goes wild. Stack both.
2. AUTOMATION FINGERPRINT
Check the user agent against known bot signatures (curl, python-requests, headless browsers, common scraper UAs). Returns 429 if matched. Sync. Most spam I see fails here before doing any real work.
3. MULTIPART PARSING AND FILE UPLOAD
Stream the body to a temp file, validate MIME by reading the first 8 bytes (magic numbers, not the Content-Type header which is client-controlled), check the size limit per plan, then upload to object storage. Files hit disk first because the streaming parser does not buffer. Forget the cleanup step in the finally block and you fill the disk in a week.
4. HONEYPOT CHECK
17 invisible field names (website, _gotcha, honeypot, url, phone, fax, and so on) plus a hidden timestamp. If any honeypot is filled or the form was submitted in under 3000ms after page load, return a fake 200 with a fake submission ID. Bots think it worked. They do not retry. This single layer catches around 80% of my spam at zero CPU cost.
5. CAPTCHA VERIFICATION
Only if the form has it enabled. Calls Turnstile or reCAPTCHA v3. Sync HTTP. reCAPTCHA returns a score 0 to 1, anything under 0.5 is blocked. The trap: this call sits on the hot path, so a Google API hiccup adds 800ms to every submission. Set an aggressive timeout and fail open or fail closed deliberately, do not let the network decide for you.
6. ADAPTIVE SPAM SCORE
Heuristic stack: temp-email domain, link density in the message body, missing Origin header, burst rate per client (8 per 300s) and per email address (4 per 600s), plus a SHA256 of (form + client fingerprint + body + files) cached 60s for dedupe. Score above 5 blocks. This catches what honeypots miss without ever showing a CAPTCHA to a human.
7. SUBMISSION INSERT
INSERT into the submissions table. Fields: form_id, endpoint, data (JSONB), file_urls, metadata (IP, user agent, referrer, spam risk score, matched signals). The metadata is the part you will wish you had during incidents. Save more than feels necessary. Also: enforce close-after-N-submissions inside a DB trigger with row locking, otherwise two concurrent submitters race past the limit.
8. EMAIL NOTIFY (fire and forget)
Async. Resend API. Custom HTML template if the form owner configured one, default otherwise. The dangerous detail: this runs after the response is sent. If you await it you add 300ms to every submission and pin the request to one process. If you do not await it and it fails, the user never knows their notification did not ship. I log every failure and alert on a threshold. A proper queue is the real answer.
9. FANOUT (Slack, Discord, Telegram, webhook)
All async, all fire-and-forget. Webhooks get an HMAC-SHA256 signature header (timestamp.payload, signed with a per-user secret) plus a 10s timeout. No retry on the hot path, retries go to a separate worker. The integrations have cruel limits you only discover in production: Slack caps message fields at 8, Discord at 15, Telegram at 4000 characters total. These exist because spammers POST forms with 200 fields and your formatting breaks if you assume small.
THE RESPONSE
201 Created. JSON body: success, submissionId, optional redirect URL. The redirect is server-validated, not trusted from the form data. javascript: and data: protocols live there if you forget.
WHAT I LEARNED REWRITING THIS
Three things that are not obvious until you ship them.
First, the order matters more than the layers. Run cheap checks (rate limit, honeypot, fingerprint) before expensive ones (CAPTCHA, storage, DB). Bots get rejected in 5ms, humans never notice.
Second, fire and forget is a lie. If you do not track what fired and what failed, every silent failure looks like a happy path. I add a status field to every async job: pending, sent, failed. Look at the failed ones once a week.
Third, the response shape is part of the API even when the API is public. Bots watch what you return. Returning a 200 with a fake ID for honeypot hits is more effective than returning a 400, because failure tells them to mutate and retry. Lying is a feature.
The full code is open source if you want to see the actual implementation. github.com/lumizone/formto. The interesting files are routes/public.js for the pipeline and middleware/rateLimit.js for the spam stack.
What is the dumbest spam your forms have caught, and which layer caught it?