You don't need Stripe. You don't need Gumroad. You don't need anyone's permission or a 5% platform cut.
With Solana, you can accept crypto payments directly on your website — sub-cent fees, instant settlement, and full control over the entire flow. I built this exact system for my own product site, and in this tutorial I'll walk you through every piece of it.
Here's what we'll build:
- A checkout page that generates a unique payment address
- On-chain transaction verification using
@solana/web3.js - Time-limited HMAC download tokens so buyers can't share links
This is the exact architecture running on devtools-site-delta.vercel.app right now, where I sell developer tools for SOL.
Why Solana?
Stripe takes 2.9% + 30 cents. Gumroad takes up to 10%. PayPal holds your money for weeks if you're a new seller.
Solana transactions cost roughly $0.00025 and settle in under a second. There's no chargeback risk, no account freezes, and no KYC hoops for the seller. For digital products, it's an ideal payment rail.
Step 1: Set Up Your Fee Wallet
Your fee wallet is where all payments land. Generate one with the Solana CLI or any wallet app (Phantom, Solflare, etc.).
solana-keygen new --outfile ~/fee-wallet.json
solana address -k ~/fee-wallet.json
Store the public key in your environment variables:
FEE_WALLET=NaTTUfDDQ8U1RBqb9q5rz6vJ22cWrrT5UAsXuxnb2Wr
SOL_RPC_URL=https://api.mainnet-beta.solana.com
You'll reference FEE_WALLET in both your frontend checkout and your backend verification.
Step 2: Build the Checkout Page
The checkout page shows the buyer how much SOL to send and where. For a clean UX, generate a QR code with the Solana Pay URI scheme.
// pages/checkout.js (Next.js example)
import { useEffect, useState } from 'react';
import QRCode from 'qrcode.react';
export default function Checkout({ product }) {
const [solPrice, setSolPrice] = useState(null);
const walletAddress = process.env.NEXT_PUBLIC_FEE_WALLET;
useEffect(() => {
// Fetch current SOL/USD price to calculate cost
fetch('https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd')
.then(r => r.json())
.then(data => {
const solUsd = data.solana.usd;
setSolPrice((product.priceUsd / solUsd).toFixed(4));
});
}, [product.priceUsd]);
if (!solPrice) return <p>Loading price...</p>;
const payUri = `solana:${walletAddress}?amount=${solPrice}&label=${product.name}`;
return (
<div className="checkout-container">
<h2>Send {solPrice} SOL to complete your purchase</h2>
<QRCode value={payUri} size={220} />
<p className="wallet-display">{walletAddress}</p>
<p className="fine-print">Transaction typically confirms in under 5 seconds.</p>
<VerifyButton wallet={walletAddress} amount={solPrice} product={product.slug} />
</div>
);
}
The buyer scans the QR or copies the address, sends SOL from any wallet, and clicks "Verify Payment."
Step 3: Verify the Transaction On-Chain
This is the core of the system. Your API endpoint queries the Solana blockchain directly using @solana/web3.js to confirm a real transfer arrived.
// api/verify-payment.js
import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import crypto from 'crypto';
const connection = new Connection(process.env.SOL_RPC_URL, 'confirmed');
const FEE_WALLET = new PublicKey(process.env.FEE_WALLET);
const HMAC_SECRET = process.env.HMAC_SECRET; // random 64-char string
export default async function handler(req, res) {
const { expectedAmount, productSlug } = req.body;
try {
// Fetch recent transactions to the fee wallet
const signatures = await connection.getSignaturesForAddress(FEE_WALLET, {
limit: 20,
});
for (const sigInfo of signatures) {
const tx = await connection.getParsedTransaction(sigInfo.signature, {
maxSupportedTransactionVersion: 0,
});
if (!tx || !tx.meta || tx.meta.err) continue;
// Check each instruction for a SOL transfer to our wallet
for (const instruction of tx.transaction.message.instructions) {
if (
instruction.programId.toString() === '11111111111111111111111111111111' &&
instruction.parsed?.type === 'transfer' &&
instruction.parsed.info.destination === FEE_WALLET.toString()
) {
const transferredSol = instruction.parsed.info.lamports / LAMPORTS_PER_SOL;
// Allow 2% slippage for price fluctuation
if (transferredSol >= expectedAmount * 0.98) {
// Payment verified — generate download token
const token = generateDownloadToken(productSlug);
return res.status(200).json({
verified: true,
downloadUrl: `/api/download?token=${token}`,
signature: sigInfo.signature,
});
}
}
}
}
return res.status(402).json({ verified: false, message: 'Payment not found yet. Try again in a few seconds.' });
} catch (err) {
console.error('Verification error:', err);
return res.status(500).json({ verified: false, message: 'Verification failed' });
}
}
function generateDownloadToken(productSlug) {
const expires = Date.now() + 1000 * 60 * 30; // 30 minutes
const payload = `${productSlug}:${expires}`;
const hmac = crypto.createHmac('sha256', HMAC_SECRET).update(payload).digest('hex');
return Buffer.from(`${payload}:${hmac}`).toString('base64url');
}
Key details:
- We pull the last 20 transactions to the fee wallet and look for a matching transfer amount.
- A 2% slippage tolerance accounts for price changes between the buyer loading the page and sending the TX.
- On match, we generate an HMAC-signed download token with a 30-minute expiry.
Step 4: Secure the Download Endpoint
The download token prevents link sharing. When the buyer hits the download URL, we verify the HMAC and check expiry.
// api/download.js
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
const HMAC_SECRET = process.env.HMAC_SECRET;
const PRODUCT_FILES = {
'sol-bot-source': 'sol-telegram-bot-source.zip',
'sol-grid-bot': 'sol-grid-bot.zip',
'sol-defi-toolkit': 'sol-defi-toolkit.zip',
'prompt-pack': 'ai-prompt-pack.zip',
};
export default function handler(req, res) {
const { token } = req.query;
if (!token) return res.status(400).json({ error: 'Missing token' });
try {
const decoded = Buffer.from(token, 'base64url').toString();
const parts = decoded.split(':');
const hmac = parts.pop();
const expires = parseInt(parts.pop());
const productSlug = parts.join(':');
// Verify HMAC
const expectedHmac = crypto
.createHmac('sha256', HMAC_SECRET)
.update(`${productSlug}:${expires}`)
.digest('hex');
if (hmac !== expectedHmac) {
return res.status(403).json({ error: 'Invalid token' });
}
// Check expiry
if (Date.now() > expires) {
return res.status(410).json({ error: 'Token expired. Please re-verify your payment.' });
}
// Serve the file
const filename = PRODUCT_FILES[productSlug];
if (!filename) return res.status(404).json({ error: 'Product not found' });
const filePath = path.join(process.cwd(), 'products', filename);
const fileStream = fs.createReadStream(filePath);
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
res.setHeader('Content-Type', 'application/zip');
fileStream.pipe(res);
} catch (err) {
return res.status(400).json({ error: 'Malformed token' });
}
}
This guarantees:
- No free downloads — every download requires a valid HMAC token
- No link sharing — tokens expire after 30 minutes
- No database needed — the token itself carries the auth state
Step 5: Add a Polling UX
Buyers shouldn't have to manually click "verify" — poll the verification endpoint automatically after they send the SOL.
// components/VerifyButton.js
import { useState, useRef } from 'react';
export default function VerifyButton({ wallet, amount, product }) {
const [status, setStatus] = useState('idle');
const [downloadUrl, setDownloadUrl] = useState(null);
const intervalRef = useRef(null);
const startPolling = () => {
setStatus('polling');
let attempts = 0;
intervalRef.current = setInterval(async () => {
attempts++;
if (attempts > 60) {
clearInterval(intervalRef.current);
setStatus('timeout');
return;
}
const res = await fetch('/api/verify-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedAmount: parseFloat(amount), productSlug: product }),
});
const data = await res.json();
if (data.verified) {
clearInterval(intervalRef.current);
setStatus('verified');
setDownloadUrl(data.downloadUrl);
}
}, 3000); // check every 3 seconds
};
return (
<div>
{status === 'idle' && (
<button onClick={startPolling}>I've Sent the SOL — Verify Payment</button>
)}
{status === 'polling' && <p>Checking for your transaction on-chain...</p>}
{status === 'verified' && (
<a href={downloadUrl} className="download-btn">Download Your Product</a>
)}
{status === 'timeout' && <p>Transaction not found. Double-check the amount and try again.</p>}
</div>
);
}
Production Considerations
Use a dedicated RPC. The public Solana RPC (api.mainnet-beta.solana.com) rate-limits aggressively. Use Helius, QuickNode, or Triton for production traffic.
Track used signatures. Store verified transaction signatures in a simple JSON file or KV store to prevent a single payment from generating multiple download tokens.
// Simple signature tracking
const usedSignatures = new Set(JSON.parse(fs.readFileSync('used-sigs.json', 'utf8')));
if (usedSignatures.has(sigInfo.signature)) continue; // skip already-used TX
// After successful verification:
usedSignatures.add(sigInfo.signature);
fs.writeFileSync('used-sigs.json', JSON.stringify([...usedSignatures]));
Handle price volatility. Lock the SOL price for 10 minutes per checkout session. Store the expected amount server-side and validate against it.
Add webhook notifications. Use a Solana websocket subscription to get instant alerts:
connection.onAccountChange(FEE_WALLET, (accountInfo) => {
console.log('New transaction to fee wallet detected!');
// Trigger verification immediately
});
See It in Action
This exact system powers all the product pages on devtools-site-delta.vercel.app, including the Solana Bot Source Code, grid trading bots, and DeFi toolkits.
I also use SOL-based payments in @solscanitbot on Telegram, where premium subscriptions and paid features are all verified on-chain.
Wrapping Up
The full stack is:
- Frontend: Show wallet address + QR code, poll for verification
- Verification API: Query Solana RPC, match transfer amount, generate HMAC token
- Download API: Validate HMAC, check expiry, stream the file
- Zero dependencies on payment processors. No Stripe dashboard, no Gumroad fees, no permission needed.
If you're selling digital products and want to keep 100% of revenue, this is the way. The code above is production-ready — adapt the product slugs and file paths to your own setup, deploy to Vercel or any Node.js host, and start accepting SOL.
Questions? Reach out on Telegram or drop a comment below.
Top comments (0)