← Back to Blog
DevOps2026-05-10Β·85 min read

How I added Solana Pay USDC to a SaaS app β€” a real implementation, not a tutorial

By jason rauch

Direct Solana Pay Integration: Reference-Based Verification and Production Architecture for SaaS

Current Situation Analysis

Global SaaS platforms face a structural inefficiency in payment processing. Traditional fiat rails, while mature, impose geographic restrictions, variable interchange fees, and settlement delays that complicate unit economics for international users. Developers often accept these constraints as unavoidable, integrating standard processors without evaluating alternative settlement layers that align better with digital-native products.

The misconception is that cryptocurrency payments require complex infrastructure, heavy third-party abstractions, or expose merchants to volatility. In reality, stablecoin rails on high-throughput chains like Solana offer deterministic settlement with fees measured in fractions of a cent. The barrier is not technical feasibility but implementation complexity; many teams rely on opaque wrappers that obscure the underlying protocol, introducing vendor lock-in and limiting control over verification logic.

A direct implementation of the Solana Pay specification allows engineering teams to build lean, auditable payment flows. By generating unique reference keys for each transaction and verifying on-chain activity directly, systems can achieve instant credit issuance without intermediaries. This approach reduces payment costs to near-zero, eliminates chargeback risks, and provides a seamless experience for users holding USDC, regardless of their location.

WOW Moment: Key Findings

The operational impact of switching to a direct Solana Pay integration becomes evident when comparing cost structures, settlement latency, and global accessibility against traditional processors. The following comparison highlights the technical and economic advantages of the reference-based verification model.

Metric Traditional Processor (e.g., Stripe) Direct Solana Pay (USDC) Impact
Transaction Fee ~2.9% + $0.30 < $0.001 (Network fee) Margins improve significantly on micro-transactions and high-volume tiers.
Settlement Time T+2 to T+7 days Instant (Seconds) Cash flow optimization; immediate service provisioning.
Global Reach Restricted by country/region Borderless (Wallet-based) Access to underserved markets; no KYC friction for the merchant.
Chargeback Risk High (User disputes) None (Irreversible) Elimination of fraud management overhead and reserve holds.
Integration Complexity Low (SDKs) Medium (Direct Spec) Requires custom verification logic but removes dependency on processor uptime.
Volatility Exposure None (Fiat) None (Stablecoin) USDC peg stability removes FX risk while retaining crypto benefits.

This data demonstrates that Solana Pay is not merely a "crypto alternative" but a superior settlement layer for digital goods. The ability to verify payments via unique references ensures security without sacrificing the speed or cost benefits of the underlying blockchain.

Core Solution

Implementing Solana Pay requires adherence to the specification's reference-based verification pattern. The architecture relies on the merchant backend generating a unique keypair for each payment request. The public key of this reference is embedded in the payment URL, allowing the backend to listen for transactions that include this reference. Once a transaction is detected, the backend verifies the signature, amount, and recipient before fulfilling the order.

Architecture Decisions

  1. Reference Keypair Generation: Each payment session must have a unique reference. This prevents replay attacks and allows precise matching of on-chain activity to internal orders. The reference secret key must never leave the backend.
  2. Direct RPC Interaction: Avoid third-party payment gateways. Direct interaction with a Solana RPC node ensures full control over verification logic and eliminates dependency on external service availability.
  3. Polling vs. WebSockets: While polling is simpler to implement, production systems should use WebSocket subscriptions (onSignature) for real-time updates, with polling as a fallback mechanism to handle network interruptions.
  4. Idempotency: Payment verification must be idempotent. The system should check the transaction status before crediting the user to prevent duplicate fulfillment in case of retries or reorgs.

Implementation Guide

The following TypeScript implementation demonstrates a modular approach to Solana Pay integration. This code defines a PaymentOrchestrator that manages session creation and a TransactionVerifier that handles on-chain validation.

Dependencies:

npm install @solana/web3.js @solana/spl-token

Type Definitions:

import { Keypair, PublicKey, Connection, TransactionSignature } from '@solana/web3.js';

interface PaymentSession {
    sessionId: string;
    referencePubkey: PublicKey;
    merchantPubkey: PublicKey;
    amountUsdc: number;
    memo: string;
    status: 'pending' | 'verified' | 'expired';
}

interface VerificationResult {
    isValid: boolean;
    signature: TransactionSignature;
    slot: number;
    error?: string;
}

Payment Orchestrator:

export class PaymentOrchestrator {
    private connection: Connection;
    private merchantKeypair: Keypair;
    private usdcMint: PublicKey;

    constructor(rpcEndpoint: string, merchantSecret: string) {
        this.connection = new Connection(rpcEndpoint, 'confirmed');
        this.merchantKeypair = Keypair.fromSecretKey(Buffer.from(merchantSecret, 'base64'));
        // USDC Mint Address on Mainnet
        this.usdcMint = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
    }

