writing/tutorial/2026/08
TutorialAug 4, 2026·26 min read

Monetizing a Next.js API for AI Agents with x402 and Stablecoin Payments

Learn how to charge AI agents per API call using the x402 protocol. This tutorial covers the HTTP 402 payment flow, protecting Next.js route handlers with withX402, building an agent buyer that pays automatically, exposing paid endpoints through MCP, and shipping to Base mainnet.

API keys were designed for humans. A developer signs up, reads the pricing page, enters a credit card, copies a secret into a .env file, and from then on the key identifies who is calling. That entire ritual assumes there is a person at the other end who can complete a signup form.

AI agents break that assumption. An agent that discovers your weather API at runtime cannot create an account, cannot pass KYC, and cannot wait three days for your sales team to reply. It can, however, sign a payment and retry a request in about one second.

x402 is the open protocol that makes this work. It revives the long-dormant HTTP 402 Payment Required status code and turns it into a real payment layer: the server answers an unpaid request with 402 plus machine-readable payment requirements, the client signs a stablecoin transfer, and the request is retried with the signature attached. No accounts, no sessions, no API keys.

This tutorial builds both halves — the seller (a Next.js API that charges per call) and the buyer (an agent that pays without asking anyone) — and then wires the paid endpoint into an MCP server so Claude can use it as a tool.

