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.
npm install x402-toll @x402/coreOptional:
npm install web-bot-auth
npm install ioredisNeeded:
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.
import express from "express";
import { x402toll } from "x402-toll/express";
import { HTTPFacilitatorClient } from "@x402/core/http";
const app = express();
app.use(
x402toll({
payTo: "0xYourWalletAddress",
network: "eip155:8453",
facilitator: new HTTPFacilitatorClient({
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" },
},
],
}),
);import { x402toll } from "x402-toll/next";
import { HTTPFacilitatorClient } from "@x402/core/http";
const toll = x402toll({
payTo: "0xYourWalletAddress",
network: "eip155:8453",
facilitator: new HTTPFacilitatorClient(),
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" },
},
],
});
export default async function middleware(request) {
return toll(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
For every request, x402toll runs 3 short steps:
- 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
- Decide — We then match the verdict against your set policy rules in order. First match wins. No match defaults to allowing the traffic normally.
- Settle — On
charge, return402with 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>.
| Code | When |
|---|---|
| 200 | Allow path, or successful settle |
| 402 | Charge, payment required with PAYMENT-REQUIRED |
| 403 | Policy block action, caller refused bythe rules you set |
| 502 | Facilitator settle failed (settle_failed) |
| 503 | Facilitator verify unavailable (verify_failed), with Retry-After: 5 |
| Signal | Class | Confidence |
|---|---|---|
| Request is signed, but the signature doesn't check out | unknown-automation |
0.99 (veto) |
| Request is signed and the signature is valid | verified-agent |
0.99 |
| Reverse DNS points back to the vendor and forward resolves | verified-bot |
0.95 |
| IP CIDR falls inside the vendor's published ranges | verified-bot / agent |
0.90 |
| User Agent matches a known vendor pattern but no IP/DNS | claimed-but-unverified |
0.70 |
isbot flags it as a generic bot |
unknown-automation |
0.60 |
| Nothing matched | human |
0.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
wbaadapter ships yet, you pass your own verifier that implementsWbaVerifier. Leave it off and WBA sits out, the other three signals run as normal
Policy is plain TypeScript object. A rule is { match, action }. Every field in match must match logical AND, action are allow, block, or charge
type PolicyRule = {
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.
| Field | Default | What |
|---|---|---|
payTo |
required | The recipient 0x address |
network |
required | CAIP 2 id (e.g. eip155:8453) |
facilitator |
required | An @x402/core FacilitatorClient (e.g. HTTPFacilitatorClient) |
policy |
required | Ordered list of { match, action } rules |
asset |
USDC for the network | ERC20 token 0x address |
maxTimeoutSeconds |
60 |
Payment timeout window |
wire |
default v2 codec | Custom WireCodec for PAYMENT-REQUIRED / SIGNATURE / RESPONSE |
session |
off | JWT receipts, read more down |
signals |
UA + IP + rDNS (+ WBA if wired) | Per signal toggles / timeouts |
registry |
built in vendor registry | Custom CIDR registry |
receipts.store |
in-memory LRU | Redis or any ReceiptStore |
logger |
JSON to stderr | Custom Logger |
wbaVerifier |
off | Your 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
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-sessionheader 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
import Redis from "ioredis";
import { createRedisStore } from "x402-toll/store-redis";
x402toll({
...
receipts: {
store: createRedisStore({ client: new Redis(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.
From verfied sources
| Vendor | Source | Class |
|---|---|---|
| OpenAI | openai.com/{gptbot,searchbot,chatgpt-user}.json |
verified-agent |
| Anthropic | claude.com/crawling/bots.json |
verified-agent |
developers.google.com/search/apis/ipranges/{googlebot,special-crawlers,user-triggered-fetchers}.json |
verified-bot | |
| Perplexity | published ranges | verified-agent |
| Meta | no published CIDR source | claimed-but-unverified |
| Bing | bing.com/toolbox/bingbot.json |
verified-bot |
| Apple | search.developer.apple.com/applebot.json |
verified-bot |
| Common Crawl | published ranges | verified-bot |
| DuckDuckGo | duckduckgo.com/duckduckbot.json |
verified-bot |
| Yandex | rDNS only (.yandex.com / .net / .ru) |
verified-bot |
rDNS only (.fwd.linkedin.com) |
verified-bot | |
| Baidu | rDNS only (.baidu.com / .baidu.jp) |
verified-bot |
| Sogou | rDNS only (.sogou.com) |
verified-bot |
| Seznam | rDNS only (.seznam.cz) |
verified-bot |
| Mistral | mistral.ai/mistralai-{user,index}-ips.json |
verified-agent |
Meta has no public CIDR source, so it maxes out at
claimed-but-unverifiedvia UA. Wire aWbaVerifierand it hitsverified-agentat 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
import {
createToll,
type X402TollConfig,
type TollOutcome,
type TollSignals,
type FacilitatorClient,
type FacilitatorConfig,
type PaymentRequirements,
type PaymentRequired,
type PaymentPayload,
type VerifyResponse,
type SettleResponse,
type WireCodec,
HTTPFacilitatorClient,
defaultWireCodec,
usdcAssetFor,
} from "x402-toll";
// adapters
import { x402toll } from "x402-toll/express";
import { x402toll } from "x402-toll/next";
import { x402toll } from "x402-toll/next/edge";
import {
uaSignal,
ipSignal,
rdnsSignal,
webBotAuthSignal,
type WbaVerifier,
} from "x402-toll/signals";
import {
createRegistry,
createDefaultRegistry,
type Registry,
} from "x402-toll/registry";
import { createRedisStore } from "x402-toll/store-redis";{
"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
}Any EVM chain that your facilitator supports. Common ones:
| Network | CAIP-2 | USDC |
|---|---|---|
| Arbitrum One | eip155:42161 |
0xaf88d065e77c8cC2239327C5EDb3A432268e5831 |
| Arbitrum Sepolia | eip155:421614 |
0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d |
| Base mainnet | eip155:8453 |
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Base Sepolia | eip155:84532 |
0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Polygon | eip155:137 |
0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 |
| Polygon Amoy | eip155:80002 |
0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582 |
| World Chain | eip155:480 |
0x79A02482A880bCe3F13E09da970dC34dB4cD24D1 |
| World Chain Sepolia | eip155:4801 |
0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88 |
Simulate a request against a running server:
curl -H "User-Agent: GPTBot/1.1" http://localhost:3000/api/v1 -iYou'll see one JSON log line on stderr and a status code per the Status codes table above
MIT. See LICENSE
SPDX-License-Identifier: (MIT OR CC0-1.0)