Documentation
Add Spamo in about two minutes.
Spamo blocks automated form spam with no CAPTCHA. There are two ways to
use it. In hosted mode you add one script tag and one
attribute to your form, and Spamo stores the lead, emails you and
optionally calls your webhook — no backend needed. In
API mode your own backend POSTs to
/api/v1/verify and gets back a plain
{allowed, reason} decision. Both run the identical five
checks: honeypot, timing, signature, expiry and dotted Gmail.
How do I add Spamo to a plain HTML form?
Create an account, create a site, and copy your site key from the dashboard. Then paste this. You do not need a backend, a build step or a package manager.
Hosted mode — copy and paste
<!-- 1. Load the widget once, anywhere on the page -->
<script src="https://spamo.net/assets/js/spamo.js" defer></script>
<!-- 2. Add your site key to the form. That is the whole install. -->
<form data-spamo-site="spamo_pk_YOUR_KEY_HERE"
data-spamo-success="Thanks — we will be in touch.">
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Your email" required>
<textarea name="message" placeholder="How can we help?"></textarea>
<button type="submit">Send</button>
</form>
That is the whole install. The script adds the hidden trap field and the signed token by itself. Do not add the trap field to your HTML by hand — its name is random per site and changes nothing if you guess it wrong, but a hard-coded one is a name a bot can learn.
What does the visitor see?
Nothing. No puzzle, no checkbox, no delay. On success the form resets and
shows your data-spamo-success message, or redirects to the
success URL you set on the site. A blocked bot sees the exact same thing,
which is the point.
How do I use Spamo from my own backend?
Use API mode. Your page still loads the widget so the token and trap field exist, but the form posts to your server. Your server then asks Spamo for a verdict using your private key, and decides what to do with the answer. Spamo never sees your lead data in this mode.
curl
# 1. Your page fetches a challenge (this happens in the browser)
curl "https://spamo.net/api/v1/challenge/spamo_pk_YOUR_KEY_HERE"
# -> {"ok":true,"token":"...","issued_at":1788159902,
# "expires_at":1788203102,"honeypot":"yitxh","min_seconds":2}
# 2. Your BACKEND asks Spamo for a verdict, using your private key
curl -X POST "https://spamo.net/api/v1/verify" \
-H "Authorization: Bearer spamo_sk_YOUR_PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{
"site_key": "spamo_pk_YOUR_KEY_HERE",
"spamo_token": "THE_TOKEN_FROM_THE_FORM",
"fields": { "name": "Ada", "email": "ada@example.com", "yitxh": "" }
}'
# -> {"allowed":true,"reason":null}
# -> {"allowed":false,"reason":"honeypot"}
PHP
<?php
// Server-side verification from your own backend.
$payload = [
'site_key' => 'spamo_pk_YOUR_KEY_HERE',
'spamo_token' => $_POST['spamo_token'] ?? '',
'fields' => $_POST,
];
$ch = curl_init('https://spamo.net/api/v1/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . getenv('SPAMO_SECRET'),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$result = json_decode(curl_exec($ch), true);
if (($result['allowed'] ?? false) === true) {
// Real person. Save the lead, send your email, redirect.
} else {
// Blocked. It is up to you what to do — most people show the same
// success message a real person sees, so the bot learns nothing.
}
JavaScript (server side only)
// Node / any JS backend. NEVER run this in the browser — the private key
// would be visible to anyone who opens dev tools.
const res = await fetch('https://spamo.net/api/v1/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SPAMO_SECRET}`,
},
body: JSON.stringify({
site_key: 'spamo_pk_YOUR_KEY_HERE',
spamo_token: body.spamo_token,
fields: body,
}),
});
const { allowed, reason } = await res.json();
if (allowed) {
// real submission
} else {
console.log('blocked because:', reason);
}
What is the difference between the widget and the API?
The widget lies to bots on purpose: it returns success no matter what, so
a caught bot never learns it was caught. The API tells you the truth —
{"allowed": false, "reason": "honeypot"} — and leaves the
lying to you. If you use the API, showing a bot the same success message
a human sees is the recommended behaviour.
How does Spamo actually decide?
Every submission runs these checks in this order, on our server:
clip-path rather than display:none — bots skip fields hidden the obvious ways. Anything typed in it is a bot.site_key|issued_at|nonce with your site's private key, compared in constant time. Edit the timestamp and the signature stops matching.+tag suffix is normal and is ignored.What does Spamo NOT do?
This section is not fine print. Knowing where the tool stops is how you decide whether it fits.
- Does not stop a patient bot that loads the real page, waits a few seconds, and uses a normal-looking email. This blocks cheap, lazy spam — it is not a wall.
- A challenge token can be reused by the same bot many times within its 12-hour window; only the site's own rate limit slows that down.
- No CAPTCHA, no puzzle, no IP blocking — Spamo ignores IP reputation entirely by design.
- Does not verify email deliverability or existence, does not block disposable email providers such as Mailinator, and does not block plus-alias tricks.
- Does not stop a human manually filling junk data.
- Does not touch login forms, password resets, admin pages, or your own APIs — Spamo is scoped to public lead, contact and signup forms only.
Does Spamo store my form data?
In hosted mode, submissions that pass are stored so you can read them in your dashboard, and are emailed to your notification address. Blocked attempts keep metadata only — a hashed IP, the user agent, the reason and a timestamp — so a spam flood cannot fill your account with junk. Raw IP addresses are never written to disk. In API mode nothing but the verdict metadata is stored, because the payload stays on your server.
Endpoint reference
| Method | Path | Auth | Returns |
|---|---|---|---|
| GET | /api/v1/challenge/{site_key} | None (public key) | token, issued_at, expires_at, honeypot |
| POST | /api/v1/submit/{site_key} | None (public key) | {ok:true} always, except {ok:false,"reason":"expired"} |
| POST | /api/v1/verify | Bearer private key | {allowed, reason} |
Block reasons: honeypot, timing,
signature, expired, gmail_dots,
rate_limit, inactive.
Need somewhere to put your site?
Spamo protects the form, not the hosting. If you still need a place to put the site itself, this is what we use.
Ready? Grab your site key.
Free plan, 500 submissions a month, no card. The snippet is waiting in your dashboard.