Stop form spam without a CAPTCHA.
Bot403 is an invisible bot shield for HTML forms: one script tag makes the browser solve a small proof of work, and your server checks it with one call.
- challenge
- issued 09:41:06 UTC, difficulty medium
- solved
- in 312 ms, in a Web Worker, while Mira typed
- verify
- ok, 9 ms, counted 1 of 5,000 this month
5,000 checks a month, free. No card required.
Up and running in five minutes
Copy the snippets, or ask your AI assistant to do it with the MCP server.
The script adds a hidden field with the solved challenge to every form. Full guide
<!-- 1. Load the script once, with your public key -->
<script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async></script>
<!-- 2. Keep your form as it is. Every form on the page is protected -->
<form action="/contact" method="POST">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<!-- 3. On your server, post the _bot403 field to /api/v1/verify. See the server tabs. -->Loads with the page and solves a fresh challenge for every submit. Full guide
// npm install @bot403/browser, or load the script tag and call Bot403.solve()
import { solve, FIELD } from "@bot403/browser";
// 1. Get a token. The proof of work runs in a Web Worker
const token = await solve("pk_live_YOUR_PUBLIC_KEY");
// 2. Send the token with your data. Your server posts it to /api/v1/verify
await fetch("/contact", {
method: "POST",
body: new URLSearchParams({ email: "[email protected]", message: "Hello", [FIELD]: token }),
});Loads with the page and solves a fresh challenge for every submit. Full guide
// npm install @bot403/react
import { useBot403, FIELD } from "@bot403/react";
export function ContactForm() {
// 1. Loads the script on mount; getToken() returns a fresh single-use token
const getToken = useBot403("pk_live_YOUR_PUBLIC_KEY");
async function onSubmit(e) {
e.preventDefault();
const body = new URLSearchParams(new FormData(e.currentTarget));
// 2. Add the token before you post the form to your server
body.set(FIELD, await getToken());
await fetch("/contact", { method: "POST", body });
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button>Send</button>
</form>
);
}Add the MCP server and let your assistant do the work: it signs you up, creates the site and adds the code. You only click the sign-in link in your email. Full guide
# Claude Code
claude mcp add --transport http bot403 https://bot403.com/mcp
# Codex
codex mcp add bot403 --url https://bot403.com/mcp
# Then ask, for example:
# "Protect the contact form in this project with Bot403."// npm install @bot403/node express
import express from "express";
import { Bot403 } from "@bot403/node";
// Keep the secret key on the server: BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
const bot403 = new Bot403({ secretKey: process.env.BOT403_SECRET_KEY });
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/contact", async (req, res) => {
// 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
const result = await bot403.verifyForm(req.body);
// 2. Refuse when ok is false, except for reason "quota"
if (!result.ok && result.reason !== "quota") return res.status(400).send("Try again");
res.send("Thanks.");
});
app.listen(3000);<?php
// Keep the secret key on the server: BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
// 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
$ch = curl_init("https://bot403.com/api/v1/verify");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BOT403_SECRET_KEY")],
CURLOPT_POSTFIELDS => http_build_query($_POST),
CURLOPT_RETURNTRANSFER => true,
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
// 2. Refuse when ok is false, except for reason "quota"
if (!$result["ok"] && $result["reason"] !== "quota") {
http_response_code(400);
exit("Try again");
}import os
import requests
from flask import abort, request
# Keep the secret key on the server: BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
# 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
r = requests.post(
"https://bot403.com/api/v1/verify",
headers={"Authorization": "Bearer " + os.environ["BOT403_SECRET_KEY"]},
data=request.form,
timeout=5,
)
result = r.json()
# 2. Refuse when ok is false, except for reason "quota"
if not result["ok"] and result.get("reason") != "quota":
abort(400)package main
import (
"encoding/json"
"net/http"
"os"
"strings"
)
// Keep the secret key on the server: BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
func contact(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
// 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
req, _ := http.NewRequestWithContext(r.Context(), "POST", "https://bot403.com/api/v1/verify", strings.NewReader(r.PostForm.Encode()))
req.Header.Set("Authorization", "Bearer "+os.Getenv("BOT403_SECRET_KEY"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var result struct {
OK bool
Reason string
}
res, err := http.DefaultClient.Do(req)
if err == nil {
defer res.Body.Close()
json.NewDecoder(res.Body).Decode(&result)
}
// 2. Refuse when ok is false, except for reason "quota". On err, let the form through
if err == nil && !result.OK && result.Reason != "quota" {
http.Error(w, "Try again", http.StatusBadRequest)
return
}
w.Write([]byte("Thanks."))
}
func main() {
http.HandleFunc("POST /contact", contact)
http.ListenAndServe(":3000", nil)
}require "net/http"
require "json"
# Keep the secret key on the server: BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY
# 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field
uri = URI("https://bot403.com/api/v1/verify")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer " + ENV["BOT403_SECRET_KEY"])
req.set_form_data(request.POST)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
result = JSON.parse(res.body)
# 2. Refuse when ok is false, except for reason "quota"
halt 400, "Try again" if !result["ok"] && result["reason"] != "quota"Bun, Deno, Hono, Next.js and Cloudflare Workers on the verify page
Who it fits
Best for
- Contact, sign-up and comment forms on small and medium sites.
- Sites that must keep cookies and Google or Cloudflare scripts off their pages.
- Developers who can add one server call in any language.
Not for
- DDoS attacks, scrapers or traffic floods.
- Forms with no server step to make the verify call.
- Spam that a person types by hand.
Compared with reCAPTCHA and Turnstile
Your visitors' data stays with a small EU provider, not with Google or Cloudflare. Read more on the compare page.
| Topic | Bot403 | Google reCAPTCHA | Cloudflare Turnstile |
|---|---|---|---|
| Price | free for 5,000 checks a month, then from €3 a month | free for 10,000 a month, then from $8 a month | free, larger plans on request |
| Cookies | none, nothing to add to your cookie banner | sets the _GRECAPTCHA cookie | can set a pre-clearance cookie |
| Who sees your visitors | only Bot403, hosted in the EU; no IP addresses stored, no cookies, no tracking | Cloudflare | |
| Setup | one script tag and one server call; sign in with an email | a Google Cloud project with billing | a Cloudflare account |
Stop spam, not customers.
The CAPTCHA your visitors never see.