x402 payments are implemented by including payment requirements in an API's 402 response. Prepare Node.js and a test wallet. First, verify v2 signatures and settlement on Base Sepolia. Configure paid responses to be delivered only after settlement succeeds.
Amounts and chain information are based on the official x402, Base, and Circle documentation linked as sources.
Setup and installation
Prepare a development environment where you can modify the API, along with test wallets. The examples use Node.js 22 and x402 SDK 2.28.0. The installation structure follows the official seller guide.
- Prepare an EVM wallet address to receive payments.
- Prepare a separate test wallet for making payments.
- Obtain Base Sepolia test USDC for the payment wallet.
- Create the practice project with the commands below.
- Save the TypeScript files that follow in the same folder.
mkdir x402-demo
cd x402-demo
npm init -y
npm pkg set type=module
npm install --save-exact [email protected] [email protected] @x402/[email protected] @x402/[email protected] @x402/[email protected] @x402/[email protected]
npm install --save-dev --save-exact typescript tsx @types/node @types/express
The receiving server does not need the receiving wallet's private key. Configure the payment private key to be read only by the client process. The wallet signing method follows the official buyer guide.
This example has been checked through type checking and a simulated payment flow. It has not been tested through a completed transfer on the actual testnet. Check the on-chain results yourself using the execution steps below.
Test duplicate-request recovery to ensure payment retries do not cause a second settlement.
Comparing x402 v1 and v2
When implementing v2, align both the headers and the body fields. Changing only the header names will leave the data structures incompatible. The following table is based on the official migration guide.
| Category | v1 | v2 |
|---|---|---|
| Protocol version | x402Version: 1 |
x402Version: 2 |
| Initial payment requirements | JSON body of the 402 response |
PAYMENT-REQUIRED response header |
| Signed payment data |
X-PAYMENT request header |
PAYMENT-SIGNATURE request header |
| Settlement result |
X-PAYMENT-RESPONSE response header |
PAYMENT-RESPONSE response header |
| Base mainnet identifier | base |
eip155:8453 |
| Base Sepolia identifier | base-sepolia |
eip155:84532 |
| Amount field in payment requirements | maxAmountRequired |
amount |
| Resource description |
resource and other fields within payment requirements |
Top-level resource object |
| Requirements in the payment payload | Top-level scheme, network, and other fields |
accepted object containing the selected requirements |
| Example TypeScript package | x402-express |
@x402/express |
The location for delivering v1 payment requirements is specified in the HTTP v1 specification. The standard location in v2 is the PAYMENT-REQUIRED header. An implementation may also return a body, but process the requirements using the header.
The HTTP v2 specification describes the status code as follows.
The server indicates payment is required using the HTTP 402 "Payment Required" status code.
Source: x402 HTTP v2 specification
v2 payment headers contain Base64-encoded JSON. Base64 is not encryption. Use HTTPS for externally accessible services.
HTTP 402 requests and the payment sequence
This exercise uses the default authorization flow for the EVM exact scheme. exact is a scheme for paying a specified amount. The sequence follows the x402 v2 core specification.
- The client requests a paid API without payment information.
- The server returns HTTP 402 and
PAYMENT-REQUIRED. - The client selects an allowed set of requirements from
accepts. - The SDK signs a payment authorization matching the selected requirements.
- The client retries the request with
PAYMENT-SIGNATURE. - The server validates the authorization through the facilitator's
/verify. - If verification passes, the server processes the resource.
- The server requests settlement through
/settle. - After settlement succeeds, it delivers the paid body and
PAYMENT-RESPONSE.
This is not a fixed sequence shared by all payment schemes. v2 also defines flows with different settlement timing. The code below allows only the default authorization flow.
Roles of the Resource server and facilitator
The Resource server determines prices and conditions for accessing content. The Facilitator handles payment verification and settlement. This division of roles follows the official facilitator documentation.
| Component | Responsibility | State that must not be treated as success |
|---|---|---|
| Client | Selects requirements and signs payment authorizations | Only the signature has been generated |
| Resource server | Declares prices and controls paid responses | The client claims success |
Facilitator /verify
|
Checks the validity of signatures and payment requirements | Only isValid: true has been received |
Facilitator /settle
|
Executes the on-chain payment and returns the result in this exercise | Submitted, but the result has not been confirmed |
/verify is a check that does not change on-chain state. Successful verification therefore does not mean funds have been received. The balance or authorization state may change even after verification.
Block the paid body if verification or settlement fails. Blocking access when success cannot be confirmed is called fail-closed behavior. This behavior can also be seen in the official Express implementation.
Comparing Base mainnet and testnet
Use only Base Sepolia for the first run. Circle states that testnet USDC has no monetary value. The addresses are based on Circle's list of USDC contracts.
| Item | Base mainnet | Base Sepolia testnet |
|---|---|---|
| Purpose | Payments with real assets | Testing implementation and failure handling |
| Chain ID | 8453 |
84532 |
| x402 v2 network | eip155:8453 |
eip155:84532 |
| USDC contract | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| Public RPC | https://mainnet.base.org |
https://sepolia.base.org |
| Token's monetary value | Asset with real monetary value | No monetary value |
Check the Chain ID and RPC in the Base network documentation. Even for the same wallet address, balances are separate on different chains. Do not record testnet success as actual revenue.
You can request test USDC from the Circle Testnet Faucet. Select Base Sepolia as the network. Enter the test payment wallet's address as the receiving address.
Implementing a paid API in Node.js
Attach paymentMiddleware before the paid route. The example below requires 0.001 USDC per call. The amount uses the example price from the official seller guide. (Verified value: 0.001 USDC per call · Source: docs.x402.org · Checked on 2026-10-28)
Server file
Save the following code as server.ts. The server accepts requests only on the local interface. It reads the receiving address from the PAY_TO environment variable.
import express from "express";
import { getAddress, zeroAddress } from "viem";
import { paymentMiddleware } from "@x402/express";
import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
const payTo = getAddress(process.env.PAY_TO ?? "");
if (payTo === zeroAddress) throw new Error("Invalid payTo");
const network = "eip155:84532";
const resourceServer = new x402ResourceServer(
new HTTPFacilitatorClient({ url: "https://x402.org/facilitator" }),
).register(network, new ExactEvmScheme());
const app = express();
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "private, no-store");
next();
});
app.use(paymentMiddleware({
"GET /paid-data": {
accepts: [{ scheme: "exact", price: "$0.001", network, payTo }],
description: "Sample paid JSON",
mimeType: "application/json",
},
}, resourceServer));
app.get("/paid-data", (_req, res) => {
res.json({ content: "Paid sample content" });
});
app.listen(4021, "127.0.0.1");
https://x402.org/facilitator is the testing address in the official guide. This example uses it for Base Sepolia settlement. For choosing a production facilitator, see the mainnet steps in the seller guide.
Price calculation example
The example price is 1000 in USDC's smallest units. USDC has 6 decimal places. The SDK's price conversion is based on the EVM server implementation.
0.001 USDC × 10^6 = 1000 smallest units
| Value to check | Expected value in this example |
|---|---|
price in the server configuration |
"$0.001" |
amount in the 402 payment requirements |
"1000" |
asset in the payment requirements |
Base Sepolia USDC contract |
payTo in the payment requirements |
Configured receiving address |
network in the payment requirements |
eip155:84532 |
price is an SDK configuration value. The actual v2 payment requirements carry amount. Compare integer amounts as strings or bigint.
Running the server and checking the 402 response
Run the server in the first Bash terminal. Enter a public receiving address, not a private key. Keep this terminal open while the server is running.
read -r -p '수취 지갑 주소: ' PAY_TO
export PAY_TO
npx tsx server.ts
Send a request without payment from a second terminal. The command below does not sign a payment. It prints the HTTP status and decoded payment requirements.
node --input-type=module <<'JS'
const response = await fetch("http://127.0.0.1:4021/paid-data");
const header = response.headers.get("PAYMENT-REQUIRED");
console.log(response.status);
console.log(header && JSON.parse(
Buffer.from(header, "base64").toString("utf8")
));
JS
The expected response without payment is HTTP 402. Compare the printed requirements with the table above. If paid content is returned immediately, fix the route protection configuration first.
Selecting payment requirements and implementing the client
Restrict the client to signing only approved requirements. This example allows only a fixed price in Base Sepolia USDC. The payment option filter uses registerPolicy from the official x402Client implementation.
- Save the code below as
client.ts. - Also save
check-transfer.tsfrom the next section. - Once both files are ready, proceed with the execution steps.
import { writeFile } from "node:fs/promises";
import { getAddress, zeroAddress, type Hex, type Hash } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { decodePaymentResponseHeader } from "@x402/core/http";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { checkTransfer } from "./check-transfer.js";
const key = process.env.EVM_PRIVATE_KEY ?? "";
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) throw new Error("Invalid test key");
const signer = privateKeyToAccount(key as Hex);
const payTo = getAddress(process.env.PAY_TO ?? "");
if (payTo === zeroAddress) throw new Error("Invalid payTo");
const network = "eip155:84532";
const token = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const client = new x402Client().register(network, new ExactEvmScheme(signer));
client.registerPolicy((version, options) => version === 2
? options.filter(r =>
r.scheme === "exact" && r.network === network &&
r.asset.toLowerCase() === token.toLowerCase() &&
r.payTo.toLowerCase() === payTo.toLowerCase() && r.amount === "1000" &&
(!r.extra?.assetTransferMethod || r.extra.assetTransferMethod === "eip3009") &&
(!r.extra?.paymentFlow || r.extra.paymentFlow === "authorization"))
: []);
const paidFetch = wrapFetchWithPayment(fetch, client);
const response = await paidFetch("http://127.0.0.1:4021/paid-data", {
redirect: "error",
});
const raw = response.headers.get("PAYMENT-RESPONSE");
if (!raw) throw new Error(`No settlement receipt: HTTP ${response.status}`);
const settlement = decodePaymentResponseHeader(raw);
await writeFile("settlement.json", JSON.stringify(settlement, null, 2));
if (!response.ok || !settlement.success || settlement.network !== network) {
throw new Error("Payment unresolved; inspect settlement.json before retrying");
}
const evidence = await checkTransfer(
network, settlement.transaction as Hash, signer.address, payTo, 1000n,
);
await writeFile("transfer.json", JSON.stringify(evidence, null, 2));
console.log(await response.text());
wrapFetchWithPayment handles the 402 response and retries the request with a signature. Its usage follows the official fetch example. If no payment requirements are allowed, payment creation stops.
AI agents can also use this client. However, a per-call price limit is not a cumulative spending limit. Manage the budget for an agent's repeated calls separately.
Verifying USDC Transfer events
Check the USDC transfer logs using the transaction hash from the settlement result. A successful transaction status alone does not confirm the recipient. The Transfer fields follow the ERC-20 standard.
Verification requires the following items.
| Verification item | Compare against |
|---|---|
| RPC's Chain ID | Chain allowed by the server |
Transaction receipt's status
|
Success status |
| Address that emitted the event | Official USDC contract on that chain |
Transfer.from |
Verified payer |
Transfer.to |
Receiving address set by the server |
Transfer.value |
Amount required by the server in the smallest units |
Save the code below as check-transfer.ts. The lookup uses viem's getTransactionReceipt. Event parsing is based on parseEventLogs.
import {
createPublicClient, http, getAddress, parseEventLogs,
erc20Abi, type Address, type Hash,
} from "viem";
import { base, baseSepolia } from "viem/chains";
const configs = {
"eip155:84532": {
chain: baseSepolia,
rpc: "https://sepolia.base.org",
token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
},
"eip155:8453": {
chain: base,
rpc: "https://mainnet.base.org",
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
},
} as const;
export async function checkTransfer(
network: keyof typeof configs, hash: Hash,
payer: Address, payTo: Address, amount: bigint,
) {
const cfg = configs[network];
const rpc = createPublicClient({ chain: cfg.chain, transport: http(cfg.rpc) });
if (await rpc.getChainId() !== cfg.chain.id) throw new Error("Wrong chain");
const receipt = await rpc.getTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Transaction failed");
const logs = parseEventLogs({
abi: erc20Abi,
eventName: "Transfer",
logs: receipt.logs.filter(
log => getAddress(log.address) === getAddress(cfg.token),
),
strict: true,
});
const transfer = logs.find(log =>
getAddress(log.args.from) === getAddress(payer) &&
getAddress(log.args.to) === getAddress(payTo) &&
log.args.value === amount,
);
if (!transfer) throw new Error("Matching USDC Transfer not found");
return {
network, transaction: hash, logIndex: transfer.logIndex,
blockHash: receipt.blockHash, blockNumber: receipt.blockNumber.toString(),
};
}
This function confirms that a USDC transfer occurred in the transaction. Whether it belongs to a specific API order must be checked separately. On a production server, use the stored order requirements as the comparison values.
Do not trust a client-uploaded transfer.json as proof for granting access. This example's server uses the facilitator's verification and settlement results. If an independent lookup is a condition for granting access, it must run on the server.
A failed receipt lookup does not necessarily mean the payment failed. If an RPC error occurs, look up the stored transaction hash again. Set the number of additional confirmations and the response to chain reorganizations through your production policy.