Skip to content

${redev}

Published 12/4/2025 · 9 min read

Tags: solana , javascript , x402 , express , api

Time to build. We’re creating an Express API that requires payment before serving content.

By the end of this post, you’ll have a working x402 server that:

  • Returns 402 with payment requirements
  • Accepts the X-PAYMENT header
  • Verifies payments via a facilitator
  • Serves protected content

Project Setup

We’ll use Bun for the server - it’s faster and has a cleaner API than Node + Express. Plus native TypeScript.

mkdir x402-server
cd x402-server
bun init -y

Bun has a built-in HTTP server, so we don’t even need Express:

The Minimal Server

Let’s start with the simplest possible x402 server using Bun’s native Bun.serve():

// server.ts

// Your Solana wallet address (receives payments)
const TREASURY_ADDRESS = "YOUR_WALLET_ADDRESS";

// USDC on Solana devnet
const USDC_DEVNET = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";

// Price in USDC micro-units (1 USDC = 1,000,000)
const PRICE = "10000"; // $0.01

const server = Bun.serve({
  port: 3000,

  async fetch(req) {
    const url = new URL(req.url);

    if (url.pathname === "/api/premium") {
      const paymentHeader = req.headers.get("x-payment");

      if (!paymentHeader) {
        // No payment - return 402 with requirements
        return Response.json(
          {
            x402Version: 1,
            accepts: [
              {
                scheme: "exact",
                network: "solana-devnet",
                maxAmountRequired: PRICE,
                resource: `http://localhost:3000/api/premium`,
                description: "Access to premium content",
                mimeType: "application/json",
                payTo: TREASURY_ADDRESS,
                maxTimeoutSeconds: 60,
                asset: {
                  address: USDC_DEVNET,
                },
                extra: {},
              },
            ],
          },
          { status: 402 }
        );
      }

      // TODO: Verify payment
      // For now, just accept any payment header

      return Response.json({
        message: "Welcome to the premium content!",
        data: {
          secret: "The answer is 42",
          timestamp: new Date().toISOString(),
        },
      });
    }

    return new Response("Not found", { status: 404 });
  },
});

console.log(`x402 server running on http://localhost:${server.port}`);

Run it:

bun server.ts

Test with curl:

# Without payment - get 402
curl http://localhost:3000/api/premium

# With payment header - get content (no verification yet)
curl -H "X-PAYMENT: fake-payment" http://localhost:3000/api/premium

Adding Real Verification

Now let’s verify payments using a facilitator. Bun has native fetch, so no extra packages needed:

// server.ts

// Configuration
const CONFIG = {
  treasuryAddress: "YOUR_WALLET_ADDRESS",
  network: "solana-devnet",
  facilitatorUrl: "https://x402.org/facilitator",
  usdcMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  price: "10000", // $0.01 USDC
};

// Create payment requirements
function createPaymentRequirements(resource: string) {
  return {
    x402Version: 1,
    accepts: [
      {
        scheme: "exact",
        network: CONFIG.network,
        maxAmountRequired: CONFIG.price,
        resource,
        description: "Premium API access",
        mimeType: "application/json",
        payTo: CONFIG.treasuryAddress,
        maxTimeoutSeconds: 60,
        asset: {
          address: CONFIG.usdcMint,
        },
        extra: {},
      },
    ],
  };
}

// Verify payment with facilitator
async function verifyPayment(paymentHeader: string, paymentRequirements: any) {
  try {
    const response = await fetch(`${CONFIG.facilitatorUrl}/verify`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        paymentHeader,
        paymentRequirements: paymentRequirements.accepts[0],
      }),
    });

    const result = (await response.json()) as { valid: boolean };
    return result.valid === true;
  } catch (error) {
    console.error("Verification error:", error);
    return false;
  }
}

const server = Bun.serve({
  port: 3000,

  async fetch(req) {
    const url = new URL(req.url);

    // CORS headers
    const headers = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "Content-Type, X-PAYMENT",
    };

    if (req.method === "OPTIONS") {
      return new Response(null, { headers });
    }

    if (url.pathname === "/api/premium") {
      const paymentHeader = req.headers.get("x-payment");
      const resource = req.url;
      const paymentRequirements = createPaymentRequirements(resource);

      if (!paymentHeader) {
        return Response.json(paymentRequirements, {
          status: 402,
          headers,
        });
      }

      // Verify the payment
      const isValid = await verifyPayment(paymentHeader, paymentRequirements);

      if (!isValid) {
        return Response.json(
          {
            error: "Invalid payment",
            ...paymentRequirements,
          },
          { status: 402, headers }
        );
      }

      // Payment verified - serve content
      return Response.json(
        {
          message: "Payment verified! Here is your premium content.",
          data: {
            secret: "The answer is 42",
            timestamp: new Date().toISOString(),
          },
        },
        {
          headers: {
            ...headers,
            "X-PAYMENT-RESPONSE": JSON.stringify({
              success: true,
              network: CONFIG.network,
            }),
          },
        }
      );
    }

    return new Response("Not found", { status: 404, headers });
  },
});

