Menu

Explorer & Settings

Tempo Explorer Submit Project
Back to all projects

solana-pay-x402

by sanniekwan1214 · Updated 17 days ago

HTTP 402 payment middleware for Solana (Express + Next.js). Supports x402 v2 (programmatic, facilitator-managed) and Solana Pay (QR code, on-chain) from a single integration.

In the AI payments ecosystem

solana-pay-x402 is an early-stage TypeScript project in the AI payments / x402 ecosystem. It currently has 3 GitHub stars and 0 forks.

README.md View on GitHub →

Solana Pay x402

Middleware that puts any API endpoint behind a Solana payment. Works with Express and Next.js. Drop it into a route and your endpoint returns 402 Payment Required until the client pays.

Two independent payment flows, one middleware — the server figures out which one the client is using:

  • x402 v2: Client signs a transaction, facilitator submits it and pays gas. Verified via x402 facilitator. Good for dApps, APIs, and anything programmatic.
  • Solana Pay: Client scans a QR code with a mobile wallet, pays on-chain directly. Verified on-chain via RPC. Good for mobile, retail, POS.

Quick Start

npm install solana-pay-x402

Express

import express from 'express'
import { solanaPay402 } from 'solana-pay-x402/express'

const app = express()

app.get('/api/premium',
  solanaPay402({
    rpcUrl: 'https://api.devnet.solana.com',
    recipient: 'YOUR_WALLET_ADDRESS',
    network: 'devnet',
    getPaymentAmount: () => 0.01 * 1e9, // 0.01 SOL in lamports
  }),
  (req, res) => {
    res.json({ content: 'You paid for this.' })
  }
)

app.listen(3000)

Next.js (App Router)

// app/api/premium/route.ts
import { withSolanaPay402 } from 'solana-pay-x402/nextjs'

export const GET = withSolanaPay402(async (req, { payment }) => {
  return Response.json({
    content: 'You paid for this.',
    txSignature: payment?.signature,
  })
}, {
  rpcUrl: process.env.SOLANA_RPC_URL!,
  recipient: process.env.MERCHANT_WALLET!,
  network: 'devnet',
  getPaymentAmount: () => 0.01 * 1e9,
})

That's it. The middleware handles the 402 response, payment verification, and settlement — by default it awaits facilitator settlement (x402 v2 flow) before your handler ever runs, so a settlement failure never lets a request through unpaid. See Settlement modes below to opt back into fire-and-forget settlement.

How It Works

x402 v2 Flow (Programmatic)