    /**
     * Creates a new payment session and returns the Solana Pay URL.
     * The reference keypair is generated internally and stored securely.
     */
    public async createPaymentSession(orderId: string, amountUsdc: number): Promise<{ qrUrl: string; session: PaymentSession }> {
        const referenceKeypair = Keypair.generate();
        const sessionId = referenceKeypair.publicKey.toBase58();

        // Construct Solana Pay URL per specification
        // Format: solana:<recipient>?amount=<amount>&reference=<reference>&label=<label>&message=<message>
        const amountLamports = amountUsdc * 1_000_000; // USDC has 6 decimals
        const label = encodeURIComponent('SaaS-Checkout');
        const message = encodeURIComponent(orderId);

        const solanaPayUrl = `solana:${this.merchantKeypair.publicKey.toBase58()}?amount=${amountLamports}&reference=${referenceKeypair.publicKey.toBase58()}&label=${label}&message=${message}`;

        const session: PaymentSession = {
            sessionId,
            referencePubkey: referenceKeypair.publicKey,
            merchantPubkey: this.merchantKeypair.publicKey,
            amountUsdc,
            memo: orderId,
            status: 'pending',
        };

        // TODO: Persist session and reference secret key in secure storage (e.g., Vault, Encrypted DB)
        // await this.db.saveSession(session, referenceKeypair.secretKey);

        return { qrUrl: solanaPayUrl, session };
    }
}

Transaction Verifier:

export class TransactionVerifier {
    private connection: Connection;
    private merchantKeypair: Keypair;

    constructor(connection: Connection, merchantKeypair: Keypair) {
        this.connection = connection;
        this.merchantKeypair = merchantKeypair;
    }

    /**
     * Verifies a transaction signature against the payment session.
     * Checks recipient, amount, reference, and memo.
     */
    public async verifyTransaction(signature: TransactionSignature, session: PaymentSession): Promise<VerificationResult> {
        try {
            const txDetails = await this.connection.getParsedTransaction(signature, {
                maxSupportedTransactionVersion: 0,
                commitment: 'confirmed',
            });

            if (!txDetails) {
                return { isValid: false, signature, slot: 0, error: 'Transaction not found' };
            }

            // 1. Verify Recipient
            const recipient = this.extractRecipient(txDetails);
            if (!recipient || !recipient.equals(this.merchantKeypair.publicKey)) {
                return { isValid: false, signature, slot: txDetails.slot, error: 'Invalid recipient' };
            }

            // 2. Verify Amount (USDC)
            const receivedAmount = this.extractUsdcAmount(txDetails, this.merchantKeypair.publicKey);
            const expectedAmount = session.amountUsdc * 1_000_000;
            if (receivedAmount < expectedAmount) {
                return { isValid: false, signature, slot: txDetails.slot, error: 'Insufficient amount' };
            }

            // 3. Verify Reference
            const hasReference = this.containsReference(txDetails, session.referencePubkey);
            if (!hasReference) {
                return { isValid: false, signature, slot: txDetails.slot, error: 'Missing reference' };
            }

            // 4. Verify Memo (Optional but recommended for order matching)
            const memoMatches = this.verifyMemo(txDetails, session.memo);
            if (!memoMatches) {
                return { isValid: false, signature, slot: txDetails.slot, error: 'Memo mismatch' };
            }

            return { isValid: true, signature, slot: txDetails.slot };

        } catch (error) {
            return { isValid: false, signature, slot: 0, error: `Verification failed: ${error.message}` };
        }
    }

    private extractRecipient(tx: any): PublicKey | null {
        // Parse transaction instructions to find USDC transfer to merchant
        // Implementation depends on transaction structure (SPL Token transfers)
        // Simplified for brevity; production code must parse inner instructions
        return null; 
    }

    private extractUsdcAmount(tx: any, recipient: PublicKey): number {
        // Parse pre/post balances or token transfers to determine amount received
        return 0;
    }

    private containsReference(tx: any, reference: PublicKey): boolean {
        // Check if reference pubkey appears in account keys or instruction data
        return false;
    }

    private verifyMemo(tx: any, expectedMemo: string): boolean {
        // Check for Memo program instruction
        return false;
    }
}

Usage Flow:

async function processPayment(orderId: string, amount: number) {
    const orchestrator = new PaymentOrchestrator(process.env.RPC_URL, process.env.MERCHANT_SECRET);
    const { qrUrl, session } = await orchestrator.createPaymentSession(orderId, amount);

    // Render QR code with qrUrl in frontend
    console.log('Scan QR:', qrUrl);

    // Backend monitors for transaction
    // In production, use WebSocket subscription for real-time updates
    const verifier = new TransactionVerifier(orchestrator['connection'], orchestrator['merchantKeypair']);
    
    // Simulated signature detection
    const signature = await waitForSignature(session.referencePubkey);
    const result = await verifier.verifyTransaction(signature, session);

    if (result.isValid) {
        await fulfillOrder(orderId);
        console.log('Payment verified and order fulfilled.');
    } else {
        console.error('Verification failed:', result.error);
    }
}

