DEV Community

TateLyman
TateLyman

Posted on

How to Accept Solana Payments on Your Website (No Stripe, No Gumroad)

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:

  1. A checkout page that generates a unique payment address
  2. On-chain transaction verification using @solana/web3.js
  3. 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
Enter fullscreen mode Exit fullscreen mode

Store the public key in your environment variables:

FEE_WALLET=NaTTUfDDQ8U1RBqb9q5rz6vJ22cWrrT5UAsXuxnb2Wr
SOL_RPC_URL=https://api.mainnet-beta.solana.com
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]));
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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)