Version note: everything here targets x402 v2, which introduced the PAYMENT-SIGNATURE and PAYMENT-RESPONSE headers, CAIP-2 network identifiers, and the scoped @x402/* package family. If you are looking at older tutorials using x402-next (unscoped) and a X-PAYMENT header, that is v1 — the migration is small but the APIs are not interchangeable.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and pnpm (or npm)
  • Next.js 15 or 16 with the App Router and TypeScript
  • Comfort with async/await, route handlers, and middleware
  • A basic understanding of what a wallet address is — you do not need to have written a smart contract
  • About 5 USDC on Base Sepolia testnet (free from any Base faucet) for the buyer side
  • Optionally, Claude Desktop or another MCP client for the final step

You do not need a Coinbase account, a Coinbase Developer Platform key, or any Coinbase product. x402 is Apache-2.0 licensed and the testnet facilitator at https://x402.org/facilitator is open to anyone.

What You'll Build

By the end of this tutorial you will have:

  1. A shared resource server configuration that knows how to verify and settle payments
  2. A Next.js route handler protected by withX402 that costs $0.001 per call
  3. A middleware-based proxy that prices several routes at once, including a premium tier
  4. A buyer client — an autonomous script that hits the endpoint, gets a 402, signs, and retries
  5. An MCP server that exposes the paid API as a Claude tool, with spend limits
  6. A checklist for moving from Base Sepolia to Base mainnet

Step 1: Understanding the 402 Handshake

Before writing code, it helps to know exactly what travels over the wire. The full flow has six moves:

  1. The client requests GET /api/weather with no payment.
  2. The server responds 402 Payment Required with a PAYMENT-REQUIRED header containing Base64-encoded JSON. Inside is an accepts array — one entry per payment option the server will take (network, asset, price, destination address, scheme).
  3. The client picks an entry it can satisfy, builds a payment payload for that entry's scheme, and signs it with its wallet key. For the exact scheme on an EVM chain this is an EIP-3009 TransferWithAuthorization signature — an off-chain authorization to move a precise amount of USDC, requiring no prior on-chain approval from the buyer.
  4. The client retries the identical request, now carrying a PAYMENT-SIGNATURE header with the Base64-encoded signed payload.
  5. The server hands the payload to a facilitator — a service that verifies the signature matches the requirements (POST /verify) and then broadcasts the transfer on-chain (POST /settle). The facilitator never custodies funds; it only relays a signature the buyer already produced. A facilitator that tampers with the amount produces an invalid signature and the transfer fails.
  6. The server returns 200 OK with the resource, plus a PAYMENT-RESPONSE header holding the Base64-encoded settlement receipt.

Three properties fall out of this design and are worth internalising:

  • It is stateless. The server stores nothing about the buyer between calls. There is no session, no rate-limit bucket keyed to an account, no user table.
  • The wallet address is the identity. If you want per-caller analytics or allowlists, the payer address is what you key on.
  • Settlement is a push and is irreversible. The exact scheme has no chargebacks. Refunds, if you offer them, are a second transfer you initiate from business logic.

Step 2: Project Setup

Start from a Next.js app with the App Router. Install the seller-side packages:

pnpm add @x402/next @x402/core @x402/evm

The split is deliberate: @x402/core holds the protocol machinery, @x402/evm implements the exact scheme for EVM chains, and @x402/next provides the Next.js bindings. If you also want to accept Solana payments, add @x402/svm.

Now you need a wallet address to receive payments. For the seller you only need the address — the public one, safe to commit to .env.local and even to expose in a client bundle. The seller never signs anything, so no private key is required on your server.

If you do not already have an address, generate a throwaway keypair for testnet:

node -e "const {generatePrivateKey,privateKeyToAccount}=require('viem/accounts');const k=generatePrivateKey();console.log('PRIVATE_KEY=',k);console.log('ADDRESS=',privateKeyToAccount(k).address)"

Add the address to .env.local:

# .env.local
NEXT_PUBLIC_EVM_ADDRESS=0xYourReceivingAddressHere

Warning: the private key printed above belongs to the buyer side later in this tutorial and must never reach a browser bundle or a git commit. Only the address is public.

Step 3: Configure the Resource Server

Every protected route shares one x402ResourceServer instance. It bundles the facilitator client and the payment schemes you accept. Create x402.ts at the project root:

// x402.ts
import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
 
// Testnet facilitator — free, open, no credentials required.
// Swap the URL for a mainnet facilitator when you go live (Step 9).
const facilitatorClient = new HTTPFacilitatorClient({
  url: "https://x402.org/facilitator",
});
 
export const server = new x402ResourceServer(facilitatorClient);
 
// Register the "exact" scheme for every EIP-155 chain.
// The wildcard means Base, Base Sepolia, and any other EVM network
// you later list in a route's accepts array.
server.register("eip155:*", new ExactEvmScheme());
 
export const evmAddress = process.env.NEXT_PUBLIC_EVM_ADDRESS as `0x${string}`;
 
if (!evmAddress) {
  throw new Error("NEXT_PUBLIC_EVM_ADDRESS is not set — payments cannot be received");
}

Two details matter here.

Networks use CAIP-2 identifiers, not friendly names. Base Sepolia is eip155:84532; Base mainnet is eip155:8453. Getting this wrong is the single most common cause of "my signature is rejected" — the chain ID is part of what the buyer signs, so a mismatch invalidates the payload rather than producing a helpful error.

The scheme registration is server-side and takes no signer. Compare this with the buyer in Step 6, where ExactEvmScheme does take a signer. Same class name, opposite import path (/server versus /client), opposite responsibility: the server verifies, the buyer signs.

Step 4: Charge for a Single Route

The cleanest way to price one endpoint is withX402, which wraps a route handler directly:

// app/api/weather/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withX402 } from "@x402/next";
import { server, evmAddress } from "@/x402";
 
const handler = async (req: NextRequest) => {
  const city = req.nextUrl.searchParams.get("city") ?? "Tunis";
 
  // Your real work goes here — a database query, a model call,
  // a third-party API you're reselling.
  const report = await getForecast(city);
 
  return NextResponse.json({ city, report }, { status: 200 });
};
 
export const GET = withX402(
  handler,
  {
    accepts: [
      {
        scheme: "exact",
        price: "$0.001",
        network: "eip155:84532", // Base Sepolia
        payTo: evmAddress,
      },
    ],
    description: "Current weather forecast for a city",
    mimeType: "application/json",
  },
  server,
);

That is the whole integration. An unpaid GET /api/weather now returns 402 with the requirements; a paid one runs handler and returns the forecast.

Why withX402 and not middleware? Because of when settlement happens. withX402 settles the payment only after your handler returns a successful response — status under 400. If getForecast throws, or returns a 503 because the upstream provider is down, the buyer is not charged. Middleware-based interception settles before your handler runs and cannot make that guarantee. For anything that can fail, wrap the handler.

On price format: always use the dollar-prefixed string form, "$0.001". Omitting the $ triggers a validation error rather than being interpreted as a raw token amount. Under the hood this resolves to USDC — six decimals — so $0.001 is 1000 base units. The practical floor is around $0.0001; below that, rounding starts to bite.

Step 5: Price Several Routes at Once

When you have a family of endpoints, declaring accepts on each handler gets repetitive. paymentProxy lets you write the price table once and apply it through Next.js middleware.

Extend x402.ts:

// x402.ts (continued)
import { paymentProxy } from "@x402/next";
 
const priced = (price: string, description: string) => ({
  accepts: [
    {
      scheme: "exact" as const,
      price,
      network: "eip155:84532" as const,
      payTo: evmAddress,
    },
  ],
  description,
  mimeType: "application/json",
});
 
export const proxy = paymentProxy(
  {
    "/api/weather": priced("$0.001", "Current weather forecast"),
    "/api/forecast/extended": priced("$0.01", "14-day extended forecast"),
    "/api/historical": priced("$0.05", "Historical weather archive, per query"),
  },
  server,
);

Then wire it in middleware.ts:

// middleware.ts
export { proxy as middleware } from "@/x402";
 
export const config = {
  matcher: [
    "/api/weather",
    "/api/forecast/:path*",
    "/api/historical/:path*",
  ],
  runtime: "nodejs",
};

Note runtime: "nodejs". Payment verification uses cryptographic primitives that are not available on the Edge runtime, so the matcher must opt into Node.

The pricing tiers above illustrate a pattern worth copying: differentiate by cost to serve, not by an arbitrary plan ladder. A cached current-conditions lookup is nearly free, so charge a tenth of a cent. A historical archive query scans real storage, so charge fifty times more. Because there is no plan to negotiate, agents route to whichever tier their budget supports — you are not forcing a $99/month commitment on a caller that wants nine requests.

Mixing both approaches is fine and often correct: use paymentProxy for cheap, reliable, read-only endpoints, and withX402 for expensive ones where you want settlement contingent on success. Just make sure a route is not covered by both, or the buyer pays twice.

Step 6: Build the Buyer

Now the other side. Create a separate small project — or a scripts/ folder inside the same repo — for the agent that consumes the API:

pnpm add @x402/fetch @x402/core @x402/evm viem dotenv

The buyer needs a signer, so this is where the private key lives. Put it in the buyer's own .env, never in the Next.js app:

# buyer/.env
EVM_PRIVATE_KEY=0xthe_key_you_generated_in_step_2
RESOURCE_SERVER_URL=http://localhost:3000

The client setup mirrors the server, with a signer attached:

// buyer/client.ts
import { wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch";
import { x402Client } from "@x402/core/client";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
import { config } from "dotenv";
 
config();
 
const signer = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`);
 
const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(signer));
 
export const fetchWithPayment = wrapFetchWithPayment(fetch, client);
export const httpClient = new x402HTTPClient(client);
export const payerAddress = signer.address;

And the call itself looks like ordinary fetch:

// buyer/main.ts
import { fetchWithPayment, httpClient, payerAddress } from "./client";
 
const base = process.env.RESOURCE_SERVER_URL!;
 
async function main() {
  console.log("Paying from", payerAddress);
 
  const response = await fetchWithPayment(`${base}/api/weather?city=Tunis`, {
    method: "GET",
  });
 
  const result = await httpClient.processResponse(response);
 
  console.log("Data:", result.body);
 
  if (result.paymentStatus === "settled") {
    console.log("Settlement receipt:", result.header);
  }
}
 
main().catch((error) => {
  console.error("Request failed:", error);
  process.exit(1);
});

Run it and you will see a single log line, but four HTTP-level events happened: the initial request, the 402, the signing, and the retry. wrapFetchWithPayment absorbs all of it. processResponse then decodes the PAYMENT-RESPONSE header into a settlement receipt — transaction hash, amount, network — which is what you would persist for accounting.

Where the money moves: the buyer signed an EIP-3009 authorization; the facilitator submitted it and paid the gas. On Base L2 that gas is roughly $0.001, absorbed by the facilitator, and Coinbase's facilitator currently charges zero on top of it. The buyer's USDC lands in the seller's wallet in about a second, with no intermediary holding it in between.

Step 7: Enforce Spend Limits

An agent with a private key and a while loop is a way to lose money quickly. Never ship a buyer without a ceiling.

The first control is structural: fund the agent wallet with only what it may spend. A hot wallet holding $20 of USDC has a hard, unbypassable cap of $20 regardless of any bug in your code. Top it up on a schedule from a treasury wallet the agent cannot reach. This one measure outperforms every software guard because it does not depend on your software being correct.

The second is a per-process budget. Wrap the paying fetch:

// buyer/budget.ts
import { fetchWithPayment, httpClient } from "./client";
 
const BUDGET_USD = Number(process.env.SESSION_BUDGET_USD ?? "0.50");
const MAX_PER_CALL_USD = Number(process.env.MAX_PER_CALL_USD ?? "0.01");
 
let spent = 0;
 
export class BudgetExceededError extends Error {}
 
export async function paidFetch(url: string, init?: RequestInit) {
  if (spent >= BUDGET_USD) {
    throw new BudgetExceededError(
      `Session budget of $${BUDGET_USD} exhausted after $${spent.toFixed(4)}`,
    );
  }
 
  // Probe first: an unpaid request returns the price without committing to it.
  const probe = await fetch(url, init);
 
  if (probe.status === 402) {
    const requirements = httpClient.parsePaymentRequired(probe);
    const quoted = Math.max(
      ...requirements.accepts.map((a) => Number(String(a.price).replace("$", ""))),
    );
 
    if (quoted > MAX_PER_CALL_USD) {
      throw new BudgetExceededError(
        `Endpoint quoted $${quoted}, above the per-call cap of $${MAX_PER_CALL_USD}`,
      );
    }
 
    if (spent + quoted > BUDGET_USD) {
      throw new BudgetExceededError(
        `Call would cost $${quoted}, exceeding the remaining budget`,
      );
    }
 
    spent += quoted;
  }
 
  return fetchWithPayment(url, init);
}
 
export const spentSoFar = () => spent;

The probe costs one extra round trip, which is a fair trade for knowing the price before authorising it. Without it, an agent will happily sign whatever a malicious or misconfigured server quotes — and a server can quote anything.

Three more guards worth adding in production:

  • Log every settlement. Store the transaction hash, amount, endpoint, and timestamp. Because settlement is on-chain and irreversible, your logs are the only place the reason for a spend exists.
  • Allowlist destinations. An agent that follows links can be steered toward an attacker's endpoint. Constrain which hosts paidFetch will pay.
  • Cache aggressively. The cheapest payment is one you do not make. A 60-second cache on a weather endpoint eliminates most of the spend in a chatty agent loop.

Step 8: Expose the Paid API to Claude via MCP

The point of all this is agents, so let's make one use it. An MCP server can wrap the paid endpoint and present it to Claude as an ordinary tool — the model never sees the payment at all.

pnpm add @modelcontextprotocol/sdk @x402/axios @x402/evm axios viem dotenv
// mcp/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { x402Client, wrapAxiosWithPayment } from "@x402/axios";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
import axios from "axios";
import { config } from "dotenv";
 
config();
 
const baseURL = process.env.RESOURCE_SERVER_URL ?? "http://localhost:3000";
const signer = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`);
 
const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(signer));
 
const api = wrapAxiosWithPayment(axios.create({ baseURL, timeout: 15_000 }), client);
 
const server = new McpServer({ name: "paid-weather", version: "1.0.0" });
 
server.tool(
  "get_weather",
  "Get the current weather for a city. Each call costs $0.001 in USDC.",
  { city: { type: "string", description: "City name, e.g. Tunis" } },
  async ({ city }) => {
    try {
      const res = await api.get("/api/weather", { params: { city } });
      return { content: [{ type: "text", text: JSON.stringify(res.data) }] };
    } catch (error) {
      const message = axios.isAxiosError(error)
        ? `Weather lookup failed (${error.response?.status ?? "network"}): ${error.message}`
        : String(error);
      return { content: [{ type: "text", text: message }], isError: true };
    }
  },
);
 
await server.connect(new StdioServerTransport());

Register it with Claude Desktop:

{
  "mcpServers": {
    "paid-weather": {
      "command": "pnpm",
      "args": ["--silent", "-C", "/absolute/path/to/mcp", "dev"],
      "env": {
        "EVM_PRIVATE_KEY": "0xyour_testnet_key",
        "RESOURCE_SERVER_URL": "http://localhost:3000"
      }
    }
  }
}

Restart Claude Desktop, ask it about the weather in Tunis, and it will call the tool. Behind that single tool call: a 402, an EIP-3009 signature, an on-chain USDC transfer, and a settled receipt — none of which the model reasoned about.

Mentioning the cost in the tool description is deliberate. It gives the model the information it needs to avoid gratuitous repeat calls, and it costs you nothing.

Step 9: Going to Mainnet

Moving from Base Sepolia to Base mainnet is a small diff and a large change in consequences. The checklist:

Change the network identifier everywhere it appears — eip155:84532 becomes eip155:8453. Because it is signed data, a stale testnet ID produces rejected payments rather than a clear error.

Point at a mainnet facilitator. The https://x402.org/facilitator endpoint is testnet only. Coinbase Developer Platform runs a production facilitator at https://api.cdp.coinbase.com/platform/v2/x402 with fee-free settlement on Base and Solana; PayAI runs an alternative covering Base, Solana, and Polygon. The facilitator is a swappable dependency — it never holds funds — so switching later is cheap.

Move the receiving address off a hot key. The seller address only receives, so it can be a hardware wallet, a multisig, or an exchange deposit address. There is no reason for it to be a key sitting in .env.local.

Fund the buyer with mainnet USDC and a little ETH. Testnet USDC is worthless on mainnet, and buyers need a small ETH balance for edge cases where they submit their own transactions.

Confirm your pricing survives contact with reality. On testnet, $0.05 per query is a number in a config file. On mainnet it is money moving irreversibly, at whatever volume an agent decides to generate. Model the case where a single caller sends ten thousand requests in an hour: is that revenue you are glad to have, or infrastructure you cannot afford to serve?

Environment-based config keeps this manageable:

// x402.ts
const IS_PRODUCTION = process.env.NODE_ENV === "production";
 
export const NETWORK = IS_PRODUCTION ? "eip155:8453" : "eip155:84532";
 
const facilitatorClient = new HTTPFacilitatorClient({
  url: IS_PRODUCTION
    ? "https://api.cdp.coinbase.com/platform/v2/x402"
    : "https://x402.org/facilitator",
});

Testing Your Implementation

Check the 402 shape first. Before any wallet is involved, confirm the server is speaking the protocol:

curl -i http://localhost:3000/api/weather?city=Tunis

You want HTTP/1.1 402 Payment Required and a PAYMENT-REQUIRED header. Decode it to see the requirements the buyer will act on:

curl -sI http://localhost:3000/api/weather | grep -i payment-required | cut -d' ' -f2 | base64 -d | jq

Verify that network, payTo, and price are what you intended. A payTo of undefined means your env var did not load.

Then run the buyer end to end and check the transaction on BaseScan for Sepolia. Search the payer address; the USDC transfer should be visible within seconds. This is the only proof that settlement actually happened rather than merely being reported.

Test the failure path. Make your handler throw and confirm the buyer is not charged when using withX402. This is the guarantee you chose withX402 for, so verify it holds rather than assuming.

Test the budget guard. Set SESSION_BUDGET_USD=0.002 and loop; the third call should raise BudgetExceededError instead of spending.

Troubleshooting

Still getting 402 after attaching PAYMENT-SIGNATURE. Almost always one of three things: the chain ID in the signature does not match the one in the requirements; the signed amount is less than the required amount; or the payer wallet has insufficient USDC. The server's JSON body carries an error field naming which — read it before guessing.

"Works on Sepolia, fails on mainnet." You changed the facilitator URL but not the network ID, or the reverse. They must move together. Also confirm the wallet holds mainnet USDC — the testnet balance does not carry over.

Edge runtime errors in middleware. Payment verification needs Node crypto. Add runtime: "nodejs" to your middleware config export.

Buyer signs but nothing settles. Check that the facilitator URL is reachable from your server, not just from your laptop. In a containerised deployment, outbound egress to the facilitator is a dependency you must explicitly allow.

Prices rejected as invalid. The $ prefix is mandatory. "0.001" is a validation error, not a synonym for "$0.001".

Double charging. A route covered by both paymentProxy middleware and withX402 will demand two payments. Pick one per route.

Next Steps

  • Add usage analytics keyed on payer address — with no accounts, the wallet address is your only caller dimension, and it is a good one.
  • Explore the upto scheme now in development, which settles the final amount based on measured usage (tokens generated, megabytes transferred) rather than a flat price agreed up front.
  • Combine x402 with Web Bot Auth and RFC 9421 so you can both identify and charge agent traffic.
  • Read the MCP server tutorial if Step 8 moved faster than you would like.
  • Review AI agent guardrails — an agent that can spend money makes prompt injection a financial problem, not only an informational one.

Conclusion

x402 removes the signup form from the middle of API commerce. A seller declares a price in a route config; a buyer signs and retries; settlement lands in about a second with near-zero fees. Neither side maintains an account for the other.

For anyone shipping APIs into an agent-heavy internet, that changes the economics of the long tail. Endpoints that could never justify a $29/month plan — a single lookup, one document conversion, one region's data — become viable at a tenth of a cent per call, because the cost of transacting finally fell below the value of a single request.

The engineering is genuinely small: one shared server config, one wrapper per route, one wrapped fetch on the buyer. The discipline is where the work is. Fund agent wallets with only what they may lose, probe prices before authorising them, log every settlement, and remember that on-chain payments do not come back. Get those right and you can hand an agent a wallet without lying awake about it.