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:
- Parses the x402 envelope and challenge token
- Validates it against your safety policy (network, mint, amount, recipient)
- Builds, signs, and submits a USDC SPL transfer on Solana
- Retries the original request with
X-PAYMENTandx-paywall-challenge - Returns the unlocked
Responsewithres.paywallPaymentattached
Installation
npm install tollgate-agent-sdk \
@solana/web3.js \
@solana/spl-token \
@x402-solana/coreThe 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).
# 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 devnetQuick Start
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)
import { fromKeypairFile } from "tollgate-agent-sdk";
signer: fromKeypairFile() // ~/.config/solana/id.json
signer: fromKeypairFile("/path/to/keypair.json") // custom pathJSON array (solana-keygen format)
import { fromSecretKeyArray } from "tollgate-agent-sdk";
const arr = JSON.parse(fs.readFileSync("keypair.json", "utf8"));
signer: fromSecretKeyArray(arr)Base58 secret key (from env var)
import { fromSecretKeyBase58 } from "tollgate-agent-sdk";
signer: fromSecretKeyBase58(process.env.AGENT_WALLET_SECRET)Existing @solana/web3.js Keypair
import { fromKeypair } from "tollgate-agent-sdk";
import { Keypair } from "@solana/web3.js";
signer: fromKeypair(Keypair.generate())Custom signer (HSM / KMS / browser wallet)
const signer = {
publicKey: myPublicKey, // @solana/web3.js PublicKey
async signTransaction(tx) { // sign in place, return tx
await myKms.sign(tx);
return tx;
},
};
signer: signerConfiguration
Pass these options to createAgentPaywallClient({ ... }).
| Option | Default | Description |
|---|---|---|
signer | required | Keypair or custom signer. See Signers section. |
network | "devnet" | "devnet", "mainnet-beta", or "testnet". |
rpcUrl | public RPC | Override Solana RPC endpoint. Use a paid RPC in production. |
maxAmountMicroUsdc | unlimited | Hard per-request cap. SDK refuses any 402 that asks for more. |
maxTotalMicroUsdc | unlimited | Lifetime budget for this client instance. Throws when exceeded. |
allowedMints | any | Restrict acceptable USDC mint addresses. |
allowedRecipients | any | Restrict acceptable payTo ATAs. |
autoPay | true | If false, 402s pass through unchanged — SDK will not pay. |
userAgent | agent-sdk/0.1 | User-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 checked | How to configure |
|---|---|
| Network mismatch (mainnet claim on devnet) | Automatic — always enforced |
| Amount exceeds per-request cap | maxAmountMicroUsdc |
| Cumulative spend exceeds session budget | maxTotalMicroUsdc |
| Asset mint not in allowlist | allowedMints |
| Recipient ATA not in allowlist | allowedRecipients |
| Programmatic approval | onChallenge — return false to block |
| Insufficient USDC balance | Automatic — checked before transaction build |
Hooks
onChallenge — approve or refuse before paying
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
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.
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 class | Code | When it throws |
|---|---|---|
PaymentRefusedError | PAYMENT_REFUSED | Network/mint/recipient/amount violates your policy |
PaymentBudgetExceededError | BUDGET_EXCEEDED | maxTotalMicroUsdc would be exceeded |
UnsupportedChallengeError | UNSUPPORTED_CHALLENGE | 402 is malformed or unsupported scheme |
OnChainError | ON_CHAIN_ERROR | RPC, balance, or confirmation failure |
VerificationRejectedError | VERIFICATION_REJECTED | Server rejected payment after on-chain confirmation |
LangChain Integration
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)
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.
// 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 === 1Spend Tracking
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
// 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)