Skip to content

Node.js Tutorial

x402 with Express, Next.js and Hono: a practical guide

By Requestway · · 7 min read

The official x402 packages for Node give Express, Next.js and Hono the same payment flow from one shared setup: a resource server that knows your facilitator and networks, plus a thin adapter per framework. This guide builds that setup once, wires it into each framework, calls a paid route from a client, and hooks into the payment outcomes so you can see what happened.

The packages and how they fit together

Version 2 of the protocol is split into small packages. You install the shared pieces once and the adapter for your framework:

Package Role
@x402/core The resource server and the HTTP facilitator client
@x402/evm The exact payment scheme for EVM chains
@x402/express paymentMiddleware(routes, server) for Express
@x402/hono paymentMiddleware(routes, server) for Hono
@x402/next withX402(handler, config, server) for App Router route handlers
@x402/fetch Client side: a fetch wrapper that pays a 402 and retries

The flow is the same whichever adapter you use. An unpaid request gets 402 Payment Required with a PAYMENT-REQUIRED header: base64-encoded JSON listing what the route accepts. The client signs a payment and retries with PAYMENT-SIGNATURE. The server verifies it through the facilitator's /verify endpoint, runs your handler, settles through /settle, and returns 200 with a PAYMENT-RESPONSE receipt. The x402 guide covers the headers in more detail.

Spec and reference SDKs live at github.com/x402-foundation/x402.

One resource server for every framework

The resource server holds the facilitator client and the payment schemes you accept, registered per network. Put it in its own module so every adapter, and later your reporting hooks, use the same instance:

// lib/x402.ts
import { HTTPFacilitatorClient, x402ResourceServer } from '@x402/core/server'
import { ExactEvmScheme } from '@x402/evm/exact/server'

export const server = new x402ResourceServer(new HTTPFacilitatorClient({ url: 'https://x402.org/facilitator' }))
  .register('eip155:84532', new ExactEvmScheme())

Three things to know about this setup:

  • eip155:84532 is Base Sepolia, a testnet. Networks are CAIP-2 ids; Base mainnet is eip155:8453.
  • https://x402.org/facilitator is free and needs no account, but it only works on testnets. Mainnet needs a production facilitator.
  • For EIP-3009 transfers the facilitator submits the transaction and pays the gas, so your server needs no ETH and no private key. Funds go straight from the payer's wallet to your payTo address.

Express

Pass a map of routes to paymentMiddleware. Keys are METHOD /path, and each entry lists the payment options it accepts:

import express from 'express'
import { paymentMiddleware } from '@x402/express'
import { server } from './lib/x402.js'

const app = express()

app.use(
  paymentMiddleware(
    {
      'GET /premium-data': {
        accepts: [{ scheme: 'exact', price: '$0.01', network: 'eip155:84532', payTo: '0xYourAddress' }],
        description: 'Premium market data',
      },
    },
    server,
  ),
)

app.get('/premium-data', (req, res) => res.json({ price: 42 }))
app.listen(3000)

The price is written in dollars as a string ('$0.01'). On the wire the requirements carry the amount in atomic units of USDC, which has 6 decimals, so $0.01 is 10000 and $1.00 is 1000000. The payTo value is a plain address you control. Nothing on the server signs anything.

Your route handler is ordinary Express. It only runs once the payment has been verified, so it needs no payment logic of its own. Register the middleware before the routes it protects, as you would any Express middleware.

What an agent sees

Call the route with no payment and you get 402 Payment Required. Decode the PAYMENT-REQUIRED header from base64 and you will find the accepts list built from your config: the scheme (exact), the network, the amount in atomic units, the USDC contract address for that network, your payTo address, maxTimeoutSeconds, and an extra object with the token's EIP-712 name and version. A client needs every one of those values to sign a valid payment, which is why a typo in the network or address shows up as a failed payment rather than an error on your side.

To charge for more routes, add more entries to the map, each with its own price and description. Keeping the map in one module makes prices easy to review in a single diff.

Hono

The Hono adapter has the same signature, paymentMiddleware(routes, server), and you mount it with app.use:

import { Hono } from 'hono'
import { paymentMiddleware } from '@x402/hono'
import { server } from './lib/x402'

const app = new Hono()

app.use(
  paymentMiddleware(
    {
      'GET /premium-data': {
        accepts: [{ scheme: 'exact', price: '$0.01', network: 'eip155:84532', payTo: '0xYourAddress' }],
        description: 'Premium market data',
      },
    },
    server,
  ),
)

app.get('/premium-data', (c) => c.json({ price: 42 }))

export default app

Because the route config has the same shape in Express and Hono, you can keep it in a shared module and import it into either. That is useful if you run the same API on a Node server and on an edge runtime.

Next.js (App Router)

In Next.js you wrap a route handler instead of mounting middleware. withX402 takes the handler, the payment config for that route, and the shared server:

// app/api/premium-data/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { withX402 } from '@x402/next'
import { server } from '@/lib/x402'

const handler = async (_: NextRequest) => NextResponse.json({ price: 42 })

export const GET = withX402(
  handler,
  {
    accepts: [{ scheme: 'exact', price: '$0.01', network: 'eip155:84532', payTo: '0xYourAddress' }],
    description: 'Premium market data',
  },
  server,
)

The config object is the same per-route entry you would put in the Express or Hono map, without the METHOD /path key, because the file location already defines the route and the export name defines the method.

Keep lib/x402.ts free of request-specific state. On serverless hosting the module may be reused across invocations or loaded fresh, and the server instance should behave the same either way.

