Skip to content

Repository files navigation

x402toll

npm versionCILicense

First classify the caller, then allow, block, or charge all via x402

x402toll is a middleware library that decides whether each request is either a human, a verified bot, verified AI agent, claimed-but-unverified, or an unknown automation, and routes accordingly.

x402toll is not a bot-detector. It is not an x402 client. x402toll sits in between both.

Install

npm install x402-toll @x402/core

Optional:

npm install web-bot-auth
npm install ioredis

Needed: Node 20+. Next.js 14, 15, or 16. The Edge adapter we have has the same API as the Node adapter but skips the reverse DNS verification. node:dns/promises as you know is not available on edge.

Getting Started

Express

importexpressfrom"express";import{x402toll}from"x402-toll/express";import{HTTPFacilitatorClient}from"@x402/core/http";constapp=express();app.use(x402toll({payTo: "0xYourWalletAddress",network: "eip155:8453",facilitator: newHTTPFacilitatorClient({url: "https://api.cdp.coinbase.com/platform/v2/x402",}),policy: [{match: {class: "human"},action: {kind: "allow"}},{match: {class: "verified-bot",vendor: "google"},action: {kind: "allow"},},{match: {class: "verified-bot",vendor: "bing"},action: {kind: "allow"},},{match: {class: "verified-agent",vendor: "openai"},action: {kind: "charge",price: "$0.01"},},{match: {class: "verified-agent",vendor: "anthropic"},action: {kind: "charge",price: "$0.01"},},{match: {class: "claimed-but-unverified"},action: {kind: "charge",price: "$0.05"},},{match: {class: "unknown-automation"},action: {kind: "charge",price: "$0.10"},},],}),);

Next.js

import{x402toll}from"x402-toll/next";import{HTTPFacilitatorClient}from"@x402/core/http";consttoll=x402toll({payTo: "0xYourWalletAddress",network: "eip155:8453",facilitator: newHTTPFacilitatorClient(),policy: [{match: {class: "human"},action: {kind: "allow"}},{match: {class: "verified-bot",vendor: "google"},action: {kind: "allow"},},{match: {class: "verified-bot",vendor: "bing"},action: {kind: "allow"},},{match: {class: "verified-agent",vendor: "openai"},action: {kind: "charge",price: "$0.01"},},{match: {class: "verified-agent",vendor: "anthropic"},action: {kind: "charge",price: "$0.01"},},{match: {class: "claimed-but-unverified"},action: {kind: "charge",price: "$0.05"},},{match: {class: "unknown-automation"},action: {kind: "charge",price: "$0.10"},},],});exportdefaultasyncfunctionmiddleware(request){returntoll(request);}

For the Edge runtime, you'll swap the import: import { x402toll } from 'x402-toll/next/edge'. The edge adapter drops the reverse dns check

How it works

For every request, x402toll runs 3 short steps:

  1. Classify — We evaluate the req against up to four signals: The User Agent pattern, IP CIDR matching, forward confirmed rDNS, and Web Bot Auth signature. The strongest one wins, signals don't combine
  2. Decide — We then match the verdict against your set policy rules in order. First match wins. No match defaults to allowing the traffic normally.
  3. Settle — On charge, return 402 with the payment requirements. The client then retries with payment attached, your facilitator verifies, then settles on-chain

The whole thing is a single func: (input) → Promise<TollOutcome>.

Status codes

CodeWhen
200Allow path, or successful settle
402Charge, payment required with PAYMENT-REQUIRED
403Policy block action, caller refused bythe rules you set
502Facilitator settle failed (settle_failed)
503Facilitator verify unavailable (verify_failed), with Retry-After: 5

How classification works

