tollgate-agent-sdk

Drop-in fetch() replacement for AI agents. Automatically detects, pays, and retries HTTP 402 paywalls. USDC settlement on Solana with configurable per-request and lifetime budget caps.

Overview

Replace fetch() with client.fetch(). On a 200 response, it's a pure passthrough. On a 402, the SDK:

  1. Parses the x402 envelope and challenge token
  2. Validates it against your safety policy (network, mint, amount, recipient)
  3. Builds, signs, and submits a USDC SPL transfer on Solana
  4. Retries the original request with X-PAYMENT and x-paywall-challenge
  5. Returns the unlocked Response with res.paywallPayment attached
Your policy is enforced client-side before anything is signed. A malicious or misconfigured server cannot drain your wallet — all checks happen before the transaction is built.

Installation

bash
npm install tollgate-agent-sdk \
  @solana/web3.js \
  @solana/spl-token \
  @x402-solana/core

The Solana and x402 packages are peer dependencies — your project stays in control of versions.

Fund your agent wallet

The agent needs SOL (for transaction fees) and USDC (for payments).

bash
# Devnet SOL (for fees)
solana airdrop 2 --url devnet

# Devnet USDC — get from Circle faucet:
# https://faucet.circle.com
# Mint: 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU

# Check balances
solana balance --url devnet
spl-token balance 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU --url devnet

Quick Start

js
import { createAgentPaywallClient, fromKeypairFile } from "tollgate-agent-sdk";

const client = createAgentPaywallClient({
  network: "devnet",
  signer: fromKeypairFile(),       // reads ~/.config/solana/id.json
  maxAmountMicroUsdc: 10_000,      // hard cap: $0.01 per request
  maxTotalMicroUsdc: 1_000_000,    // session budget: $1.00 total
});

// Drop-in fetch — 402s paid automatically
const res = await client.fetch("https://publisher.com/articles/ai-trends");
const data = await res.json();

// Receipt attached if a payment was made
if (res.paywallPayment) {
  console.log("paid:", res.paywallPayment.signature);
  console.log("amount:", res.paywallPayment.amountMicroUsdc, "micro-USDC");
}

// Running spend total
console.log(client.spend());
// { totalMicroUsdc: 1000, count: 1, payments: [...] }

Signers

The SDK needs to sign Solana transactions. Pick the helper that matches your setup.

Keypair file (Solana CLI default)

js
import { fromKeypairFile } from "tollgate-agent-sdk";

signer: fromKeypairFile()                          // ~/.config/solana/id.json
signer: fromKeypairFile("/path/to/keypair.json")   // custom path

JSON array (solana-keygen format)

js
import { fromSecretKeyArray } from "tollgate-agent-sdk";

const arr = JSON.parse(fs.readFileSync("keypair.json", "utf8"));
signer: fromSecretKeyArray(arr)

Base58 secret key (from env var)

js
import { fromSecretKeyBase58 } from "tollgate-agent-sdk";

signer: fromSecretKeyBase58(process.env.AGENT_WALLET_SECRET)

Existing @solana/web3.js Keypair

js
import { fromKeypair } from "tollgate-agent-sdk";
import { Keypair } from "@solana/web3.js";

signer: fromKeypair(Keypair.generate())

Custom signer (HSM / KMS / browser wallet)

js
const signer = {
  publicKey: myPublicKey,              // @solana/web3.js PublicKey
  async signTransaction(tx) {          // sign in place, return tx
    await myKms.sign(tx);
    return tx;
  },
};

signer: signer

Configuration

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

OptionDefaultDescription
signerrequiredKeypair or custom signer. See Signers section.
network"devnet""devnet", "mainnet-beta", or "testnet".
rpcUrlpublic RPCOverride Solana RPC endpoint. Use a paid RPC in production.
maxAmountMicroUsdcunlimitedHard per-request cap. SDK refuses any 402 that asks for more.
maxTotalMicroUsdcunlimitedLifetime budget for this client instance. Throws when exceeded.
allowedMintsanyRestrict acceptable USDC mint addresses.
allowedRecipientsanyRestrict acceptable payTo ATAs.
autoPaytrueIf false, 402s pass through unchanged — SDK will not pay.
userAgentagent-sdk/0.1User-Agent sent with all requests.
confirmCommitment"confirmed"Solana commitment level for transaction confirmation.
onChallenge(info)Hook called before each payment. Return false to refuse.
onPayment(info)Hook called after each successful payment.

Safety Guards

Every check runs client-side before any transaction is built. Violations throw typed errors immediately — no SOL or USDC leaves the wallet.

What is checkedHow to configure
Network mismatch (mainnet claim on devnet)Automatic — always enforced
Amount exceeds per-request capmaxAmountMicroUsdc
Cumulative spend exceeds session budgetmaxTotalMicroUsdc
Asset mint not in allowlistallowedMints
Recipient ATA not in allowlistallowedRecipients
Programmatic approvalonChallenge — return false to block
Insufficient USDC balanceAutomatic — checked before transaction build

Hooks

onChallenge — approve or refuse before paying

js
const client = createAgentPaywallClient({
  // ...
  onChallenge: async ({ url, amountMicroUsdc, envelope }) => {
    console.log(`About to pay ${amountMicroUsdc} µUSDC for ${url}`);

    // Return false to refuse — no payment made, no retry
    if (amountMicroUsdc > 5_000) return false;

    return true; // undefined also means proceed
  },
});