Client                    Server                   Facilitator
  |-- GET /api/premium ----->|                          |
  |<---- 402 + requirements -|                          |
  |                          |                          |
  | (build tx, sign with wallet, don't submit)          |
  |                          |                          |
  |-- GET /api/premium ----->|                          |
  |   + PAYMENT-SIGNATURE    |-- verify ---------------->|
  |                          |<---- isValid -------------|
  |                          |-- settle (awaited) ------>|
  |                          |<---- ok, tx submitted ----|
  |<---- 200 + content ------|                          |

The client signs a transaction but never submits it. The facilitator verifies the payment, then submits and settles it. This is the x402 protocol — the client just uses createX402Client from x402-solana/client and it handles everything behind a fetch() call.

By default the middleware awaits the settlement step before running your handler (settlementMode: 'blocking', the default since 0.2.0). If settlement fails, the request gets a 402 instead of your handler ever running — see Settlement modes.

Solana Pay Flow (QR Code)

Client                    Server                   Solana
  |-- GET /api/premium ----->|                        |
  |<---- 402 + QR URL ------|                        |
  |                          |                        |
  | (scan QR, wallet submits tx on-chain)             |
  |                          |                   tx on-chain
  |-- GET /api/premium ----->|                        |
  |   + PAYMENT-SIGNATURE    |-- getTransaction ----->|
  |     (tx signature)       |<---- tx details -------|
  |<---- 200 + content ------|                        |

The client scans a QR code, their wallet submits the transaction directly on-chain, and the client sends the transaction signature back. The server verifies on-chain.

402 Response

When a request hits a gated endpoint without payment, the response includes both payment options:

{
  "x402Version": 2,
  "accepts": [{
    "scheme": "exact",
    "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
    "amount": "10",
    "payTo": "YOUR_WALLET",
    "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "extra": { "feePayer": "..." }
  }],
  "solanaPay": {
    "url": "solana:YOUR_WALLET?amount=0.00001&spl-token=EPjFW...",
    "reference": "Bx7j8K..."
  }
}

x402 clients read the accepts array. Solana Pay clients use the solanaPay.url for QR codes. The middleware auto-detects which flow the client used when they come back with the PAYMENT-SIGNATURE header.

Configuration

Express

solanaPay402({
  // Required
  rpcUrl: 'https://api.devnet.solana.com',
  recipient: 'YOUR_WALLET_ADDRESS',
  getPaymentAmount: (req) => 1000000,  // return null to skip payment

  // Optional
  network: 'devnet',                    // default: 'mainnet-beta'
  label: 'My API',                      // shown in wallet
  message: 'Thanks for paying',         // memo field
  autoSettle: true,                     // default: true
  settlementMode: 'blocking',           // default: 'blocking' (see Settlement modes below)
  facilitatorUrl: 'https://...',        // default: PayAI Network

  // SPL token (defaults to native SOL)
  splToken: {
    mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
    decimals: 6,
  },

  // Callbacks
  onPaymentVerified: (req, verification) => {
    console.log('Paid:', verification.signature, verification.amount)
  },
  onPaymentFailed: (req, error) => {
    console.error('Failed:', error)
  },
  onSettlementFailed: (req, error) => {
    console.error('Settlement failed:', error)
  },
})

Next.js

withSolanaPay402(handler, {
  // Same config as Express, except:
  // - getPaymentAmount receives a web standard Request (not Express req)
  // - Payment info is passed via context, not attached to req

  rpcUrl: process.env.SOLANA_RPC_URL!,
  recipient: process.env.MERCHANT_WALLET!,
  getPaymentAmount: (req) => 1000000,

  // Callbacks receive Request instead of Express req
  onPaymentVerified: (req, verification) => {
    console.log('Paid:', verification.signature)
  },
})

The handler receives a PaymentContext as the second argument:

export const GET = withSolanaPay402(async (req, { payment }) => {
  // payment is undefined if no payment was required (amount was null/0)
  // payment.valid, payment.signature, payment.amount when paid
  return Response.json({ data: 'content' })
}, options)

Settlement modes

The x402 v2 flow has two steps: verify (facilitator confirms the signed transaction is valid — no funds move yet) and settle (facilitator submits the transaction — funds move here). Since 0.2.0, the middleware awaits settlement by default:

  • settlementMode: 'blocking' (default) — the middleware awaits facilitator settlement before running your handler. If settlement fails, the client gets a 402 ({ error: 'Payment settlement failed', message }) and your handler never runs, so served content and moved funds stay in sync.
  • settlementMode: 'async' — restores the pre-0.2.0 fire-and-forget behavior: your handler runs immediately after verification, and settlement happens in the background. Use this only if your endpoint can tolerate serving content before settlement is confirmed. Failures are only observable via onSettlementFailed.
solanaPay402({
  // ...
  settlementMode: 'async', // opt back into fire-and-forget settlement
  onSettlementFailed: (req, error) => {
    // Only fires in 'blocking' mode before the 402 response, or in 'async' mode
    // after the handler has already run (best-effort observability).
    console.error('Settlement failed:', error)
  },
})

x402 v2 payloads also get replay protection as of 0.2.0: each signed-but-unsettled transaction is tracked in the same signatureStore used for Solana Pay, keyed off the payer's signature — the fee payer in the x402 flow is the facilitator, whose signature slot is empty until settle time, so it is skipped (falling back to a hash of the payment header if the transaction can't be parsed). The key is claimed atomically before verification, so concurrent requests carrying the same header are rejected, and it is released again if verification or blocking settlement fails — retrying the identical signed transaction after a transient facilitator error is safe and supported (it's also the only retry that can't double-charge the payer).

If you supply a custom signatureStore, implement the optional addIfAbsent(signature, ttlMs) (atomic check-and-set, e.g. Redis SET NX) and delete(signature) methods: without addIfAbsent the replay check falls back to a non-atomic has()+add(), and without delete a failed settlement permanently burns the payment header for the TTL. The built-in InMemorySignatureStore implements both.

Two more notes on 0.2.0: onPaymentVerified fires after verification but before settlement — funds have not moved yet in the v2 flow, so use it for logging, not for granting access (the protected handler running is the paid signal). And for direct SolanaPayX402Bridge users, settlePayment() now returns a SettlementResult object ({ status: 'settled' | 'skipped' | 'failed', ... }) instead of string | null.

Examples

Dynamic Pricing

const pricing = { small: 1000000, medium: 5000000, large: 10000000 }

