2026-04-07  ·  15 viewsx402basetutorialdeveloper

x402 From Zero to Paid Endpoint in 30 Minutes

The docs are sparse and the SDKs are young. Here's the concrete path from nothing to a working x402 endpoint — including the parts that aren't documented anywhere.

The x402 developer experience in early 2026 is somewhere between "early adopter" and "read the source code." The core protocol is sound. The Base mainnet infrastructure works. But the documentation is scattered, the error messages are cryptic, and several important behaviors are only discoverable by trial and error.

This is a practical walkthrough. No theory. Just the steps that work.


What You're Building

A Next.js route that:

  1. Returns a 402 Payment Required response with USDC payment requirements on Base mainnet
  2. Verifies payment proof on incoming requests
  3. Settles the payment via Coinbase CDP
  4. Returns your actual response

The same pattern applies to Express, Hono, or any HTTP server — the middleware layer changes but the underlying mechanics are identical.


Prerequisites

  • A Next.js 14+ app (App Router)
  • A wallet address on Base mainnet to receive payments
  • CDP API keys from cdp.coinbase.com

You don't need to fund a wallet. You don't need to deploy first. The payment address is just an EVM address that will receive USDC.


Step 1: Install the packages

npm install x402-next @coinbase/cdp-sdk

x402-next provides the withX402 wrapper. @coinbase/cdp-sdk handles JWT generation for the CDP facilitator.


Step 2: Set environment variables

# .env.local
X402_RECIPIENT_ADDRESS=0xYourAddressHere
CDP_API_KEY_ID=your-key-id
CDP_API_KEY_SECRET=your-key-secret

The recipient address is where USDC lands. The CDP keys authenticate your server to the Coinbase facilitator.

Get CDP keys from portal.cdp.coinbase.com → API Keys. The free tier supports development and low-volume production.


Step 3: Create the route

// app/api/your-endpoint/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { withX402 } from 'x402-next'
import { generateJwt } from '@coinbase/cdp-sdk/auth'

const RECIPIENT = process.env.X402_RECIPIENT_ADDRESS as `0x${string}`

async function cdpAuthHeaders() {
  const makeHeader = async (path: string, method = 'POST') => {
    const jwt = await generateJwt({
      apiKeyId:      process.env.CDP_API_KEY_ID!,
      apiKeySecret:  process.env.CDP_API_KEY_SECRET!,
      requestMethod: method,
      requestHost:   'api.cdp.coinbase.com',
      requestPath:   path,
    })
    return { Authorization: `Bearer ${jwt}` }
  }
  return {
    verify:    await makeHeader('/platform/v2/x402/verify'),
    settle:    await makeHeader('/platform/v2/x402/settle'),
    supported: await makeHeader('/platform/v2/x402/supported', 'GET'),
  }
}

const facilitator = {
  url: 'https://api.cdp.coinbase.com/platform/v2/x402' as `${string}://${string}`,
  createAuthHeaders: cdpAuthHeaders as () => Promise<{
    verify:    Record<string, string>
    settle:    Record<string, string>
    supported: Record<string, string>
  }>,
}

async function handler(req: NextRequest): Promise<NextResponse> {
  // Your actual response goes here
  return NextResponse.json({ data: 'your paid content' })
}

const x402Handler = withX402(
  handler,
  RECIPIENT,
  async (req: NextRequest) => ({
    price:   '$0.01' as const,
    network: 'base'  as const,
    config:  {
      description: 'Your endpoint description',
      resource: req.url as `${string}://${string}`,
    },
  }),
  facilitator,
)

export async function GET(request: NextRequest): Promise<Response> {
  return x402Handler(request)
}

That's a working paid endpoint. No other configuration needed.


Step 4: Test it

Without a payment header, you should get a 402:

curl -i https://yoursite.com/api/your-endpoint
HTTP/2 402
content-type: application/json

{
  "x402Version": 1,
  "error": "X-PAYMENT header is required",
  "accepts": [{
    "scheme": "exact",
    "network": "base",
    "maxAmountRequired": "10000",
    "resource": "https://yoursite.com/api/your-endpoint",
    "description": "Your endpoint description",
    "mimeType": "application/json",
    "payTo": "0xYourAddress",
    "maxTimeoutSeconds": 300,
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
  }]
}