SignalClassConfidence
Request is signed, but the signature doesn't check outunknown-automation0.99 (veto)
Request is signed and the signature is validverified-agent0.99
Reverse DNS points back to the vendor and forward resolvesverified-bot0.95
IP CIDR falls inside the vendor's published rangesverified-bot / agent0.90
User Agent matches a known vendor pattern but no IP/DNSclaimed-but-unverified0.70
isbot flags it as a generic botunknown-automation0.60
Nothing matchedhuman0.50

Web Bot Auth (WBA) runs first whenever its headers are there. A forged signature vetoes any later verdict the weaker confidence would have produced

No default wba adapter ships yet, you pass your own verifier that implements WbaVerifier. Leave it off and WBA sits out, the other three signals run as normal

Policy

Policy is plain TypeScript object. A rule is { match, action }. Every field in match must match logical AND, action are allow, block, or charge

typePolicyRule={match: {class?: 'human'|'verified-bot'|'verified-agent'|'claimed-but-unverified'|'unknown-automation';vendor?: 'google'|'bing'|'openai'|'anthropic'|/* others ... */;confidence?: {gte: number};};action:
|{kind: 'allow'}|{kind: 'block'}|{kind: 'charge';price: `$${number}` };};

The price on a charge action is a $-prefixed USD string like $0.01. It resolves to atomic units (6 decimals) per the chosen asset.

Config

FieldDefaultWhat
payTorequiredThe recipient 0x address
networkrequiredCAIP 2 id (e.g. eip155:8453)
facilitatorrequiredAn @x402/coreFacilitatorClient (e.g. HTTPFacilitatorClient)
policyrequiredOrdered list of { match, action } rules
assetUSDC for the networkERC20 token 0x address
maxTimeoutSeconds60Payment timeout window
wiredefault v2 codecCustom WireCodec for PAYMENT-REQUIRED / SIGNATURE / RESPONSE
sessionoffJWT receipts, read more down
signalsUA + IP + rDNS (+ WBA if wired)Per signal toggles / timeouts
registrybuilt in vendor registryCustom CIDR registry
receipts.storein-memory LRURedis or any ReceiptStore
loggerJSON to stderrCustom Logger
wbaVerifieroffYour Web Bot Auth verifier

The library ships a USDC asset for Base, Base Sepolia, Arbitrum (One + Sepolia), Polygon (+ Amoy), World Chain (+ Sepolia), and Ethereum. Pass asset to override or to target any other ERC20

Sessions

Without sessions every request runs the full x402 verify and settle flow everytime, even when the same caller hits the same paid route over and over. in our case, a paid caller gets a JWT so they don't re pay for every request they make. The default ttl is 1 hour, scoped to the same route group "network, scheme, payTo, price"

Session tokens are bearer credentials. A leaked x402toll-session header or cookie for example spends the budget for whoever holds it until the expiry

x402toll({
...
session: {enabled: true,secret: [process.env.X402_SECRET,process.env.X402_SECRET_PREV],maxAge: 3600,maxRequests: 100,},});

For multi replica deployments for e.g, you'll swap the in memory store for Redis

importRedisfrom"ioredis";import{createRedisStore}from"x402-toll/store-redis";x402toll({
...
receipts: {store: createRedisStore({client: newRedis(process.env.REDIS_URL)}),},});

Side Note: The in-memory store uses LRU cleanup at 10,000 entries. Refusing on overflow would let a person for example pay once and lock a slot, LRU evicts them first, paid once, never came back is exactly least recently used.

Current coverage

From verfied sources

VendorSourceClass
OpenAIopenai.com/{gptbot,searchbot,chatgpt-user}.jsonverified-agent
Anthropicclaude.com/crawling/bots.jsonverified-agent
Googledevelopers.google.com/search/apis/ipranges/{googlebot,special-crawlers,user-triggered-fetchers}.jsonverified-bot
Perplexitypublished rangesverified-agent
Metano published CIDR sourceclaimed-but-unverified
Bingbing.com/toolbox/bingbot.jsonverified-bot
Applesearch.developer.apple.com/applebot.jsonverified-bot
Common Crawlpublished rangesverified-bot
DuckDuckGoduckduckgo.com/duckduckbot.jsonverified-bot
YandexrDNS only (.yandex.com / .net / .ru)verified-bot
LinkedInrDNS only (.fwd.linkedin.com)verified-bot
BaidurDNS only (.baidu.com / .baidu.jp)verified-bot
SogourDNS only (.sogou.com)verified-bot
SeznamrDNS only (.seznam.cz)verified-bot
Mistralmistral.ai/mistralai-{user,index}-ips.jsonverified-agent

