solana-pay-x402
v0.2.0
Published
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.
Maintainers
Readme
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-x402Express
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 a402({ 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 viaonSettlementFailed.
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.tsOpen 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 testEnvironment Variables
SOLANA_RPC_URL=https://api.devnet.solana.com
MERCHANT_WALLET=YOUR_WALLET_ADDRESS
SOLANA_NETWORK=devnet
PORT=3000Disclaimer
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
