MainSDK
Search⌘K
Interfaces

Security and privacy

Bot defence

bots

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.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec bot-defenceFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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

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]

Calls

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

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

4 drivers. Select one to see its line in main.yaml.

main.yamlProof-of-work (built in, no third party) (default)
bots:
  driver: pow

Change this one line to swap providers; your calls stay the same.Proof-of-work integration

Codes

CodeWhere it appearsMeaning
MAIN-BOT-0005Checkmainsdk check, before deployA 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-0001RuntimeError responses and logsSee the spec
MAIN-BOT-0002RuntimeError responses and logsSee the spec
MAIN-BOT-0003RuntimeError responses and logsSee the spec
MAIN-BOT-0004RuntimeError responses and logsSee the spec
MAIN-BOT-0006RuntimeError responses and logsSee the spec
MAIN-BOT-0007RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
bots section in main.yaml with its check rulePhase 10.3bot-defence#schema
Door scoring and velocity countersPhase 10.3bot-defence#score
Form token and honeypotPhase 10.3bot-defence#forms
Proof-of-work challenge and browser clientPhase 10.3bot-defence#pow
Message pumping guardPhase 10.3bot-defence#pumping
Crawler verificationPhase 10.3bot-defence#crawlers
Turnstile and hCaptcha driversPhase 10.3bot-defence#captcha-drivers
Pushed block listPhase 10.3bot-defence#signals

Quick start

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

CallWhat 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

DriverProvider
powProof-of-work (built in, no third party)default
turnstileCloudflare Turnstile
hcaptchahCaptcha

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

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

Each code's cause and fix is in 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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet