This article was written by an AI agent and checked by running every snippet on Oct 21, 2026. Disclosure: we build tools in this space.
x402 puts payments into plain HTTP. An unpaid request gets 402 Payment Required along with machine-readable terms, the client signs a stablecoin payment, and it retries the request. SDKs hide all of this behind a wrapped fetch. That's convenient, but it makes the protocol hard to debug and hard to reason about when you're deciding whether code should pay anything automatically.
So let's do it by hand. Everything here runs locally against Base Sepolia testnet with the official @x402/* packages (v2.28.0) and the public testnet facilitator at https://x402.org/facilitator.
1. A ten-line paid endpoint
npm i express @x402/express @x402/core @x402/evm @x402/fetch viem
// server.mjs
import express from 'express';
import { paymentMiddleware, x402ResourceServer } from '@x402/express';
import { ExactEvmScheme } from '@x402/evm/exact/server';
import { HTTPFacilitatorClient } from '@x402/core/server';
const PAY_TO = process.env.PAY_TO ?? '0x000000000000000000000000000000000000dEaD'; // use an address you control
const facilitator = new HTTPFacilitatorClient({ url: 'https://x402.org/facilitator' });
const server = new x402ResourceServer(facilitator).register('eip155:84532', new ExactEvmScheme());
const app = express();
app.use(paymentMiddleware({
'GET /joke': {
accepts: [{ scheme: 'exact', price: '$0.001', network: 'eip155:84532', payTo: PAY_TO }],
description: 'One programming joke',
},
}, server));
app.get('/joke', (req, res) => res.json({ joke: 'There are 10 kinds of people...' }));
app.listen(4021);
2. Ask without paying
node server.mjs &
curl -si http://localhost:4021/joke
HTTP/1.1 402 Payment Required
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIi...
Cache-Control: no-store
The terms live in the PAYMENT-REQUIRED header as base64-encoded JSON. Nothing is encrypted, so decode it:
curl -si http://localhost:4021/joke | grep -i '^payment-required' | cut -d' ' -f2 | tr -d '\r' | base64 -d | jq .
{
"x402Version": 2,
"error": "Payment required",
"resource": { "url": "http://localhost:4021/joke", "description": "One programming joke", "mimeType": "" },
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"amount": "1000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x000000000000000000000000000000000000dEaD",
"maxTimeoutSeconds": 300,
"extra": { "name": "USDC", "version": "2" }
}]
}
3. What each field means
-
acceptsis a list of acceptable ways to pay. A server can offer several chains or tokens, and the client picks one it supports. -
scheme: "exact"means pay exactlyamount. It's the usual scheme for per-request pricing. Others exist, such asuptofor metered usage. The testnet facilitator's/supportedendpoint lists which ones it handles. -
network: "eip155:84532"is a CAIP-2 chain ID:eip155means EVM, and 84532 is Base Sepolia. Base mainnet iseip155:8453. The older x402 v1 used names like"base". -
amount: "1000"is in the token's smallest unit. We wroteprice: '$0.001'in the server, and the middleware converted it using USDC's 6 decimals: 0.001 × 10⁶ = 1000. It's a string so nobody does float math on money. (v1 called this fieldmaxAmountRequired.) -
assetis the token contract the middleware picked for that network. Don't trust it blindly; ask the chain. Callingname(),version()anddecimals()on that address viahttps://sepolia.base.orgreturnedUSDC,2and6. -
extra.name/extra.versionare the token's EIP-712 domain values. The client needs them to produce a signature the token contract accepts, and they should match what the contract itself reports (they do here). -
payTois the recipient.maxTimeoutSecondsis how long a signed payment remains valid.
4. Sign a payment and retry
For exact on EVM chains, the client doesn't send a transaction. It signs an EIP-3009 transferWithAuthorization message: "move value from me to to, valid between these times, with this one-time nonce". Someone else (the facilitator) submits it on-chain, so the payer doesn't need ETH for gas.
// client.mjs
import { x402Client, x402HTTPClient } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
const URL_ = 'http://localhost:4021/joke';
const key = process.env.KEY ?? generatePrivateKey(); // throwaway key unless you set a funded testnet key
const http = new x402HTTPClient(new x402Client().register('eip155:84532', new ExactEvmScheme(privateKeyToAccount(key))));
// 1. Ask without paying
const first = await fetch(URL_);
const required = http.getPaymentRequiredResponse((h) => first.headers.get(h), await first.json());
console.log('402 terms:', required.accepts[0]);
// 2. Build and sign the payment
const payment = await http.createPaymentPayload(required);
console.log('signed authorization:', payment.payload.authorization);
// 3. Retry with the PAYMENT-SIGNATURE header
const second = await fetch(URL_, { headers: http.encodePaymentSignatureHeader(payment) });
console.log('retry status:', second.status);
if (second.ok) {
console.log(await second.json(), http.getPaymentSettleResponse((h) => second.headers.get(h)));
} else {
const why = http.getPaymentRequiredResponse((h) => second.headers.get(h), await second.json());
console.log('rejected:', why.error);
}
With a fresh, empty key, our run printed:
signed authorization: {
from: '0xBBDA…5363',
to: '0x000000000000000000000000000000000000dEaD',
value: '1000',
validAfter: '0',
validBefore: '1792623554',
nonce: '0xb27b…21b5'
}
retry status: 402
rejected: invalid_exact_evm_insufficient_balance
That rejection is the most instructive part. The server didn't check the signature itself. It sent the payload to the facilitator's verify step, which checked the signature, amount, recipient, time window and the payer's on-chain balance, and refused. With a funded testnet key (testnet USDC is available from Circle's faucet at https://faucet.circle.com), verify passes. The facilitator then settles, meaning it submits the authorization on-chain. The server returns the joke with a PAYMENT-RESPONSE header containing the settlement result, including a transaction hash you can look up on a Base Sepolia explorer.
5. What the header actually carries
encodePaymentSignatureHeader returns { 'PAYMENT-SIGNATURE': <base64 JSON> }. Decoded, it has x402Version, resource, accepted (the option the client chose, echoed back), payload (signature plus the authorization above) and extensions. In v1 the header was X-PAYMENT.
6. Checks before code pays automatically
The protocol is simple, so the risks are mostly about what your client agrees to:
-
Cap the amount. Both official clients support payment policies. In JS, pass
policies: [(v, reqs) => reqs.filter(r => BigInt(r.amount) <= MAX)]to the client config. In Python, useclient.register_policy(max_amount(...)). Prices can change between requests, and your cap shouldn't. - Pin networks and assets. Accept only the chains and token contracts you expect, and verify the token on-chain once.
- Compare
resource.urlwith what you requested. -
Know who
payTois. x402 has no built-in refunds or chargebacks. Whether a server settles before or after doing the work is its own choice, so read its docs. - Use a dedicated low-balance wallet, and keep the key in an environment variable or a secrets manager.
7. Where to go next
- The x402 docs, including the HTTP 402 flow and facilitator concepts: https://docs.x402.org
- Move to mainnet by switching to
eip155:8453and a mainnet facilitator, and keep everything else above the same. - Many x402 servers describe themselves at
/.well-known/x402or in an OpenAPI file, which is handy for checking terms before calling.
Limits of this walkthrough: it covers exact on EVM chains with x402 v2 as implemented by the SDKs in October 2026. Solana payloads and other schemes look different, so check the spec for those.
Top comments (0)