Skip to main content

Using x402 Payments with Privy

Enable your users to pay for APIs and content using x402, the new HTTP payment protocol. This recipe shows how to integrate Privy embedded wallets with x402 to make automatic stablecoin payments.

What is x402?

x402 is an open payment protocol that enables instant, automatic payments for APIs and digital content over HTTP. When a resource requires payment, the server responds with 402 Payment Required. The client constructs an X-PAYMENT header with a signed payment authorization and retries the request. Learn more: x402.org | x402 Docs

Installation

No additional packages needed beyond Privy:
npm install @privy-io/react-auth

Implementation

Create a custom hook that uses Privy’s useSignTypedData to sign x402 payment headers:
// hooks/useX402Payment.ts
import {useState} from 'react';

import {useSignTypedData, useWallets} from '@privy-io/react-auth';

// x402 types
interface PaymentRequirements {
  scheme: string;
  network: string;
  maxAmountRequired: string;
  resource: string;
  description: string;
  mimeType: string;
  payTo: string;
  maxTimeoutSeconds: number;
  asset: string;
  extra: {
    name: string;
    version: string;
    chainId: string;
  };
}

interface PaymentRequiredResponse {
  x402Version: number;
  accepts: PaymentRequirements[];
  error?: string;
}

export function useX402Payment() {
  const {signTypedData} = useSignTypedData();
  const {wallets} = useWallets();
  const [isLoading, setIsLoading] = useState(false);

  async function buildXPaymentHeader(requirements: PaymentRequirements): Promise<string> {
    if (!wallets[0]) {
      throw new Error('No wallet available');
    }

    const wallet = wallets[0];
    const address = wallet.address;

    // Generate a random 32-byte nonce
    const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
    const nonce = `0x${Array.from(nonceBytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;

    // Calculate time window
    const validAfter = Math.floor(Date.now() / 1000);
    const validBefore = validAfter + requirements.maxTimeoutSeconds;

    // Map network to chainId (fallback if not in extra)
    const networkToChainId: Record<string, number> = {
      base: 8453,
      'base-sepolia': 84532,
      ethereum: 1,
      sepolia: 11155111
    };

    const chainIdNum = requirements.extra?.chainId
      ? Number(requirements.extra.chainId)
      : networkToChainId[requirements.network] || 84532;

    // Construct EIP-712 typed data for USDC transferWithAuthorization
    const domain = {
      name: requirements.extra.name,
      version: requirements.extra.version,
      chainId: chainIdNum,
      verifyingContract: requirements.asset as `0x${string}`
    };

    const types = {
      TransferWithAuthorization: [
        {name: 'from', type: 'address'},
        {name: 'to', type: 'address'},
        {name: 'value', type: 'uint256'},
        {name: 'validAfter', type: 'uint256'},
        {name: 'validBefore', type: 'uint256'},
        {name: 'nonce', type: 'bytes32'}
      ]
    };

    const message = {
      from: address,
      to: requirements.payTo,
      value: requirements.maxAmountRequired,
      validAfter,
      validBefore,
      nonce
    };

    // Sign with Privy's signTypedData
    const {signature} = await signTypedData(
      {
        domain,
        types,
        primaryType: 'TransferWithAuthorization',
        message
      },
      {
        address: wallet.address
      }
    );

    // Construct x402 payment payload matching x402 spec format
    const xPayment = {
      x402Version: 1,
      scheme: requirements.scheme,
      network: requirements.network,
      payload: {
        authorization: {
          from: address,
          to: requirements.payTo,
          value: requirements.maxAmountRequired,
          validAfter: validAfter.toString(),
          validBefore: validBefore.toString(),
          nonce
        },
        signature
      }
    };

    return Buffer.from(JSON.stringify(xPayment)).toString('base64');
  }

  async function fetchWithPayment(url: string, options?: RequestInit): Promise<Response> {
    setIsLoading(true);
    try {
      // First request - may receive 402
      let response = await fetch(url, options);

      if (response.status === 402) {
        // Parse payment requirements
        const paymentRequired: PaymentRequiredResponse = await response.json();

        if (!paymentRequired.accepts || paymentRequired.accepts.length === 0) {
          throw new Error('No payment methods accepted');
        }

        // Select first payment requirement (typically USDC on Base)
        const requirements = paymentRequired.accepts[0];

        // Build X-PAYMENT header
        const xPaymentHeader = await buildXPaymentHeader(requirements);

        // Retry with payment
        response = await fetch(url, {
          ...options,
          headers: {
            ...options?.headers,
            'X-PAYMENT': xPaymentHeader
          }
        });

        // Check if payment was successful
        if (!response.ok && response.status !== 402) {
          throw new Error(`Payment failed: ${response.statusText}`);
        }
      }

      return response;
    } finally {
      setIsLoading(false);
    }
  }

  return {
    buildXPaymentHeader,
    fetchWithPayment,
    isLoading
  };
}

Usage

Use the hook in your component:
import {useX402Payment} from './hooks/useX402Payment';

function MyComponent() {
  const {fetchWithPayment, isLoading} = useX402Payment();

  async function handleFetch() {
    const response = await fetchWithPayment('https://api.example.com/premium');
    const data = await response.json();
    console.log(data);
  }

  return (
    <button onClick={handleFetch} disabled={isLoading}>
      {isLoading ? 'Processing...' : 'Fetch Premium Content'}
    </button>
  );
}

How It Works

  1. User requests content: Client calls fetchWithPayment()
  2. Server responds 402: Returns payment requirements (USDC amount, recipient address, time window)
  3. Build typed data: Hook constructs EIP-712 typed data for USDC’s transferWithAuthorization
  4. Sign with Privy: User signs the authorization using their embedded wallet (no gas required)
  5. Build X-PAYMENT: Hook creates a base64-encoded JSON payload with authorization + signature
  6. Retry with payment: Request repeats with X-PAYMENT header
  7. Server verifies: Resource server calls facilitator to verify the payment
  8. Facilitator settles: Facilitator submits the authorization onchain and confirms the transaction
  9. Server delivers: Returns content with 200 OK

Key Details

Requirements:
  • Users need USDC in their Privy embedded wallet on the correct network (Base or Base Sepolia)
  • Use Privy’s useFundWallet hook to help users add funds if needed
  • The facilitator pays gas fees (users only need USDC, not ETH)
Testing:

Resources