tollgate-sdk

Drop-in AI bot paywall for Express, Next.js, Fastify, and Cloudflare Workers. Provide your Solana wallet address — USDC payments land there directly. No API key, no signup, no custodian.

Overview

Tollgate intercepts HTTP requests at the middleware layer. When it detects an AI bot, it returns HTTP 402 with an x402 payment envelope — the bot's wallet address, price in USDC, and a signed challenge token. Human visitors pass through with zero overhead.

On retry with a valid X-PAYMENT header, the SDK verifies the on-chain USDC transfer via Solana RPC and unlocks content. Replay protection is enforced via Supabase.

No private key on the server. You only provide your wallet address. USDC flows from the agent's wallet directly to your ATA on-chain.

Installation

bash
npm install tollgate-sdk

Zero Solana dependencies on your server — the SDK handles all on-chain verification through the hosted facilitator.

Supabase setup (required)

Run the schema in your Supabase SQL editor once. This creates the payments table and replay-protection cache.

sql
create table if not exists public.payments (
  id             bigserial primary key,
  tx             text not null unique,
  wallet_address text,
  bot_name       text,
  path           text,
  lamports       bigint,
  timestamp      timestamptz not null default now()
);

create table if not exists public.verified_tx_cache (
  tx        text primary key,
  cached_at timestamptz not null default now()
);
bash
# .env
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

Quick Start

The only required config is your Solana wallet address. Everything else has a sensible default.

Express

js
import express from "express";
import { createPaywall } from "tollgate-sdk";
import { expressMiddleware } from "tollgate-sdk/express";

const paywall = createPaywall({
  walletAddress: process.env.SOLANA_WALLET_ADDRESS,
  network: "mainnet-beta",
  protect: ["/*"],              // gate all routes
  basePriceMicroUsdc: 1_000,   // $0.001 per crawl
});

const app = express();
app.use(expressMiddleware(paywall));

// req.paywallPayment is set when a bot paid successfully
app.get("/articles/:slug", (req, res) => {
  res.json({
    content: "Your article...",
    payment: req.paywallPayment ?? null,
  });
});

Next.js (App Router)

Use paywallMiddleware in middleware.ts to gate routes at the edge, before any route handler runs.

ts
// middleware.ts
import { createPaywall } from "tollgate-sdk";
import { paywallMiddleware } from "tollgate-sdk/nextjs";

const paywall = createPaywall({
  walletAddress: process.env.SOLANA_WALLET_ADDRESS!,
  basePriceMicroUsdc: 1_000,
  protect: ["/articles/*", "/blog/*"],
});

export default paywallMiddleware(paywall);

export const config = { matcher: ["/articles/:path*", "/blog/:path*"] };

For App Router route handlers, use withRouteHandler:

ts
// app/articles/[slug]/route.ts
import { withRouteHandler } from "tollgate-sdk/nextjs";
import { paywall } from "@/lib/paywall"; // shared instance

export const GET = withRouteHandler(paywall, async (req) =>
  Response.json({ content: "Your article..." })
);

Fastify

js
import Fastify from "fastify";
import { createPaywall } from "tollgate-sdk";
import { fastifyPlugin } from "tollgate-sdk/fastify";

const paywall = createPaywall({
  walletAddress: process.env.SOLANA_WALLET_ADDRESS,
  basePriceMicroUsdc: 1_000,
});

const app = Fastify();
await app.register(fastifyPlugin, { paywall });

app.get("/articles/:slug", async (req, reply) => {
  return { content: "Your article..." };
});

Cloudflare Workers

js
import { createPaywall } from "tollgate-sdk";
import { cloudflareHandler } from "tollgate-sdk/cloudflare";

export default {
  async fetch(request, env) {
    const paywall = createPaywall({
      walletAddress: env.SOLANA_WALLET_ADDRESS,
      basePriceMicroUsdc: 1_000,
    });

    return cloudflareHandler(paywall, request, async () =>
      new Response(JSON.stringify({ content: "Your article..." }), {
        headers: { "Content-Type": "application/json" },
      })
    );
  },
};

Configuration

Pass these options to createPaywall({ ... }).

OptionDefaultDescription
walletAddressrequiredYour Solana wallet address. USDC payments land at its ATA.
network"devnet"Solana network — "devnet" or "mainnet-beta".
protect["/*"]Path globs or RegExp patterns to gate. Use ["/*"] to protect all routes.
basePriceMicroUsdc1000Price per crawl in micro-USDC. 1000 = $0.001. 1_000_000 = $1.00.
botScoreThreshold70Composite score threshold for bot classification. Lower = stricter.
allowList[]UA patterns that always pass as humans, e.g. [{pattern: /Googlebot/i}].
failOpenfalseIf true, allow bots through when the facilitator is unreachable.
onDetection(d)Hook called with the bot detection result on every classified request.
apiUrlhostedOverride the facilitator URL for self-hosting.
timeoutMs8000Network timeout for facilitator calls in milliseconds.

Path matching

The protect option accepts strings or RegExp. String patterns support * suffix wildcards.