console.log(`x402 server running on http://localhost:${server.port}`);
console.log(`Treasury: ${CONFIG.treasuryAddress}`);

Creating Reusable Middleware

For multiple endpoints, let’s create a helper function:

// x402.ts

interface X402Config {
  treasuryAddress: string;
  network?: string;
  facilitatorUrl?: string;
  usdcMint?: string;
}

interface RouteConfig {
  price: number;
  description?: string;
}

export function createX402Handler(config: X402Config) {
  const {
    treasuryAddress,
    network = "solana-devnet",
    facilitatorUrl = "https://x402.org/facilitator",
    usdcMint = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  } = config;

  return function withPayment(
    routeConfig: RouteConfig,
    handler: (req: Request) => Response | Promise<Response>
  ) {
    return async (req: Request): Promise<Response> => {
      const paymentHeader = req.headers.get("x-payment");

      const paymentRequirements = {
        x402Version: 1,
        accepts: [
          {
            scheme: "exact",
            network,
            maxAmountRequired: String(routeConfig.price),
            resource: req.url,
            description: routeConfig.description || "API access",
            mimeType: "application/json",
            payTo: treasuryAddress,
            maxTimeoutSeconds: 60,
            asset: { address: usdcMint },
            extra: {},
          },
        ],
      };

      if (!paymentHeader) {
        return Response.json(paymentRequirements, { status: 402 });
      }

      // Verify payment
      try {
        const response = await fetch(`${facilitatorUrl}/verify`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            paymentHeader,
            paymentRequirements: paymentRequirements.accepts[0],
          }),
        });

        const result = (await response.json()) as { valid: boolean };

        if (!result.valid) {
          return Response.json(
            {
              error: "Payment verification failed",
              ...paymentRequirements,
            },
            { status: 402 }
          );
        }

        // Call the actual handler
        return handler(req);
      } catch (error) {
        console.error("x402 verification error:", error);
        return Response.json(
          { error: "Payment verification failed" },
          { status: 500 }
        );
      }
    };
  };
}

Use it like this:

// server.ts
import { createX402Handler } from "./x402";

const withPayment = createX402Handler({
  treasuryAddress: "YOUR_WALLET_ADDRESS",
});

// Define your handlers
const cheapHandler = withPayment(
  { price: 10000, description: "Basic access" },
  () => Response.json({ tier: "basic", data: "..." })
);

const premiumHandler = withPayment(
  { price: 100000, description: "Premium access" },
  () => Response.json({ tier: "premium", data: "..." })
);

const expensiveHandler = withPayment(
  { price: 1000000, description: "Enterprise access" },
  () => Response.json({ tier: "enterprise", data: "..." })
);

// Router
const server = Bun.serve({
  port: 3000,

  async fetch(req) {
    const url = new URL(req.url);

    switch (url.pathname) {
      case "/api/cheap":
        return cheapHandler(req);
      case "/api/premium":
        return premiumHandler(req);
      case "/api/expensive":
        return expensiveHandler(req);
      case "/api/free":
        return Response.json({ tier: "free", data: "..." });
      default:
        return new Response("Not found", { status: 404 });
    }
  },
});

console.log(`Server running on http://localhost:${server.port}`);

Testing Without a Real Wallet

For development, you might want to bypass payment verification:

const x402 = createX402Middleware({
  treasuryAddress: "YOUR_WALLET_ADDRESS",
  network: "solana-devnet",
  // Add bypass option for development
  bypassVerification: process.env.NODE_ENV === "development",
});

Update the middleware to check this flag and skip verification if set.

Complete Working Server

Here’s the full implementation with Bun:

// server.ts

// Configuration (use Bun.env for environment variables)
const CONFIG = {
  treasuryAddress: Bun.env.TREASURY_ADDRESS || "YOUR_WALLET_ADDRESS",
  network: Bun.env.SOLANA_NETWORK || "solana-devnet",
  facilitatorUrl: Bun.env.FACILITATOR_URL || "https://x402.org/facilitator",
  usdcMint: Bun.env.USDC_MINT || "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  port: Number(Bun.env.PORT) || 3000,
};

// Verify payment with facilitator
async function verifyPayment(paymentHeader: string, paymentRequirements: any) {
  const response = await fetch(`${CONFIG.facilitatorUrl}/verify`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      paymentHeader,
      paymentRequirements: paymentRequirements.accepts[0],
    }),
  });
  const result = (await response.json()) as { valid: boolean };
  return result.valid;
}