The accepts array is what clients use to construct the payment. maxAmountRequired: "10000" means 10,000 USDC-units = $0.01 (USDC has 6 decimals).

To test with an actual payment, use @x402/fetch:

import { wrapFetchWithPayment } from '@x402/fetch'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount(process.env.TEST_PRIVATE_KEY as `0x${string}`)
const fetchWithPayment = wrapFetchWithPayment(fetch, account)

const res = await fetchWithPayment('https://yoursite.com/api/your-endpoint')
const data = await res.json()

The wrapper handles the 402 → sign → retry cycle automatically.


The Parts That Aren't Documented

Problem: resource URL must match exactly.

The resource field in the payment requirements is what the client signs over. If the URL in your 402 response doesn't exactly match the URL the client signed, verification fails. This is why you should use req.url (the full URL including query string) as the resource, not a hardcoded path.

// Wrong — breaks when clients pass query params
resource: 'https://yoursite.com/api/your-endpoint'

// Right — matches whatever URL the client actually called
resource: req.url as `${string}://${string}`

Problem: parameter validation before the 402 gate returns 400, not 402.

If you validate query parameters before calling x402Handler, requests without those params return 400. Discovery tools (like x402scan) probe endpoints without params, so they never see the 402. Validate inside your handler function — after the payment gate.

// Wrong
export async function GET(req: NextRequest) {
  if (!req.nextUrl.searchParams.get('wallet')) {
    return NextResponse.json({ error: 'wallet required' }, { status: 400 })
  }
  return x402Handler(req)  // x402scan never reaches this
}

// Right — validate inside the handler, after the payment gate
async function handler(req: NextRequest): Promise<NextResponse> {
  const wallet = req.nextUrl.searchParams.get('wallet')
  if (!wallet) return NextResponse.json({ error: 'wallet required' }, { status: 400 })
  // ...
}

Problem: withX402 doesn't settle if the inner handler returns 4xx.

From the source: if response.status >= 400, withX402 returns the response without settling the payment. This is actually correct behavior — the payment authorization is never executed. But it means: if your handler has a bug that returns 500 after payment verification, the caller's payment authorization is abandoned (no charge, no data). Build your handler to either succeed or fail cleanly.

Problem: CDP JWT must be generated per-request.

The createAuthHeaders function is called on every request, not once at startup. JWTs are short-lived (~2 minutes). If you generate them at module initialization time, they'll expire. The pattern above (async function called inside the middleware) is correct.

Problem: expires in mppx (not x402, but related).

If you're using mppx for MPP/Tempo payments, the expires field in the charge challenge must be dynamic: new Date(Date.now() + 25 * 1000).toISOString(). Some clients copy this timestamp directly into the transaction's validBefore field, and Tempo's maximum is 30 seconds. Static initialization means the challenge expires before any real request arrives.


Registering for Discovery

Once your endpoint is live, register it so agents can find it:

  1. x402scan — add your server URL at x402scan.com
  2. Bazaar — Coinbase's registry automatically indexes via the CDP facilitator
  3. openapi.json — add x-payment-info to your OpenAPI spec
  4. llms.txt — document your paid endpoints for AI-assisted discovery

None of these are required to accept payments, but they make your endpoint discoverable without an agent already knowing your URL.


Common Errors and What They Mean

Error Cause Fix
X-PAYMENT header is required No payment header sent Expected — client needs to pay
Unable to find matching payment requirements Client payment doesn't match resource URL Use req.url as resource, not hardcoded path
Payment verification failed Wrong facilitator, expired JWT, or wrong asset Check CDP keys and network config
Invalid payment Malformed payment header Client-side issue, usually wrong scheme
Build error: not assignable to Promise<NextResponse> Handler is not async Add async to handler function

The 30-minute estimate is real if you have CDP keys ready. The main time sink is usually debugging URL matching issues. Once your first endpoint works, the pattern is copy-paste for everything else.