Skip to main content

Create an x402 server with ERC-7710

In this guide, you build a Node.js server that charges for HTTP API access using x402 and accepts ERC-7710 delegation payments verified through the MetaMask facilitator.

The official @x402/express middleware doesn't yet support ERC-7710 delegation payloads. This guide shows you how to implement the x402 HTTP contract manually with Express.

Prerequisites

Steps

1. Install the dependencies

npm install viem

2. Define constants

Configure the server constants the middleware uses to build payment requirements in later steps. The example charges in USDC on Base mainnet and routes verification and settlement through the MetaMask facilitator.

// src/config.ts
import 'dotenv/config'
import { Address } from 'viem'
import { base } from 'viem/chains'

export const NETWORK_ID = `eip155:${base.id}`

// USDC address on Base mainnet.
export const USDC_ADDRESS: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
export const PAY_TO_ADDRESS = '0x<PAY_TO_ADDRESS>'
// MetaMask facilitator host for Base mainnet.
export const FACILITATOR_URL = 'https://tx-sentinel-base-mainnet.dev-api.cx.metamask.io'

3. Add a discovery method

Add a public GET /info route that publishes the seller's payment terms, including the facilitator address discovered from the MetaMask facilitator's /supported endpoint. Buyers call this route before they prepare an ERC-7710 delegation payment payload.

import express, { type Request, type Response } from 'express'
import cors from 'cors'
import { NETWORK_ID, USDC_ADDRESS, PAY_TO_ADDRESS } from './config'
import { fetchFacilitatorAddress } from './utils'

const app = express()

app.use(cors())
app.use(express.json({ limit: '1mb' }))

app.get('/info', async (_req: Request, res: Response) => {
try {
const facilitatorAddress = await fetchFacilitatorAddress()
res.json({
payToAddress: PAY_TO_ADDRESS,
facilitatorAddress,
network: NETWORK_ID,
asset: USDC_ADDRESS,
supportedMethods: ['erc7710'],
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch facilitator address'
res.status(503).json({ error: message })
}
})

4. Create payment middleware

Add payment middleware that runs before each protected handler. The middleware factory builds the payment requirements for the route, reads the buyer's delegation payload from the request header, verifies it through the facilitator, and settles asynchronously.

When the header is missing, the middleware encodes the payment requirements in a PAYMENT-REQUIRED response header and returns 402 Payment Required with a JSON body that tells the buyer how to pay. When the header is malformed, the middleware returns 400 Bad Request.

The middleware builds payment requirements that follow the x402 payment requirements schema. Set scheme to exact and assetTransferMethod to erc7710 so the facilitator routes the payment to its delegation verifier.

Parameters

Payment terms the seller advertises. The middleware builds the PaymentRequirements object per request and returns it in the PAYMENT-REQUIRED response header when payment is missing.

NameDescription
schemePayment scheme the facilitator uses to interpret the payload. Set this to exact so the facilitator routes the payment through its standard verifier.
networkCAIP-2 chain identifier for the payment. The example uses eip155:8453, the identifier for Base.
amountAmount the buyer must authorize, expressed in the wei format. The example charges 0.01 USDC.
assetERC-20 token contract address used for payment. The example uses USDC on Base.
payToSeller wallet address that receives the settled funds.
maxTimeoutSecondsMaximum time, in seconds, the buyer's payment authorization stays valid before settlement must complete.
extra.assetTransferMethodAsset transfer method the facilitator uses. Set this to erc7710 so the facilitator routes the payment to its ERC-7710 delegation verifier.
extra.facilitatorsList of facilitator addresses allowed to settle the payment. This helps buyers scope a delegation to a specific facilitator address before signing.

Implementation

import type { Request, Response, NextFunction } from 'express'
import type { Address } from 'viem'
import { USDC_ADDRESS, NETWORK_ID, PAY_TO_ADDRESS, FACILITATOR_URL } from './config'
import type { PaymentPayload, PaymentRequirements, PaymentMiddlewareOptions } from './types'
import { fetchFacilitatorAddress } from './utils'

export function createPaymentMiddleware(options: PaymentMiddlewareOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
let facilitatorAddress: Address | null = null
try {
facilitatorAddress = await fetchFacilitatorAddress()
} catch (err) {
console.warn('[x402] Could not fetch facilitator address:', err)
}

const paymentRequirements: PaymentRequirements = {
scheme: 'exact',
network: NETWORK_ID,
amount: options.amount,
asset: USDC_ADDRESS,
payTo: PAY_TO_ADDRESS,
maxTimeoutSeconds: 60,
extra: {
assetTransferMethod: 'erc7710',
...(facilitatorAddress ? { facilitators: [facilitatorAddress] } : {}),
},
}

const paymentHeader =
(req.headers['payment-signature'] as string) || (req.headers['x-payment-signature'] as string)

if (!paymentHeader) {
const paymentRequired = {
x402Version: 2,
accepts: [paymentRequirements],
description: options.description || 'Payment required to access this resource',
mimeType: options.mimeType || 'application/json',
}
const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString('base64')
res.setHeader('PAYMENT-REQUIRED', encoded)
res.status(402).json({ error: 'Payment Required', paymentRequired })
return
}

let paymentPayload: PaymentPayload
try {
const decoded = Buffer.from(paymentHeader, 'base64').toString('utf-8')
paymentPayload = JSON.parse(decoded)
} catch {
res.status(400).json({ error: 'Invalid PAYMENT-SIGNATURE header' })
return
}

// Verification and settlement are added in steps 6 and 7.
next()
}
}

5. Verify the payment

Call the MetaMask facilitator's verify endpoint through the verifyPayment helper to confirm the encoded delegation chain authorizes the requested resource. If verification fails, return 402 with the failure reason from the facilitator so the buyer can correct the payment and retry. If the facilitator itself is unreachable, return 502 so the buyer knows the failure is on the seller side.

+ import { facilitatorPost } from './utils'
+ import type { VerifyResult } from './types'

+ function verifyPayment(
+ paymentPayload: PaymentPayload,
+ paymentRequirements: PaymentRequirements,
+ ): Promise<VerifyResult> {
+ return facilitatorPost('verify', { paymentPayload, paymentRequirements })
+ }

export function createPaymentMiddleware(options: PaymentMiddlewareOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
// Additional code from previous step

+ let verifyResult: VerifyResult
+ try {
+ verifyResult = await verifyPayment(paymentPayload, paymentRequirements)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Verification failed'
+ res.status(502).json({ error: message })
+ return
+ }
+
+ if (!verifyResult.isValid) {
+ res.status(402).json({
+ error: 'Payment verification failed',
+ reason: verifyResult.invalidReason,
+ message: verifyResult.invalidMessage,
+ })
+ return
+ }

next()
}
}