app.get('/api/data/:size',
  solanaPay402({
    rpcUrl: process.env.SOLANA_RPC_URL,
    recipient: process.env.MERCHANT_WALLET,
    getPaymentAmount: (req) => pricing[req.params.size] || null,
  }),
  (req, res) => {
    res.json({ data: `Content for ${req.params.size}` })
  }
)

USDC Payments

app.get('/api/premium',
  solanaPay402({
    rpcUrl: process.env.SOLANA_RPC_URL,
    recipient: process.env.MERCHANT_WALLET,
    splToken: {
      mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
      decimals: 6,
    },
    getPaymentAmount: () => 100, // $0.0001 USDC
  }),
  (req, res) => {
    res.json({ content: 'Paid with USDC' })
  }
)

Multi-Token Pricing

Accept multiple tokens on the same endpoint. The 402 response includes one entry per token in the accepts array, and the client picks which one to pay with.

app.get('/api/premium',
  solanaPay402({
    rpcUrl: process.env.SOLANA_RPC_URL,
    recipient: process.env.MERCHANT_WALLET,
    getPaymentAmount: () => 100, // base amount in USDC atomic units
    acceptedTokens: [
      { mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, label: 'USDC' },
      {
        mint: 'So11111111111111111111111111111111111111112',
        decimals: 9,
        label: 'SOL',
        // Convert USDC amount to SOL using live exchange rate
        amount: async (baseAmount) => {
          const price = await fetchSolPrice() // your price fetcher
          return Math.ceil(Number(baseAmount) / price)
        },
      },
    ],
  }),
  (req, res) => {
    res.json({ content: 'Paid with USDC or SOL' })
  }
)

The same acceptedTokens option works in the Next.js adapter.

Reading Payment Info After Verification

import { getPaymentInfo } from 'solana-pay-x402/express'

app.get('/api/premium',
  solanaPay402({ /* config */ }),
  (req, res) => {
    const payment = getPaymentInfo(req)
    res.json({
      content: 'Premium stuff',
      paidAmount: payment?.amount,
      txSignature: payment?.signature,
    })
  }
)

Client-Side: x402 v2

Use x402-solana/client for automatic payment handling. It wraps fetch() — if the server returns 402, it builds a transaction, asks the wallet to sign, and retries with the payment header. One line:

import { createX402Client } from 'x402-solana/client'

const client = createX402Client({
  wallet: phantomWallet, // any Solana wallet adapter
  network: 'solana-devnet',
})

// This handles 402 → sign → retry automatically
const response = await client.fetch('http://localhost:3000/api/premium')
const data = await response.json()

Client-Side: Solana Pay

For QR-based payments, the 402 response includes a solanaPay.url that you display as a QR code. After the user pays with their mobile wallet, send the transaction signature back:

// 1. Request endpoint, get 402
const res = await fetch('/api/premium')
const { solanaPay } = await res.json()

// 2. Show QR code with solanaPay.url
displayQR(solanaPay.url)

// 3. After user pays and you have the tx signature
const proof = btoa(JSON.stringify({ signature: txSig, scheme: 'exact' }))
const content = await fetch('/api/premium', {
  headers: { 'PAYMENT-SIGNATURE': proof }
})

Demo

The repo includes a working demo with both flows:

cd demo
SOLANA_NETWORK=devnet npx tsx server.ts

Open http://localhost:3000 — pick an endpoint, try x402 with Phantom browser extension or Solana Pay with the QR code.

How This Compares to x402-solana

solana-pay-x402 x402-solana
What it is Express + Next.js middleware Protocol implementation
Solana Pay QR Built-in Not included
x402 v2 Built-in Built-in
Multi-token Built-in with async price converters Manual
Frameworks Express, Next.js App Router Bring your own
Setup One-line middleware Wire it up yourself
Best for Ship fast Custom setups

This package uses x402-solana under the hood. If you're on Express or Next.js and want both payment flows without the plumbing, this is the shortcut.

Development

npm install
npm run build
npm test

Environment Variables

SOLANA_RPC_URL=https://api.devnet.solana.com
MERCHANT_WALLET=YOUR_WALLET_ADDRESS
SOLANA_NETWORK=devnet
PORT=3000

Disclaimer

This software is provided as-is, without warranty. It has not been independently audited. Use at your own risk, especially on mainnet with real funds.

This package relies on the PayAI Network facilitator for x402 v2 payment verification and settlement. The authors are not responsible for facilitator downtime, failed transactions, or lost funds.

Always test on devnet before deploying to mainnet. The authors are not liable for any financial losses resulting from the use of this software. See LICENSE for full terms.

License

MIT

Links

All Solana projects →