// Create payment requirements
function createRequirements(
  price: number,
  description: string,
  resource: string
) {
  return {
    x402Version: 1,
    accepts: [
      {
        scheme: "exact",
        network: CONFIG.network,
        maxAmountRequired: String(price),
        resource,
        description,
        mimeType: "application/json",
        payTo: CONFIG.treasuryAddress,
        maxTimeoutSeconds: 60,
        asset: { address: CONFIG.usdcMint },
        extra: {},
      },
    ],
  };
}

// x402 wrapper
function requirePayment(
  price: number,
  description: string,
  handler: () => object
) {
  return async (req: Request): Promise<Response> => {
    const paymentHeader = req.headers.get("x-payment");
    const requirements = createRequirements(price, description, req.url);

    if (!paymentHeader) {
      return Response.json(requirements, { status: 402 });
    }

    try {
      const isValid = await verifyPayment(paymentHeader, requirements);

      if (!isValid) {
        return Response.json(
          { error: "Invalid payment", ...requirements },
          { status: 402 }
        );
      }

      return Response.json(handler(), {
        headers: {
          "X-PAYMENT-RESPONSE": JSON.stringify({
            success: true,
            network: CONFIG.network,
          }),
        },
      });
    } catch {
      return Response.json({ error: "Verification failed" }, { status: 500 });
    }
  };
}

// Routes
const routes: Record<string, (req: Request) => Response | Promise<Response>> = {
  "/": () =>
    Response.json({
      name: "x402 Demo API",
      endpoints: {
        "/api/free": "Free endpoint",
        "/api/basic": "Requires $0.01 USDC",
        "/api/premium": "Requires $0.10 USDC",
      },
    }),

  "/api/free": () =>
    Response.json({
      message: "This is free content!",
      timestamp: new Date().toISOString(),
    }),

  "/api/basic": requirePayment(10000, "Basic API access", () => ({
    message: "Thanks for paying! Here is basic content.",
    data: {
      fact: "Solana can process 65,000 transactions per second.",
      tier: "basic",
    },
  })),

  "/api/premium": requirePayment(100000, "Premium API access", () => ({
    message: "Welcome, premium user!",
    data: {
      secrets: ["The answer is 42", "Blockchain is just a linked list"],
      tier: "premium",
      bonus: "Premium users get extra data",
    },
  })),
};

// Server
const server = Bun.serve({
  port: CONFIG.port,

  async fetch(req) {
    const url = new URL(req.url);

    // CORS
    if (req.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Headers": "Content-Type, X-PAYMENT",
        },
      });
    }

    const handler = routes[url.pathname];
    if (handler) {
      const response = await handler(req);
      // Add CORS to all responses
      response.headers.set("Access-Control-Allow-Origin", "*");
      return response;
    }

    return new Response("Not found", { status: 404 });
  },
});

console.log(`
x402 Server Running
==================
URL: http://localhost:${server.port}
Network: ${CONFIG.network}
Treasury: ${CONFIG.treasuryAddress}
Facilitator: ${CONFIG.facilitatorUrl}
`);

Environment Variables

Create a .env file (Bun loads this automatically):

TREASURY_ADDRESS=your_solana_wallet_address
SOLANA_NETWORK=solana-devnet
FACILITATOR_URL=https://x402.org/facilitator
USDC_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU
PORT=3000

Run the server:

bun server.ts

Why Bun?

Coming from Node.js, here’s what’s better:

Node.jsBun
Need Express for routingBun.serve() built-in
Need node-fetch for fetchNative fetch
Need dotenv for .envAuto-loads .env
package.json type: moduleESM by default
Need ts-node for TypeScriptNative TS support
~200ms cold start~20ms cold start

For an x402 server where response time matters, Bun’s speed advantage is real.

What You Built

You now have a working x402 server that:

✅ Returns 402 with payment requirements for protected routes ✅ Accepts X-PAYMENT headers ✅ Verifies payments through a facilitator ✅ Serves content after verification ✅ Supports multiple price tiers

Next Up

We have a server. Now we need a client that can:

  1. Detect the 402 response
  2. Parse payment requirements
  3. Make the Solana payment
  4. Retry with the proof

That’s the next post.

Related Articles

  • Understanding requestAnimationFrame

    A practical guide to browser animation timing. Learn what requestAnimationFrame actually does, why it beats setInterval, and how to use it properly.

  • Lambda Expressions vs Anonymous Functions

    When learning a functional programming style you will often come across the term Lambda Expressions or Lambda Functions. In simple terms they are just functions that can be used as data and therefore declared as a value. Let's explore a few examples.

  • JavaScript typeof Number

    Often you will need to check that you have a number before using it in your JavaScript, here's how.