js
protect: ["/*"]                  // all routes
protect: ["/articles/*"]        // prefix match
protect: ["/blog/*", "/docs/*"] // multiple prefixes
protect: [/^/api//]           // RegExp

Using req.paywallPayment

After a verified payment, Express and Fastify attach the payment receipt to the request object.

js
app.get("/article", (req, res) => {
  console.log(req.paywallPayment);
  // {
  //   signature: "3jK9xZ...",  // Solana tx signature
  //   payer:     "AgentWallet...",
  //   received:  1000           // micro-USDC received
  // }
});

Bot Detection

Detection runs entirely in-process — no network call, zero overhead for human visitors. A composite score across four signals determines whether a request is classified as a bot.

SignalExamplesScore
User-Agent matchGPTBot, ClaudeBot, PerplexityBot, CCBot, Scrapy, python-requests55–90 pts
Missing browser headersaccept-language, sec-fetch-site, sec-ch-ua absent12 pts each
No Accept: text/htmlScripts rarely request HTML15 pts
Datacenter IPAWS, GCP, Azure, Cloudflare CIDR ranges30 pts
Reverse DNS verifiedReal Googlebot/ClaudeBot resolve to known hostnamesLabelled, not blocked

Score ≥ 70 → bot (gated). Score 40–69 → suspicious (passed through by default). Score < 40 → human. Tune the threshold with botScoreThreshold.

Using the onDetection hook

js
const paywall = createPaywall({
  walletAddress: process.env.SOLANA_WALLET_ADDRESS,
  protect: ["/*"],
  onDetection: (d) => {
    console.log({
      isBot:    d.isBot,
      botName:  d.botName,     // "GPTBot", "ClaudeBot", etc.
      score:    d.score,       // composite score
      signals:  d.signals,     // ["ua:GPTBot(90)", "headers:suspicious(36)", ...]
      ip:       d.ip,
    });
  },
});

Dynamic Pricing

The base price is multiplied by signals from the bot type and content being requested. The final price is returned in the 402 envelope so agents can decide whether to pay before committing.

text
final_price = base × bot_multiplier × content_affinity × exclusivity_mod
Bot / crawlerMultiplierRationale
CCBot (Common Crawl)2.8×Training data — highest commercial value
GPTBot, ClaudeBot2.5×LLM training / inference
MetaAI, CohereBot2.6–2.7×Training data
PerplexityBot2.0×Answer engine
Googlebot, Bingbot1.0×Search index — don't over-price
Unknown1.5×Conservative default

Content type is detected from path patterns (/blog/ = prose,/data/ = dataset) and body signals (code blocks, tables). The 402 response body includes the full score breakdown so agents can inspect it.

Dashboard & Auth

The dashboard shows payments received, top bots, and top paid pages — scoped to your wallet. Authentication uses Sign-In With Solana: you sign a server-issued message, no password required.

Step 1 — Request a nonce

bash
curl -X POST https://ai-paywall-production-f453.up.railway.app/v1/auth/nonce \
  -H "Content-Type: application/json" \
  -d '{"walletAddress": "YourSolanaWallet..."}'

# Response:
# {
#   "token": "<opaque-token>",
#   "message": "ai-paywall.dev wants you to sign in...",
#   "expiresAt": "2026-05-12T12:05:00.000Z"
# }

Step 2 — Sign the message

js
import nacl from "tweetnacl";
import bs58 from "bs58";

// message = the exact string from Step 1
const messageBytes = new TextEncoder().encode(message);
const signature = nacl.sign.detached(messageBytes, keypair.secretKey);
const signatureBase58 = bs58.encode(signature);

Step 3 — Exchange for a session (valid 24h)

bash
curl -X POST https://ai-paywall-production-f453.up.railway.app/v1/auth/verify \
  -H "Content-Type: application/json" \
  -d '{
    "walletAddress": "YourSolanaWallet...",
    "message": "<exact message from Step 1>",
    "signature": "<base58 signature>",
    "token": "<token from Step 1>"
  }'

# Response: { "session": "<token>", "expiresAt": "..." }

Step 4 — Fetch analytics

bash
curl https://ai-paywall-production-f453.up.railway.app/v1/dashboard \
  -H "Authorization: Bearer <session-token>"

# Response:
# {
#   "wallet": { "address": "YourWallet..." },
#   "total": 42,
#   "total_lamports": 56000,
#   "payments": [
#     { "tx": "3jK9...", "botName": "GPTBot", "path": "/article", "lamports": 1000, "timestamp": "..." },
#     ...
#   ]
# }

Check your treasury ATA

bash
# Verify where payments will land before going live
curl "https://ai-paywall-production-f453.up.railway.app/v1/wallet/treasury?walletAddress=YourWallet&network=mainnet-beta"

# Response:
# {
#   "walletAddress": "YourWallet...",
#   "network": "mainnet-beta",
#   "usdcMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
#   "treasuryAta": "7xKpT..."
# }

Environment Variables

bash
# Required
SOLANA_WALLET_ADDRESS=YourSolanaWallet...
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# Recommended
SOLANA_NETWORK=mainnet-beta
SOLANA_RPC_URL=https://mainnet.helius-rpc.com/?api-key=...

# Auth secrets — generate fresh: openssl rand -hex 32
PAYWALL_CHALLENGE_SECRET=<32-byte-hex>
PAYWALL_AUTH_SECRET=<32-byte-hex>
PAYWALL_AUTH_DOMAIN=yourdomain.com

# Optional — enables auto-creation of USDC ATAs for new publisher wallets
FACILITATOR_FEE_PAYER_SECRET_KEY=[...keypair-json-array...]
Never commit .env to source control. Rotate PAYWALL_CHALLENGE_SECRET and PAYWALL_AUTH_SECRET before going to production.