onPayment — record after each payment

js
const client = createAgentPaywallClient({
  // ...
  onPayment: async ({ url, signature, amountMicroUsdc, payTo, network }) => {
    // Persist to your own DB, send to analytics, etc.
    await db.insert({ url, signature, amountMicroUsdc, ts: new Date() });
  },
});

Errors thrown inside onPayment are silently swallowed — they won't break the response flow.

Error Handling

All SDK errors extend PaywallError and carry a .code string.

js
import {
  PaymentRefusedError,
  PaymentBudgetExceededError,
  UnsupportedChallengeError,
  OnChainError,
  VerificationRejectedError,
} from "tollgate-agent-sdk";

try {
  const res = await client.fetch("https://publisher.com/article");
} catch (err) {
  if (err instanceof PaymentRefusedError) {
    // Policy refused: wrong network, mint, recipient, or amount too high.
    // Do NOT retry — your policy explicitly blocked this.

  } else if (err instanceof PaymentBudgetExceededError) {
    // Lifetime budget exhausted. Create a new client or stop.

  } else if (err instanceof UnsupportedChallengeError) {
    // The 402 was malformed or uses an unsupported payment scheme.

  } else if (err instanceof OnChainError) {
    // RPC failure, insufficient balance, or tx rejected on-chain.

  } else if (err instanceof VerificationRejectedError) {
    // Payment submitted on-chain but server returned 402/403 anyway.
    // Funds were spent. Investigate before retrying.
    console.error("sig:", err.details.signature);

  } else {
    throw err;
  }
}
Error classCodeWhen it throws
PaymentRefusedErrorPAYMENT_REFUSEDNetwork/mint/recipient/amount violates your policy
PaymentBudgetExceededErrorBUDGET_EXCEEDEDmaxTotalMicroUsdc would be exceeded
UnsupportedChallengeErrorUNSUPPORTED_CHALLENGE402 is malformed or unsupported scheme
OnChainErrorON_CHAIN_ERRORRPC, balance, or confirmation failure
VerificationRejectedErrorVERIFICATION_REJECTEDServer rejected payment after on-chain confirmation

LangChain Integration

js
import { createAgentPaywallClient, fromKeypairFile } from "tollgate-agent-sdk";
import { paywallFetchTool } from "tollgate-agent-sdk/langchain";
import { createOpenAIToolsAgent, AgentExecutor } from "langchain/agents";
import { ChatOpenAI } from "@langchain/openai";

const client = createAgentPaywallClient({
  network: "mainnet-beta",
  signer: fromKeypairFile(),
  maxAmountMicroUsdc: 5_000,
});

const tool = paywallFetchTool(client, {
  allowHost: (host) => host.endsWith("trusted-publisher.com"),
});

const llm = new ChatOpenAI({ model: "gpt-4o" });
const agent = await createOpenAIToolsAgent({ llm, tools: [tool], prompt });
const executor = AgentExecutor.fromAgentAndTools({ agent, tools: [tool] });

const result = await executor.invoke({
  input: "Fetch the article at https://trusted-publisher.com/articles/ai-2026",
});

OpenAI function-calling (manual)

js
const tool = paywallFetchTool(client);

// tool.name        → "paywall_fetch"
// tool.description → natural language description for the model
// tool.schema      → JSON Schema of arguments ({ url, method?, headers? })
// tool.invoke(args) → Promise<string> (response body as text)

// Register with OpenAI:
const functions = [{
  name: tool.name,
  description: tool.description,
  parameters: tool.schema,
}];

// On tool call:
const result = await tool.invoke({ url: "https://..." });

Concurrency & Idempotency

Concurrent client.fetch() calls to the same URL+nonce coalesce automatically. If ten parallel requests all receive the same 402, only one payment is submitted. All ten callers receive the unlocked response.

js
// Safe — only ONE payment is sent for these concurrent calls
const [a, b, c] = await Promise.all([
  client.fetch("https://publisher.com/article"),
  client.fetch("https://publisher.com/article"),
  client.fetch("https://publisher.com/article"),
]);

// All three responses are the same unlocked content
// client.spend().count === 1
Create separate client instances if you need independent payment tracking per caller or per-agent budget isolation.

Spend Tracking

js
const stats = client.spend();
// {
//   totalMicroUsdc: 4500,    // total spent this session
//   count: 3,                // number of payments made
//   payments: [
//     {
//       signature:      "3jK9...",
//       amountMicroUsdc: 1000,
//       url:             "https://publisher.com/article",
//     },
//     ...
//   ]
// }

The spend tracker resets when the client is re-created (i.e. per Node.js process). Use onPayment to persist receipts across restarts.

res.paywallPayment shape

js
// Available on the Response object after a successful payment
res.paywallPayment = {
  url:             "https://publisher.com/article",
  signature:       "3jK9xZ...",      // Solana tx signature
  amountMicroUsdc: 1000,             // micro-USDC paid (1000 = $0.001)
  payTo:           "7xKpT...",       // recipient's USDC ATA
  asset:           "EPjFW...",       // USDC mint address
  network:         "mainnet-beta",
  challengeToken:  "tok_9fK2...",
}

// Note: paywallPayment is non-enumerable — won't appear in JSON.stringify(res)