Pitfall Guide

  1. Public RPC Rate Limiting

    • Explanation: Relying on public RPC endpoints for transaction monitoring introduces unpredictable throttling. During high network congestion, rate limits can delay verification, causing payment timeouts and poor user experience.
    • Fix: Provision a dedicated RPC endpoint from a reputable provider. Ensure the endpoint supports WebSocket subscriptions and has sufficient throughput for your transaction volume.
  2. Reference Key Exposure

    • Explanation: The reference secret key must remain strictly within the backend. If exposed, an attacker could forge transactions or manipulate payment verification.
    • Fix: Generate reference keypairs server-side and store the secret key in encrypted storage (e.g., HashiCorp Vault, AWS KMS). Only the public key should be included in the Solana Pay URL.
  3. Decimal Mismatch Errors

    • Explanation: USDC uses 6 decimal places, while SOL uses 9. Incorrectly converting amounts can lead to verification failures or underpayment acceptance.
    • Fix: Explicitly handle token decimals. Multiply USDC amounts by 1,000,000 when constructing the payment URL and when verifying received amounts. Use @solana/spl-token utilities for safe decimal handling.
  4. Race Conditions in Fulfillment

    • Explanation: Concurrent verification attempts or retries can lead to duplicate order fulfillment if the system does not check the current state before crediting.
    • Fix: Implement idempotency checks. Before fulfilling an order, verify that the transaction has not already been processed. Use database constraints or atomic updates to prevent double-crediting.
  5. Ignoring Transaction Finality

    • Explanation: Verifying transactions at the confirmed commitment level may expose the system to rare reorgs where a transaction is dropped or reordered.
    • Fix: For high-value transactions, wait for finalized commitment before fulfillment. For micro-transactions, confirmed may be acceptable, but document the risk tolerance.
  6. Missing Memo Validation

    • Explanation: Failing to validate the memo field can allow attackers to reuse valid transactions for different orders if the reference check is insufficient.
    • Fix: Always include a unique memo (e.g., order ID) in the payment URL and verify it matches the expected value during transaction validation.
  7. Polling Inefficiency

    • Explanation: Continuous polling of RPC endpoints for transaction status consumes resources and increases latency.
    • Fix: Use WebSocket subscriptions (connection.onSignature) for real-time notifications. Implement a fallback polling mechanism to handle connection drops, ensuring robustness without sacrificing performance.

Production Bundle

Action Checklist

  • Provision a dedicated Solana RPC endpoint with WebSocket support.
  • Generate and securely store the merchant keypair; never commit secrets to version control.
  • Implement reference keypair generation for each payment session.
  • Construct Solana Pay URLs adhering to the specification format.
  • Develop transaction verification logic covering recipient, amount, reference, and memo.
  • Handle USDC decimal conversion explicitly (6 decimals).
  • Implement idempotency checks to prevent duplicate fulfillment.
  • Set up monitoring and alerting for verification failures and RPC health.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
High-Volume Micro-Transactions WebSocket Subscription + confirmed Low latency and cost; confirmed is sufficient for small amounts. Minimal RPC cost; high throughput.
High-Value Enterprise Orders WebSocket + finalized Commitment Ensures transaction immutability; mitigates reorg risk. Slightly higher latency; no additional cost.
Global User Base Direct Solana Pay Integration Borderless access; no geographic restrictions or FX fees. Near-zero fees; improved conversion.
Legacy System Integration Wrapper Service with Direct Fallback Allows gradual migration; maintains compatibility. Higher initial cost; reduces long-term dependency.

Configuration Template

# .env.production
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
SOLANA_WS_URL=wss://api.mainnet-beta.solana.com
MERCHANT_SECRET_KEY=<Base64EncodedMerchantSecret>
USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
VERIFICATION_COMMITMENT=confirmed
IDEMPOTENCY_WINDOW_MS=300000

Quick Start Guide

  1. Initialize Project: Create a new TypeScript project and install @solana/web3.js and @solana/spl-token.
  2. Configure RPC: Set up environment variables with your dedicated RPC endpoint and merchant secret key.
  3. Implement Orchestrator: Use the PaymentOrchestrator class to generate payment sessions and QR codes.
  4. Deploy Verifier: Run the TransactionVerifier as a background service or serverless function to monitor for incoming transactions.
  5. Test on Devnet: Validate the flow using Solana Devnet to ensure URL construction, verification logic, and fulfillment work correctly before deploying to Mainnet.