6. Settle the payment

After verification succeeds, wrap res.json so settlement runs asynchronously through the settlePayment helper after the protected handler responds. Set the PAYMENT-RESPONSE header with the verified payer and scheme so the buyer can correlate the result with the original request. Settling after responding keeps the protected route's latency independent of facilitator settlement time.

+ import type { SettleResult } from './types'

+ function settlePayment(
+ paymentPayload: PaymentPayload,
+ paymentRequirements: PaymentRequirements,
+ ): Promise<SettleResult> {
+ return facilitatorPost('settle', { paymentPayload, paymentRequirements })
+ }

export function createPaymentMiddleware(options: PaymentMiddlewareOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
// Additional code from previous steps

+ const originalJson = res.json.bind(res)
+ res.json = function (body: unknown) {
+ settlePayment(paymentPayload, paymentRequirements)
+ .then((settleResult) => {
+ console.log(
+ '[x402] Settlement:',
+ settleResult.success
+ ? `tx ${settleResult.transaction}`
+ : `failed: ${settleResult.errorMessage}`,
+ )
+ })
+ .catch((err) => console.error('[x402] Settlement error:', err))
+
+ const paymentResponse = {
+ x402Version: 2,
+ scheme: paymentRequirements.scheme,
+ network: NETWORK_ID,
+ payer: verifyResult.payer,
+ }
+ const encodedResponse = Buffer.from(JSON.stringify(paymentResponse)).toString('base64')
+ res.setHeader('PAYMENT-RESPONSE', encodedResponse)
+ return originalJson(body)
+ }

next()
}
}

7. Configure the server

Mount the protected routes on an Express router gated by the middleware, then start the server. The example returns { message: 'Hello!' } as a placeholder. Replace it with whatever JSON payload your protected route serves.

// src/index.ts (continued)
import { createPaymentMiddleware } from './middleware'

const api = express.Router()
api.use(
createPaymentMiddleware({
// 0.01 USDC in wei format.
amount: '10000',
description: 'Access to protected resource',
mimeType: 'application/json',
})
)

api.get('/hello', (_req: Request, res: Response) => {
res.json({ message: 'Hello!' })
})

app.use('/api', api)

app.listen(4402, () => {
console.log(`[seller] Server running on http://localhost:4402`)
})

Next steps