Calling a paid route from a client

To check your server from the other side, use @x402/fetch. It wraps fetch, reads the 402, signs a payment and retries:

import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from '@x402/fetch'
import { ExactEvmScheme } from '@x402/evm'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`)
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'eip155:84532', client: new ExactEvmScheme(account) }],
})

const response = await fetchWithPayment('http://localhost:3000/premium-data')
const receipt = response.headers.get('PAYMENT-RESPONSE')
if (receipt) console.log(decodePaymentResponseHeader(receipt))

The decoded receipt contains success, the transaction hash, the payer and the network. Paste the hash into sepolia.basescan.org to see the USDC transfer.

The private key here belongs to the client, never the server. Use a dedicated wallet with a small balance, funded with test USDC from faucet.circle.com (choose USDC and Base Sepolia). The Base Sepolia testing guide covers this end to end. network: 'eip155:*' lets the client pay on any EVM chain the server accepts.

Hooks: seeing what happened to each payment

The adapters return a 402 or your response, but they do not tell you how many payments settled, which failed and why. The resource server exposes lifecycle hooks for that:

server
  .onAfterSettle(async (ctx) => {
    console.log('settled', ctx.requirements.amount, ctx.requirements.network, ctx.result.transaction)
  })
  .onSettleFailure(async (ctx) => {
    console.warn('settle failed', ctx.error.errorReason ?? ctx.error.invalidReason ?? ctx.error.name)
  })
  .onVerifyFailure(async (ctx) => {
    console.warn('verify failed', ctx.error.errorReason ?? ctx.error.invalidReason ?? ctx.error.name)
  })

ctx.requirements holds amount, network and payTo; ctx.result.transaction is the settlement hash; ctx.error carries errorReason or invalidReason with the protocol error code, such as insufficient_funds or invalid_exact_evm_payload_signature. Verify failures usually point at the client (no funds, a bad signature, an expired authorization); settle failures are worth an alert. Why x402 payments fail goes through the codes.

Because the hooks live on the shared server, they fire for Express, Hono and Next.js routes alike.

Sending outcomes to a dashboard

If you want those outcomes as per-route earnings and grouped failures instead of log lines, @requestway/reporter plugs into the same hooks. It does not implement x402; it reports what the official packages did. It has zero dependencies and needs Node 20 or newer:

import { reporter } from '@requestway/reporter'
export const rw = reporter({ key: process.env.REQUESTWAY_KEY })

server
  .onAfterSettle(async (ctx) => { rw.settled({ ...details(ctx), transaction: ctx.result.transaction }) })
  .onSettleFailure(async (ctx) => { rw.failed({ ...details(ctx), stage: 'settle', reason: reasonOf(ctx.error) }) })
  .onVerifyFailure(async (ctx) => { rw.failed({ ...details(ctx), stage: 'verify', reason: reasonOf(ctx.error) }) })

Here details(ctx) is your own helper returning the route, method, resource, mode: 'enforce', the configured priceUsd, amountAtomic: ctx.requirements.amount, the asset, network, payTo and a request id, and reasonOf = (e) => e.errorReason ?? e.invalidReason ?? e.name. The install guide has the full helper.

The reporter's methods are synchronous, return nothing and never throw, and without a key they do nothing. It batches every 5 seconds or 100 events and on process exit, which suits Express and Hono on a long-lived Node process. On serverless platforms the function is frozen once the response is sent, so pass waitUntil (import { waitUntil } from '@vercel/functions', then reporter({ key, waitUntil })), call await rw.flush(), or on Next.js 15+ use after(() => rw.flush()).

Moving from Base Sepolia to Base

When the testnet flow works, switching to mainnet is mostly configuration, but each change matters:

Setting Testnet Mainnet
Network eip155:84532 eip155:8453
USDC contract 0x036CbD53842c5426634e7929541eC2318f3dCF7e 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
Facilitator https://x402.org/facilitator A production facilitator
Explorer sepolia.basescan.org basescan.org

Change the network in both places: the register() call on the server and every accepts entry. Coinbase Developer Platform runs a managed mainnet facilitator at https://api.cdp.coinbase.com/platform/v2/x402, which requires authenticated, per-request signed headers; check the official repository for how to supply them. You can also self-host any service that exposes /verify, /settle and /supported.

Check the payTo address character by character before you deploy. Payments are irreversible. The testnet to mainnet checklist lists the rest, and once you are live the endpoint tester can still validate a mainnet route's 402 without paying.

FAQ

Do I need a private key on my server to accept x402 payments in Node?

No. The server only needs a payTo address. The paying client signs the transfer, and the facilitator submits it and pays the gas.

What is the difference between @x402/express and @x402/next?

@x402/express (like @x402/hono) is middleware that takes a map of METHOD /path routes. @x402/next exports withX402, which wraps a single App Router route handler. Both use the same x402ResourceServer from @x402/core.

Can I use the x402.org facilitator in production?

Only for testnet traffic. It is free and needs no account, but it does not settle mainnet payments, so production on Base needs a different facilitator.

How do I test an x402 Express route locally without real money?

Run the server on Base Sepolia (eip155:84532) with the x402.org facilitator, fund a client wallet with test USDC from the Circle faucet, and call the route with @x402/fetch. No ETH is needed on either side.

Why do my x402 events never arrive from a Vercel function?

The platform freezes the function once the response is sent, so buffered events never leave. Pass waitUntil to the reporter, await rw.flush() before returning, or use after(() => rw.flush()) on Next.js 15+.