Skip to main content

Overview

This guide shows how to use Magic’s Express Server Wallet to pay for x402-protected API endpoints. x402 is an open payment protocol by Coinbase that uses the HTTP 402 Payment Required status code to enable instant, gasless stablecoin payments over HTTP. Your server signs the payment via the TEE and the x402 facilitator handles settlement — the private key never leaves the TEE.

Prerequisites

Before starting, ensure you have:
  1. A Magic Secret Key — from your Magic Dashboard
  2. An OIDC Provider ID — configured for your auth provider (setup guide)
  3. A user JWT from your authentication provider
  4. USDC on Base Sepolia in the user’s wallet (for testing)

How It Works

  1. Your server authenticates the user and obtains a JWT
  2. JWT is sent to Magic’s TEE to get (or create) the user’s EOA
  3. Your server makes a request to an x402-protected endpoint
  4. The server responds with HTTP 402 and payment requirements
  5. The x402 client signs a gasless USDC transfer (EIP-3009) via the TEE
  6. The request is retried with the payment signature
  7. A facilitator settles the payment on-chain
x402 payments are gasless for the payer. The protocol uses EIP-3009 transferWithAuthorization, which means only a typed data signature is needed — no ETH required for gas. The facilitator submits the on-chain transaction.

TEE Request Helper

All TEE calls use the same authentication headers. Create a reusable helper for your server-side code.
TypeScript
const TEE_BASE = "https://tee.express.magiclabs.com";

async function teeRequest<T>(path: string, jwt: string, body: object): Promise<T> {
  const res = await fetch(`${TEE_BASE}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${jwt}`,
      "X-Magic-Secret-Key": "YOUR_SECRET_KEY",
      "X-OIDC-Provider-ID": "YOUR_OIDC_PROVIDER_ID",
      "X-Magic-Chain": "ETH",
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`TEE error: ${res.status}`);
  return res.json();
}

Get or Create a Wallet

Fetch the user’s wallet address. If one doesn’t exist, it will be created automatically.
TypeScript
const { public_address: eoaAddress } = await teeRequest<{ public_address: string }>(
  "/v1/wallet", jwt, {}
);

Creating a TEE-Backed Account

The x402 SDK expects a viem Account object that can sign typed data. Create a custom account that delegates signing to the TEE.
TypeScript
import { toAccount } from "viem/accounts";
import { hashTypedData, serializeSignature, type Address, type Hex } from "viem";

function createTeeAccount(jwt: string, address: Address) {
  return toAccount({
    address,
    signMessage: async ({ message }) => {
      const hash = typeof message === "string"
        ? hashMessage(message)
        : hashMessage({ raw: message as Hex });
      return signHashViaTee(jwt, hash);
    },
    signTransaction: async () => {
      throw new Error("Use signAndSend for transactions");
    },
    signTypedData: async ({ domain, types, primaryType, message }) => {
      const hash = hashTypedData({ domain, types, primaryType, message });
      return signHashViaTee(jwt, hash);
    },
  });
}

async function signHashViaTee(jwt: string, hash: Hex): Promise<Hex> {
  const { r, s, v } = await teeRequest<{ r: string; s: string; v: string }>(
    "/v1/wallet/sign/data", jwt, { raw_data_hash: hash, chain: "ETH" }
  );
  return serializeSignature({ r: r as Hex, s: s as Hex, yParity: Number(v) - 27 });
}

const teeAccount = createTeeAccount(jwt, eoaAddress as Address);

Setting Up the x402 Client

Register the TEE-backed account with the x402 client and create a payment-enabled fetch wrapper.
TypeScript
import { x402Client } from "@x402/core/client";
import { wrapFetchWithPayment } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm/exact/client";

const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(teeAccount));

const fetchWithPayment = wrapFetchWithPayment(fetch, client);

Making Paid Requests

Use fetchWithPayment just like the regular fetch API. If the server responds with 402, the x402 client automatically handles the payment flow via the TEE.
TypeScript
async function getPaidResource(jwt: string, url: string) {
  const response = await fetchWithPayment(url, { method: "GET" });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return await response.json();
}