Meta has no public CIDR source, so it maxes out at claimed-but-unverified via UA. Wire a WbaVerifier and it hits verified-agent at 0.99

User Agent patterns: GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, Claude-User, anthropic-ai, PerplexityBot, Meta-ExternalAgent, Meta-ExternalFetcher, Googlebot, bingbot, Applebot-Extended, Applebot, CCBot, DuckDuckBot, YandexBot, LinkedInBot, Baiduspider, Sogou web/inst/news spider, SeznamBot, MistralAI-User, MistralAI-Index. Generic bots are caught by [isbot](https://www.npmjs.com/package/isbot).

Not supported yet: xAI / Grok, Brave Search, ByteDance / Bytespider — none publish verifiable IPs, rDNS suffixes, or signed identities. Amazon's three crawlers (Amazonbot, Amzn-SearchBot, Amzn-User) publish IPs in HTML, an adapter is planned

Public

import{createToll,typeX402TollConfig,typeTollOutcome,typeTollSignals,typeFacilitatorClient,typeFacilitatorConfig,typePaymentRequirements,typePaymentRequired,typePaymentPayload,typeVerifyResponse,typeSettleResponse,typeWireCodec,HTTPFacilitatorClient,defaultWireCodec,usdcAssetFor,}from"x402-toll";// adaptersimport{x402toll}from"x402-toll/express";import{x402toll}from"x402-toll/next";import{x402toll}from"x402-toll/next/edge";import{uaSignal,ipSignal,rdnsSignal,webBotAuthSignal,typeWbaVerifier,}from"x402-toll/signals";import{createRegistry,createDefaultRegistry,typeRegistry,}from"x402-toll/registry";import{createRedisStore}from"x402-toll/store-redis";

Logging

{
"ts": "2026-05-10T12:00:00.000Z",
"rid": "...",
"route": "/api/data",
"ip": "1.2.3.4",
"ua": "GPTBot/1.1",
"class": "verified-agent",
"vendor": "openai",
"conf": 0.9,
"decision": "charge",
"price": "$0.01",
"network": "eip155:8453",
"chain": "Base",
"tx": "0x...",
"evidence": [
{ "type": "ip_match", "vendor": "openai", "cidr": "40.84.180.224/28" }
],
"ms": 3
}

Networks

Any EVM chain that your facilitator supports. Common ones:

NetworkCAIP-2USDC
Arbitrum Oneeip155:421610xaf88d065e77c8cC2239327C5EDb3A432268e5831
Arbitrum Sepoliaeip155:4216140x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d
Base mainneteip155:84530x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
Base Sepoliaeip155:845320x036CbD53842c5426634e7929541eC2318f3dCF7e
Polygoneip155:1370x3c499c542cEF5E3811e1192ce70d8cC03d5c3359
Polygon Amoyeip155:800020x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582
World Chaineip155:4800x79A02482A880bCe3F13E09da970dC34dB4cD24D1
World Chain Sepoliaeip155:48010x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88

Simulate a request against a running server:

curl -H "User-Agent: GPTBot/1.1" http://localhost:3000/api/v1 -i

You'll see one JSON log line on stderr and a status code per the Status codes table above

License

MIT. See LICENSE

SPDX-License-Identifier: (MIT OR CC0-1.0)

About

Classify the caller, then allow, block, or charge via x402

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages