---
title: "Bot defence"
description: "Score the sign-in, sign-up, reset and invite doors in process, challenge or refuse bots with a proof-of-work, and stop SMS and WhatsApp pumping before a message is paid for."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "bot-defence"
canonical: "https://mainsdk.dev/docs/interfaces/bots"
updated: "2026-10-28"
---

# Bot defence

> Score the sign-in, sign-up, reset and invite doors in process, challenge or refuse bots with a proof-of-work, and stop SMS and WhatsApp pumping before a message is paid for.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/bots · Updated: 2026-10-28

Score the sign-in, sign-up, reset and invite doors in process, challenge or refuse bots with a proof-of-work, and stop SMS and WhatsApp pumping before a message is paid for.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/bot-defence.md
- Category: Security and privacy

Calls:

- `bots.formToken()`
- `bots.formFields()`
- `bots.check(req, { door? })`
- `bots.guardSend(phone)`
- `bots.challenge({ score? })`

In main.yaml:

```yaml
bots:
  mode: enforce
  doors:
    sign-up: { challenge: 30, deny: 80, disposable-email: deny }
    contact: { path: /api/contact, form: true }
  challenge: { driver: pow }
  pumping: { per-prefix: { max: 10, per: 1h }, countries: [IN] }
  crawlers: [googlebot]
  block: [198.51.100.0/24]
```

Guarantees, whatever the driver:

- A request is scored from its headers, a form token and counters in process, with no cookie, no script and no fingerprint
- A refusal never says why; the reasons are in the log and the security event
- No raw address, email or phone number is stored, and each counter holds at most 10,000 keys
- A store outage never blocks a sign-in; the counters fall back to local


## Drivers

Pick one in main.yaml with `bots.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Proof-of-work (built in, no third party) | `pow` | yes |  |
| In-process counters (the default store; no third party) | `local` |  |  |
| Cloudflare Turnstile | `turnstile` |  |  |
| hCaptcha | `hcaptcha` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-BOT-0005` | check | A `bots` value is invalid: a door path that matches no route, `challenge` not below `deny`, an unknown country code or CIDR, or an unknown key |
| `MAIN-BOT-0001` | runtime |  |
| `MAIN-BOT-0002` | runtime |  |
| `MAIN-BOT-0003` | runtime |  |
| `MAIN-BOT-0004` | runtime |  |
| `MAIN-BOT-0006` | runtime |  |
| `MAIN-BOT-0007` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| bots section in main.yaml with its check rule | 1 | 0.3 | `bot-defence#schema` |
| Door scoring and velocity counters | 1 | 0.3 | `bot-defence#score` |
| Form token and honeypot | 1 | 0.3 | `bot-defence#forms` |
| Proof-of-work challenge and browser client | 1 | 0.3 | `bot-defence#pow` |
| Message pumping guard | 1 | 0.3 | `bot-defence#pumping` |
| Crawler verification | 1 | 0.3 | `bot-defence#crawlers` |
| Turnstile and hCaptcha drivers | 1 | 0.3 | `bot-defence#captcha-drivers` |
| Pushed block list | 1 | 0.3 | `bot-defence#signals` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
bots:
  mode: enforce
  doors:
    sign-up: { challenge: 30, deny: 80, disposable-email: deny }
    contact: { path: /api/contact, form: true }
  challenge: { driver: pow }
  pumping: { per-prefix: { max: 10, per: 1h }, countries: [IN] }
  crawlers: [googlebot]
  block: [198.51.100.0/24]
```

```ts title="app/example.ts"
import { bots } from "mainsdk/bots";

// a hidden token and a honeypot field for a contact form; the door is declared in main.yaml
const { html } = bots.formFields();

// before you send a code yourself
await bots.guardSend("+91 98765 43210");   // throws MAIN-BOT-0007 (429) when refused
```

## Configuration

A request to a door is scored from 0 (person) to 100 (bot). Below the door's `challenge` score it goes through. At or above it, the answer is 403 `MAIN-BOT-0001` with a `challenge` member; the browser solves it with `mainsdk/bots/client` (`fetchWithChallenge` does this and retries once). At or above `deny`, it is 403 `MAIN-BOT-0002`. The reasons are in the log and the `bot.challenged` or `bot.denied` security event, never in the response.

The built-in doors are `sign-in`, `sign-up`, `reset`, `whatsapp` and `invite`, mapped onto the auth routes. Declare your own with `doors.<name>.path`. `mode: observe` lets every request through and logs what would have happened. With no `bots` section, nothing is scored.

## Calls

| Call | What it does |
| --- | --- |
| `bots.formToken()` | A signed hidden-field value for a form: the issue time and a honeypot field name. Use `bots.formFields()` for the ready HTML. |
| `bots.formFields()` | The token and the honeypot input as HTML to put inside the form, with the field names. |
| `bots.check(req, { door? })` | Scores one request and returns the decision: `allow`, `challenge` or `deny`, with the score and reasons. The engine already does this for every door. |
| `bots.guardSend(phone)` | Throws `MAIN-BOT-0007` (429) when the country is not allowed or the send limits are reached. The WhatsApp code send calls it for you. |
| `bots.challenge({ score? })` | A fresh proof-of-work challenge sized for a score. |

## Drivers

| Driver | Provider | |
| --- | --- | --- |
| `pow` | Proof-of-work (built in, no third party) | default |
| `turnstile` | Cloudflare Turnstile |  |
| `hcaptcha` | hCaptcha |  |

## What you get

- A request is scored from its headers, a form token and counters in process, with no cookie, no script and no fingerprint
- A refusal never says why; the reasons are in the log and the security event
- No raw address, email or phone number is stored, and each counter holds at most 10,000 keys
- A store outage never blocks a sign-in; the counters fall back to local

## Errors and checks

| Code | When |
| --- | --- |
| `MAIN-BOT-0001` | 403: a challenge is required; the problem's `challenge` member carries it |
| `MAIN-BOT-0002` | 403: refused as automated; the reasons are logged, not returned |
| `MAIN-BOT-0003` | 403: the solution is wrong, expired, for another challenge or used twice |
| `MAIN-BOT-0004` | 403: the form token is missing, forged or older than 1 hour |
| `MAIN-BOT-0005` | check: a `bots` value is invalid, such as a door path that matches no route |
| `MAIN-BOT-0006` | the CAPTCHA provider did not answer in 2 s; the built-in challenge was used (logged once per outage) |
| `MAIN-BOT-0007` | 429: a WhatsApp or SMS code was refused before sending |

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes). `mainsdk check` runs the `bots` gate.

## Swapping drivers

`challenge.driver` is `pow` (the default), `turnstile` or `hcaptcha`. A provider needs `sitekey` and a `secret:NAME` reference for its secret; the page's CSP lists the provider's hosts only when the driver is set, and the token is verified through `http.fetch` with a 2 s timeout and no retry. If the provider does not answer, the built-in challenge is used instead of letting the request through.

```yaml title="main.yaml"
bots:
  challenge:
    driver: turnstile
    turnstile: { sitekey: 0x4AAAA, secret: "secret:TURNSTILE_SECRET" }
```

## Testing

Call `bots.check(new Request(url, { method: "POST", headers }))` and read `action`, `score` and `reasons`. Set `mode: observe` first in a real app. The proof-of-work helpers `solveChallenge` and `encodeSolution` from `mainsdk/bots` solve a challenge in a test.

## Next

- [Use bot defence in your app](https://mainsdk.dev/docs/guides/use-bots)
- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)
