It's free on every plan, up to 50,000 checks a month per account.
How it works
- Add the tag. A script and
<burrow-guard>next to the email field. - The box checks the email. When the visitor has typed their email, their browser solves a proof-of-work in the background, in about a second, and puts a token in a hidden
burrowguard-tokenfield. The box then checks the address and shows Verified, or Couldn't verify with "Try another email". It never says why. - Your server asks. Send the token and the email to
POST /burrowguard/verify. Accept ifsuccessis true. This is the final answer: the box doesn't stop the form from being sent, and verify runs every check, including the browser and IP checks the box can't.
Tokens work once, for one email, for 5 minutes.
Install
Create a site
In the dashboard, open BurrowGuard and choose Create a site. Or from a terminal:
You get a public site key (gm_site_…) and a secret (gm_bg_…). The secret is shown once. Keep it on your server, for example as GROUNDMAIL_BURROWGUARD_SECRET.
Tokens only work on the hostnames you list. Keep localhost while you build.
groundmail burrowguard sites create --name "Sign-up form" --hostname app.example.com --hostname localhostAdd it to your form
The tag finds the email field in its form by itself: input[type="email"], then input[name="email"]. To name it, add email-field="#work-email".
Visitors see a small box with the check's status, under the Groundmail name:
| Attribute | Values | Default | What it does |
|---|---|---|---|
theme |
auto, light, dark |
auto |
auto follows the visitor's system setting |
size |
normal, compact |
normal |
300 x 65, or one line at 300 x 44. Narrower columns shrink it |
mode |
visible, invisible |
visible |
invisible hides the box. The check still runs, and a "Protected by Groundmail" line stays |
For invisible, put the tag under your button: the line shows where the tag is.
The box has its own styles and your page's CSS doesn't affect it. It uses the visitor's system font and loads nothing from anyone else.
If you send the form with JavaScript, ask the element for the token. It waits for a solve that's still running, and never rejects:
If your site sets a content security policy, allow https://api.groundmail.dev in script-src and connect-src, and blob: in worker-src. Without worker-src the solve runs on the page instead, which still works. The box needs nothing added to style-src.
<script src="https://api.groundmail.dev/burrowguard/v1/burrowguard.js" async></script>
<form action="/signup" method="post">
<input type="email" name="email" required>
<burrow-guard site-key="YOUR_SITE_KEY"></burrow-guard>
<button>Create account</button>
</form><burrow-guard site-key="YOUR_SITE_KEY" theme="dark" size="compact"></burrow-guard>const token = await document.querySelector('burrow-guard').getToken()Verify on your server
With the Node SDK, which also lets the sign-up through if we can't be reached:
Call verify from your server, never the browser: the secret must stay private, and the visitor must not see the reason.
const check = await fetch('https://api.groundmail.dev/burrowguard/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GROUNDMAIL_BURROWGUARD_SECRET}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: req.body['burrowguard-token'],
email: req.body.email,
}),
})
const { success, reason } = await check.json()
if (!success && reason !== 'no_token') return showFormError() // your own messageimport os, requests
check = requests.post(
"https://api.groundmail.dev/burrowguard/verify",
headers={"Authorization": f"Bearer {os.environ['GROUNDMAIL_BURROWGUARD_SECRET']}"},
json={
"token": form.get("burrowguard-token"),
"email": form["email"],
},
timeout=5,
).json()
if not check["success"] and check["reason"] != "no_token":
return show_form_error() # your own messageimport { Groundmail } from 'groundmail'
const burrowguard = new Groundmail(process.env.GROUNDMAIL_BURROWGUARD_SECRET).burrowguard
const { success, reason } = await burrowguard.verify({ token, email })
if (!success && reason !== 'no_token') return showFormError()Email checks only
Already using a captcha? Turn on Email checks only for the site. There's no script, tag or box: your server sends the address and the secret in one call.
It runs the address checks: format and mail server, disposable domains, bounce history, bot patterns, and free webmail if you turned that on. There are no browser or IP checks, so this does nothing to stop a bot using a believable address: that's your captcha's job.
The setting belongs to the site. Sending no token to a site without it still returns no_token, so a bot can't switch a form to the lighter checks. Allowed hostnames are optional for these sites.
const check = await fetch('https://api.groundmail.dev/burrowguard/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GROUNDMAIL_BURROWGUARD_SECRET}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: req.body.email }),
})
const { success } = await check.json()
if (!success) return showFormError() // your own messagegroundmail burrowguard sites create --name "Contact form" --email-checks-onlyWatch only
Not sure what BurrowGuard would do on a live form? Turn on Watch only for your first week. Every check runs and is recorded, but nobody is turned away, so you can see what would be blocked before it counts.
Verify always answers success: true, with no change needed on your server. If a check failed, would_block_reason says which:
would_block_reason is left out when every check passed. It can be no_token, which the snippets above accept anyway.
On the BurrowGuard page these checks show as would block and have their own count, so Blocked only ever means someone was turned away. A token used twice still shows as token_used, it just isn't refused.
The setting belongs to the site, and nothing in the request can turn it on or off. Watch only changes the answer and nothing else: checks count towards your monthly limit as usual, a wrong secret is still a 401, and what the visitor sees on your form doesn't change.
When the results look right, turn it off. The site starts blocking at once, and past checks stay as they were recorded.
{ "success": true, "reason": null, "watch_only": true, "would_block_reason": "disposable" }groundmail burrowguard sites create --name "Sign-up form" --hostname app.example.com --watch-only
groundmail burrowguard checks --result would_block
groundmail burrowguard sites update SITE_ID --no-watch-onlyWhen the script can't run
A sign-up form must never break because of us. If the script is blocked, can't reach us or can't finish, the form still submits, just without a token. A submit only waits for a solve that's running, for 4 seconds at most. If the script loaded but can't reach us, the box says the check is unavailable and that the visitor can still sign up.
Your server then calls verify with an empty token, and the email checks still run:
- A bad address fails with its own reason, for example
disposable. - A good address returns
success: falsewithreason: no_token.
We recommend accepting no_token, as the snippets above do. It means nothing is known about the browser, but the address passed every email check. Real visitors land here too: some block scripts, and some networks block us.
A bot can skip the script on purpose, so a form that sees many no_token checks is worth a look. If you'd rather turn those away, or add your own check first, that's your call: treat no_token like any other failure.
Also let the sign-up through if the call itself fails, for example on a timeout. The SDK does this for you. With plain fetch, wrap the call in try/catch.
What it checks
In this order. The first failure blocks.
| Check | reason |
Notes |
|---|---|---|
| Token | invalid_token, expired_token, token_used, email_mismatch |
Signed by us, for this site and this email, unused, under 5 minutes old |
| Format and mail server | invalid_format, no_mail_server |
The address could exist, and its domain takes mail |
| Disposable domains | disposable |
Throwaway inbox services |
| Bounce history | bounce_history |
A hard bounce on Groundmail in the last 90 days. One generic answer, with nothing about where it came from |
| Likely bot sign-ups | bot_dot_stuffed_gmail, bot_gibberish |
Patterns in the address. They can be wrong: see Validation |
| Free webmail | free_webmail |
Only with Block free webmail on |
| Scripted browsers | scripted_browser, too_fast |
Automation flags and tools that name themselves, and forms sent under a second after the page was ready |
| Abusive IPs and Tor | abusive_ip, tor |
Published lists of abusive networks and Tor exits |
| Data-center IPs | data_center_ip |
Off with Allow data-center IPs on. Covers the ranges large cloud providers publish, not every host |
If one check can't get an answer, for example a DNS timeout, it passes.
BurrowGuard raises the cost of abuse. It doesn't stop everything: a determined bot can run a real browser from a home connection and use a believable address. Some checks read what the browser reports, and a bot can lie there. And a check can be wrong about a real person, so give blocked visitors a way forward, like your support address, in your own error message.
Authentication
| Endpoint | Credential |
|---|---|
POST /burrowguard/verify |
The site secret: Authorization: Bearer gm_bg_… |
POST /burrowguard/challenge, POST /burrowguard/verdict |
None. The browser script calls these with the public site key |
/burrowguard/sites, /burrowguard/checks |
A full-access API key, or groundmail login |
Verify a submission
/burrowguard/verify{ "token": "b1.eyJqdGkiOi…", "email": "ada@example.com" }200
reason is null when success is true. It's for your logs. Show visitors your form's own message, so a bot learns nothing from it.
Once the secret is right this always answers 200, so a limit on our side never looks like an error on yours.
successbooleanreasonstring or nulllimitedtrue, or left outsuccess is truewatch_onlytrue, or left outsuccess is always truewould_block_reasonstring, or left outreason this would have had{ "success": false, "reason": "disposable" }Errors
401 unauthorized: the secret is wrong. You can roll a new one under BurrowGuard in the dashboard.
Limits
50,000 checks a month per account, free. Each verify call counts as one. The check in the box doesn't, so a visitor fixing a typo costs you nothing. Past that, verify answers success: true with limited: true until the 1st, UTC: your forms keep working, unchecked, and the BurrowGuard page says so.
Verify is also limited to 1,200 calls a minute per site. Past that it answers the same way. The challenge is limited to 30 a minute per site and visitor; past that the form goes out with no token. The check in the box has the same limit; past that the box says the check is unavailable and the form still goes out.
Sites
| Endpoint | What it does |
|---|---|
GET /burrowguard/sites |
List sites. Secrets are never listed |
POST /burrowguard/sites |
Create one. The response holds the secret, once |
GET /burrowguard/sites/{id} |
Get one |
PATCH /burrowguard/sites/{id} |
Change the name, hostnames or options |
POST /burrowguard/sites/{id}/secret |
Roll the secret. The old one stops working at once |
DELETE /burrowguard/sites/{id} |
Delete it and its checks |
curl -X POST https://api.groundmail.dev/burrowguard/sites \
-H "Authorization: Bearer gm_…" \
-H "Content-Type: application/json" \
-d '{"name":"Sign-up form","allowed_hostnames":["app.example.com","localhost"],"block_free_webmail":true}'Checks
GET /burrowguard/checks returns recent checks with totals and this month's usage. Filter with site, result (passed, blocked, would_block, no_token) and range (24h, 7d, 30d).
Each check has watch_only. When it's true, result and reason are what would have happened, and nobody was turned away. The blocked filter and the blocked total are real blocks only: those checks are under would_block instead.
groundmail burrowguard checks --result blockedFrom an agent
The MCP server has create_burrowguard_site, update_burrowguard_site, list_burrowguard_sites and list_burrowguard_checks. Ask your agent:
An agent should put the secret straight into the app's environment as GROUNDMAIL_BURROWGUARD_SECRET, never in source code.
Privacy
- We never store the address. A check keeps the email's domain and a one-way hash, keyed per account, so the same person on two accounts can't be matched. The address reaches us from the visitor's browser for the check in the box, and from your server in the verify call. It's used for the checks and goes no further.
- No cookies, no storage, no tracking. The script adds its box and one hidden form field, loads nothing from anyone else, and doesn't identify anyone across sites.
- Each check also keeps the visitor's IP address and user agent, so you can look into a block. They're returned by
GET /burrowguard/checks. - Checks are deleted after 30 days.
Hard bounces are shared across Groundmail as a plain yes or no, with no detail, so nobody can learn who sent to an address. Complaints aren't used.