// Example: fetch weather data from an x402-protected API
const data = await getPaidResource(jwt, "https://api.example.com/weather");
console.log("Weather:", data);
The x402 client handles everything automatically:
  1. Receives the 402 response with payment requirements
  2. Signs a gasless USDC transfer via the TEE
  3. Retries the request with the payment signature in the header
  4. Returns the successful response

Setting Up a Test Server

To test the payment flow, set up a simple Express server that requires x402 payment.
npm install express @x402/express @x402/evm @x402/core
TypeScript
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { HTTPFacilitatorClient } from "@x402/core/server";

const app = express();

// Your wallet address to receive payments
const payTo = "0xYourWalletAddress";

// Use the free testnet facilitator (no signup required)
const facilitatorClient = new HTTPFacilitatorClient({
  url: "https://x402.org/facilitator",
});

const server = new x402ResourceServer(facilitatorClient)
  .register("eip155:84532", new ExactEvmScheme());

app.use(
  paymentMiddleware(
    {
      "GET /weather": {
        accepts: [
          {
            scheme: "exact",
            price: "$0.001",
            network: "eip155:84532", // Base Sepolia
            payTo,
          },
        ],
        description: "Get current weather data",
        mimeType: "application/json",
      },
    },
    server,
  ),
);

app.get("/weather", (req, res) => {
  res.json({ weather: "sunny", temperature: 72, city: "San Francisco" });
});

app.listen(4021, () => {
  console.log("x402 server running at http://localhost:4021");
});
The testnet facilitator at https://x402.org/facilitator requires no API keys or signup. For production, switch to the Coinbase CDP facilitator with network eip155:8453 (Base mainnet).

Switching to Production

To move from testnet to mainnet, update the network and facilitator:
TypeScript
// Server: use CDP facilitator with Base mainnet
const facilitatorClient = new HTTPFacilitatorClient({
  url: "https://api.cdp.coinbase.com/platform/v2/x402",
});

const server = new x402ResourceServer(facilitatorClient)
  .register("eip155:8453", new ExactEvmScheme());

// Route config: update network
{
  scheme: "exact",
  price: "$0.01",
  network: "eip155:8453", // Base mainnet
  payTo: "0xYourWalletAddress",
}

Key Dependencies

PackagePurpose
viemEthereum client, account utilities, and typed data hashing
@x402/fetchWraps fetch with automatic x402 payment handling
@x402/evmEVM payment scheme for x402
@x402/coreCore x402 client and server utilities
@x402/expressExpress middleware for x402-protected endpoints

TEE Endpoints Used

EndpointPurpose
POST /v1/walletGet or create an EOA wallet
POST /v1/wallet/sign/dataSign a raw data hash (EIP-712 typed data hash)

Troubleshooting

Symptoms: Authentication errors when calling TEE endpoints.Solutions:
  • Verify the JWT token is valid and not expired
  • Check that X-Magic-Secret-Key matches your dashboard credentials
  • Ensure the OIDC Provider ID is correct
  • Confirm your domain is allowlisted in the Magic Dashboard
Symptoms: The facilitator rejects the payment signature.Solutions:
  • Ensure the wallet has sufficient USDC on the correct network (Base Sepolia for testing, Base mainnet for production)
  • Verify the TEE is signing the correct EIP-712 hash
  • Check that the signHashViaTee function is correctly reconstructing the signature from r, s, v components
Symptoms: The fetch call returns a raw 402 response instead of automatically paying.Solutions:
  • Make sure you’re using fetchWithPayment (the wrapped version), not the native fetch
  • Verify the x402 client has a scheme registered for the server’s network
  • Check that ExactEvmScheme is imported from @x402/evm/exact/client (not /server)
Symptoms: Payment fails with a network mismatch error.Solutions:
  • Use eip155:84532 for Base Sepolia or eip155:8453 for Base mainnet
  • The client scheme must match the network the server is requesting payment on

Resources

Express API Docs

Learn about Magic’s Express Server Wallet API

x402 Documentation

Official x402 protocol documentation

x402 GitHub

Reference implementations and examples

x402 Foundation

Protocol specification and facilitator info