Build an HTTP Paywall from Scratch
Learn how to create a paid API endpoint that requires USDC payment before granting access. This tutorial covers the x402 protocol fundamentals and builds a production-ready paywall.
Time to complete : 15-20 minutesWhat you’ll build : An Express server with a /weather endpoint that requires $0.001 USDC payment
Prerequisites
Node.js Version 18.17.0 or higher
Wallet Address An Ethereum address to receive payments
Base Sepolia RPC Testnet for development (free)
Basic TypeScript Familiarity with Express and async/await
What You’ll Learn
How the x402 protocol works (402 status code + payment headers)
Implementing payment middleware for Express
Handling payment verification and settlement
Testing paywalls with curl and payment clients
Step 1: Project Setup
Create a new project and install dependencies:
Terminal
package.json
tsconfig.json
mkdir weather-paywall
cd weather-paywall
npm init -y
npm install express x402-express dotenv
npm install -D typescript @types/express @types/node ts-node
Step 2: Environment Configuration
Create a .env file with your payment address:
# Your wallet address to receive payments
PAY_TO = 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb
# Server configuration
PORT = 3100
# Network (base-sepolia for testnet)
NETWORK = base-sepolia
Replace PAY_TO with your actual Ethereum address. This is where USDC payments will be sent.
Step 3: Weather Data Service
Create a simple weather service using the Open-Meteo API (no API key required):
interface WeatherData {
city : string ;
temperature : number ;
conditions : string ;
humidity : number ;
timestamp : string ;
}
// Geocoding: city name → lat/lon
async function getCityCoordinates ( city : string ) : Promise <{ lat : number ; lon : number }> {
const url = `https://geocoding-api.open-meteo.com/v1/search?name= ${ encodeURIComponent ( city ) } &count=1&language=en&format=json` ;
const response = await fetch ( url );
const data = await response . json ();
if ( ! data . results || data . results . length === 0 ) {
throw new Error ( `City not found: ${ city } ` );
}
return {
lat: data . results [ 0 ]. latitude ,
lon: data . results [ 0 ]. longitude
};
}
// Weather API: lat/lon → current weather
async function getWeatherByCoords ( lat : number , lon : number ) : Promise < Omit < WeatherData , 'city' >> {
const url = `https://api.open-meteo.com/v1/forecast?latitude= ${ lat } &longitude= ${ lon } ¤t_weather=true&hourly=relative_humidity_2m` ;
const response = await fetch ( url );
const data = await response . json ();
const current = data . current_weather ;
const humidity = data . hourly . relative_humidity_2m [ 0 ];
// Map WMO weather codes to conditions
const weatherCodes : Record < number , string > = {
0 : "Clear sky" ,
1 : "Mainly clear" ,
2 : "Partly cloudy" ,
3 : "Overcast" ,
45 : "Foggy" ,
48 : "Depositing rime fog" ,
51 : "Light drizzle" ,
61 : "Slight rain" ,
63 : "Moderate rain" ,
65 : "Heavy rain" ,
71 : "Slight snow" ,
95 : "Thunderstorm"
};
return {
temperature: current . temperature ,
conditions: weatherCodes [ current . weathercode ] || "Unknown" ,
humidity ,
timestamp: new Date (). toISOString ()
};
}
// Main export: city name → weather data
export async function getWeather ( city : string ) : Promise < WeatherData > {
const coords = await getCityCoordinates ( city );
const weather = await getWeatherByCoords ( coords . lat , coords . lon );
return {
city ,
... weather
};
}
Step 4: Server with x402 Middleware
Create the Express server with payment middleware:
import express , { Request , Response } from "express" ;
import { paymentMiddleware } from "x402-express" ;
import * as dotenv from "dotenv" ;
import { getWeather } from "./weather" ;
dotenv . config ();
const app = express ();
const port = process . env . PORT ? Number ( process . env . PORT ) : 3100 ;
const payTo = process . env . PAY_TO || "0x0000000000000000000000000000000000000000" ;
const network = process . env . NETWORK || "base-sepolia" ;
// Apply payment middleware BEFORE route handlers
app . use ( paymentMiddleware ( payTo , {
"GET /weather" : { price: "$0.001" , network }
}));
// Protected route - only accessible after payment
app . get ( "/weather" , async ( req : Request , res : Response ) => {
try {
const city = req . query . city as string ;
if ( ! city ) {
return res . status ( 400 ). json ({
error: "Missing 'city' query parameter" ,
example: "/weather?city=San%20Francisco"
});
}
// Fetch weather data
const weatherData = await getWeather ( city );
// Return weather info
res . json ({
success: true ,
data: weatherData
});
} catch ( error ) {
res . status ( 500 ). json ({
error: "Failed to fetch weather" ,
message: error instanceof Error ? error . message : String ( error )
});
}
});
// Health check endpoint (free, no payment)
app . get ( "/health" , ( _req : Request , res : Response ) => {
res . json ({
status: "healthy" ,
timestamp: new Date (). toISOString (),
payTo ,
network
});
});
app . listen ( port , () => {
console . log ( `✅ Weather paywall server running on http://localhost: ${ port } ` );
console . log ( `💰 Payments to: ${ payTo } ` );
console . log ( `🌐 Network: ${ network } ` );
console . log ( ` \n Try: curl -i "http://localhost: ${ port } /weather?city=Paris"` );
});
Step 5: Understanding the x402 Flow
When a request hits the /weather endpoint:
Request Without Payment
curl -i -H "Accept: application/json" "http://localhost:3100/weather?city=Paris"
Response: 402 Payment Required HTTP / 1.1 402 Payment Required
Content-Type : application/json
{
"error" : "Payment Required" ,
"accepts" : [
{
"type" : "exact" ,
"network" : "base-sepolia" ,
"chainId" : 84532 ,
"maxAmountRequired" : "1000" ,
"minAmountRequired" : "1000" ,
"payTo" : "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb" ,
"payWith" : "0x036CbD53842c5426634e7929541eC2318f3dCF7e" ,
"resource" : "GET /weather?city=Paris" ,
"facilitator" : "https://x402.org/facilitator"
}
]
}
Client Signs Payment
Using an x402 client (like x402-axios), the client:
Detects the 402 response
Extracts payment requirements from accepts[0]
Signs an EIP-712 message with the payment details
Generates X-PAYMENT header
Retry with Payment Header
curl -i \
-H "Accept: application/json" \
-H "X-PAYMENT: <base64-encoded-signature>" \
"http://localhost:3100/weather?city=Paris"
Middleware Verifies Payment
The paymentMiddleware:
Extracts signature from X-PAYMENT header
Sends to facilitator for verification
Facilitator settles USDC on-chain
Returns transaction hash
Middleware passes request to route handler
Success Response
HTTP / 1.1 200 OK
Content-Type : application/json
X-PAYMENT-RESPONSE : {"success":true,"transaction":"0xabc..."}
{
"success" : true ,
"data" : {
"city" : "Paris" ,
"temperature" : 18.5 ,
"conditions" : "Partly cloudy" ,
"humidity" : 65 ,
"timestamp" : "2025-03-03T10:30:00.000Z"
}
}
Step 6: Testing the Paywall
Start the Server
Test Without Payment (Expect 402)
curl -i -H "Accept: application/json" "http://localhost:3100/weather?city=Tokyo"
You should see:
HTTP/1.1 402 Payment Required
To actually pay and access the endpoint, you need an x402 client. The simplest way is to use the payment header generator from the ping example:
Install x402 Client
Configure Payer
Generate Header
Use Header
cd ..
git clone https://github.com/crossmint/crossmint-agentic-finance.git
cd crossmint-agentic-finance/ping
npm install
Now you should see the weather data!
Step 7: Production Enhancements
Add Rate Limiting
import rateLimit from "express-rate-limit" ;
const limiter = rateLimit ({
windowMs: 15 * 60 * 1000 , // 15 minutes
max: 100 , // 100 requests per window
message: "Too many requests, please try again later"
});
app . use ( "/weather" , limiter );
Add Logging
import morgan from "morgan" ;
app . use ( morgan ( "combined" ));
// Custom payment logging
app . use (( req , res , next ) => {
const paymentHeader = req . headers [ "x-payment" ];
if ( paymentHeader ) {
console . log ( `💰 Payment received for ${ req . method } ${ req . path } ` );
}
next ();
});
Add CORS
import cors from "cors" ;
app . use ( cors ({
origin: process . env . ALLOWED_ORIGINS ?. split ( "," ) || "*" ,
credentials: true
}));
Error Handling
app . use (( err : Error , req : Request , res : Response , next : NextFunction ) => {
console . error ( "Error:" , err );
res . status ( 500 ). json ({
error: "Internal server error" ,
message: process . env . NODE_ENV === "development" ? err . message : undefined
});
});
Step 8: Deploy to Production
# Install Railway CLI
npm i -g @railway/cli
# Login and deploy
railway login
railway init
railway up
# Set environment variables
railway variables set PAY_TO=0xYourAddress
railway variables set NETWORK=base
Push code to GitHub
Create new Web Service on Render
Connect repository
Set build command: npm run build
Set start command: npm start
Add environment variables:
PAY_TO: Your wallet address
NETWORK: base (for mainnet)
# Install Fly CLI
curl -L https://fly.io/install.sh | sh
# Launch app
fly launch
# Set secrets
fly secrets set PAY_TO=0xYourAddress
fly secrets set NETWORK=base
# Deploy
fly deploy
Switching to Mainnet
Before production deployment:
Update network : Change NETWORK=base-sepolia to NETWORK=base
Update USDC address : The middleware will use the correct USDC contract for Base mainnet
Test with small amounts : Start with $0.001 payments to verify everything works
Monitor transactions : Use Basescan to track payments
Advanced: Multiple Endpoints
You can protect multiple endpoints with different prices:
app . use ( paymentMiddleware ( payTo , {
"GET /weather" : { price: "$0.001" , network },
"GET /forecast" : { price: "$0.005" , network },
"GET /alerts" : { price: "$0.010" , network },
"POST /subscribe" : { price: "$1.00" , network }
}));
Troubleshooting
402 response but no payment details
Check that you’re sending Accept: application/json header. Without it, you’ll get an HTML paywall page instead of JSON.
Payment verification failed
Common causes:
Wrong network (testnet vs mainnet)
Insufficient USDC balance
Invalid signature
Facilitator is down
Check the X-PAYMENT-RESPONSE header for error details.
Add CORS middleware: import cors from "cors" ;
app . use ( cors ());
Payments not appearing in wallet
Check the correct network on block explorer
Verify PAY_TO address is correct
Wait for transaction finality (2-3 seconds on Base)
Next Steps
Autonomous Agent Build an AI agent that automatically pays for API access
Durable Objects Learn stateful agent patterns with Cloudflare
x402 Protocol Deep dive into HTTP payment protocol
Smart Wallets Understand Crossmint wallet integration
Full Code
View the complete example: weather-402 on GitHub