# BurrowGuard

BurrowGuard keeps bots and bad addresses out of your sign-up and contact forms. Add a script and one tag. The visitor's browser solves a small proof-of-work in the background, so nobody sees a puzzle, and your server gets a clear pass or block before the account exists.

It's free on every plan, up to 50,000 checks a month per account.

---

## How it works

1. **Add the tag.** A script and `<burrow-guard>` next to the email field.
2. **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-token` field. The box then checks the address and shows Verified, or Couldn't verify with "Try another email". It never says why.
3. **Your server asks.** Send the token and the email to `POST /burrowguard/verify`. Accept if `success` is 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

### 1. Create a site

In the dashboard, open [BurrowGuard](https://app.groundmail.dev/burrowguard) and choose **Create a site**. Or from a terminal:

```bash
groundmail burrowguard sites create --name "Sign-up form" --hostname app.example.com --hostname localhost
```

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.

### 2. Add it to your form

```html
<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>
```

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 |

```html
<burrow-guard site-key="YOUR_SITE_KEY" theme="dark" size="compact"></burrow-guard>
```

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:

```js
const token = await document.querySelector('burrow-guard').getToken()
```

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`.

### 3. Verify on your server

```js
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 message
```

```python
import 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 message
```

With the Node SDK, which also lets the sign-up through if we can't be reached:

```js
import { 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()
```

Call verify from your server, never the browser: the secret must stay private, and the visitor must not see the reason.

---

## Email checks only {#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.

```js
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 message
```

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.

```bash
groundmail burrowguard sites create --name "Contact form" --email-checks-only
```

---

## Watch only {#watch-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:

```json
{ "success": true, "reason": null, "watch_only": true, "would_block_reason": "disposable" }
```

`would_block_reason` is left out when every check passed. It can be `no_token`, which the snippets above accept anyway.

On the [BurrowGuard](https://app.groundmail.dev/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.

```bash
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-only
```

---

## When the script can't run {#no-token}

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: false` with `reason: 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](https://docs.groundmail.dev/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

`POST /burrowguard/verify`

```json
{ "token": "b1.eyJqdGkiOi…", "email": "ada@example.com" }
```

### 200

```json
{ "success": false, "reason": "disposable" }
```

`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.

| Field | Type | Meaning |
|---|---|---|
| `success` | boolean | True means accept |
| `reason` | string or null | Why not |
| `limited` | true, or left out | Nothing was checked. `success` is true |
| `watch_only` | true, or left out | The site is on [watch only](#watch-only). `success` is always true |
| `would_block_reason` | string, or left out | On watch only, the `reason` this would have had |

### 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 |

```bash
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`).

```bash
groundmail burrowguard checks --result blocked
```

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.

---

## From an agent

The MCP server has `create_burrowguard_site`, `update_burrowguard_site`, `list_burrowguard_sites` and `list_burrowguard_checks`. Ask your agent:

> Add BurrowGuard to my sign-up form and verify it on the server.

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.
