I tried to sell access to a trading signals group last year. Nothing fancy — just a monthly fee for entry to a private Telegram channel. I set up Stripe, submitted my business info, and within 48 hours got the dreaded email: “Your account has been restricted due to prohibited business activity.” Crypto-related products are a nightmare with traditional payment processors.
So I built my own subscription system using Solana Pay. It took a weekend. No KYC on the seller side, no bank approvals, instant settlement. Here’s exactly how I did it.
How Solana Pay Works
Solana Pay is surprisingly simple. A customer scans a QR code (or clicks a payment link), their wallet creates a transaction, and you verify that transaction on-chain. That’s it.
The key pieces are:
- Transfer requests — you specify the recipient, amount, and a unique reference
- Reference keys — random public keys you attach to a transaction so you can find it later
- Transaction verification — you watch the chain for a transaction matching your reference
The reference key is the magic part. You generate a unique one per payment, embed it in the QR code, and then poll the network to see when it shows up in a confirmed transaction.
Generating a Payment Request
First, install the dependencies:
npm install @solana/pay @solana/web3.js bignumber.js express
Here’s how I generate a payment link with a unique reference:
import { Keypair, PublicKey } from "@solana/web3.js";
import { encodeURL } from "@solana/pay";
import BigNumber from "bignumber.js";
const MERCHANT_WALLET = new PublicKey("YOUR_WALLET_ADDRESS_HERE");
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
function createPaymentRequest(amountInUSDC: number) {
const reference = Keypair.generate().publicKey;
const url = encodeURL({
recipient: MERCHANT_WALLET,
amount: new BigNumber(amountInUSDC),
splToken: USDC_MINT,
reference,
label: "Pro Subscription",
message: "30-day access to trading signals",
});
return { url, reference: reference.toBase58() };
}
The reference is a throwaway public key. Nobody owns it — it just exists so you can search for the transaction later. I store it in a database alongside the user’s info.
Verifying Payments On-Chain
This is where most people get stuck. You need to poll the Solana network and check if a transaction containing your reference has been confirmed.
import { Connection } from "@solana/web3.js";
import { findReference, validateTransfer } from "@solana/pay";
const connection = new Connection("https://api.mainnet-beta.solana.com");
async function checkPayment(reference: string, expectedAmount: number) {
const referenceKey = new PublicKey(reference);
try {
const signatureInfo = await findReference(connection, referenceKey, {
finality: "confirmed",
});
const response = await validateTransfer(connection, signatureInfo.signature, {
recipient: MERCHANT_WALLET,
amount: new BigNumber(expectedAmount),
splToken: USDC_MINT,
reference: referenceKey,
});
return { verified: true, signature: signatureInfo.signature };
} catch (err) {
return { verified: false, signature: null };
}
}
I run this check every 3 seconds after showing the QR code. Once findReference returns a result, I validate the amount and recipient. If it all checks out, the subscription is active.
The Subscription Model
Every subscription gets stored with an activation date and expiry. Nothing complicated:
interface Subscription {
userId: string;
walletAddress: string;
reference: string;
activatedAt: Date | null;
expiresAt: Date | null;
amountPaid: number;
txSignature: string | null;
status: "pending" | "active" | "expired";
}
function activateSubscription(sub: Subscription, txSignature: string): Subscription {
const now = new Date();
const expiry = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
return {
...sub,
activatedAt: now,
expiresAt: expiry,
txSignature,
status: "active",
};
}
function isSubscriptionActive(sub: Subscription): boolean {
if (sub.status !== "active" || !sub.expiresAt) return false;
return new Date() < sub.expiresAt;
}
I used to store this in PostgreSQL, but honestly a simple JSON file or SQLite works fine when you’re starting out. Don’t over-engineer the storage layer when you have 50 subscribers.
Access Control Middleware
Here’s the Express middleware that gates your premium endpoints:
import { Request, Response, NextFunction } from "express";
function requireActiveSubscription(req: Request, res: Response, next: NextFunction) {
const userId = req.headers["x-user-id"] as string;
if (!userId) {
return res.status(401).json({ error: "Missing user ID" });
}
const subscription = getSubscriptionByUserId(userId); // your DB lookup
if (!subscription || !isSubscriptionActive(subscription)) {
return res.status(403).json({
error: "Subscription expired or not found",
renewUrl: "/api/subscribe",
});
}
next();
}
// Protect your premium routes
app.get("/api/signals", requireActiveSubscription, (req, res) => {
res.json({ signals: getLatestSignals() });
});
When someone’s subscription expires, they get a 403 with a link to renew. Clean and simple.
Why USDC Over SOL
I made the mistake of pricing my first version in SOL. A subscriber paid 1.5 SOL when SOL was at $90. Two weeks later SOL hit $140 and I’d basically given away a month of access for $60 worth of value when I was charging $135 worth.
Price in USDC. Always. SOL is great for gas fees, but for subscription pricing you want stability. Your subscribers don’t want to pay more when SOL drops, and you don’t want to earn less when SOL pumps.
The code change is minimal — just include the splToken parameter pointing to the USDC mint address, which I already showed above.
Subscriber Dashboard
I built a simple admin endpoint to track revenue and active subscribers:
app.get("/api/admin/dashboard", requireAdmin, (req, res) => {
const allSubs = getAllSubscriptions();
const active = allSubs.filter((s) => isSubscriptionActive(s));
const totalRevenue = allSubs
.filter((s) => s.status === "active" || s.status === "expired")
.reduce((sum, s) => sum + s.amountPaid, 0);
const expiringIn3Days = active.filter((s) => {
const daysLeft = (s.expiresAt!.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return daysLeft <= 3;
});
res.json({
totalSubscribers: allSubs.length,
activeSubscribers: active.length,
totalRevenue: `$${totalRevenue} USDC`,
expiringIn3Days: expiringIn3Days.length,
});
});
Nothing fancy. I check this once a day. If you want charts and graphs, throw the data into a spreadsheet — don’t build a whole analytics platform for 200 users.
Discord Alerts on New Subscriptions
I want to know the second someone subscribes. Here’s a simple webhook notification:
async function notifyNewSubscription(sub: Subscription) {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL!;
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `New subscriber! Wallet: ${sub.walletAddress.slice(0, 8)}... | Amount: $${sub.amountPaid} USDC | Expires: ${sub.expiresAt?.toISOString().split("T")[0]}`,
}),
});
}
You can do the same for Telegram with their Bot API. I use Discord because my community already lives there.
Sending Renewal Reminders
There’s no auto-charge in crypto. That’s the one real downside. Users have to manually renew. So you need to remind them.
I run a daily cron job that checks for subscriptions expiring in the next 3 days:
async function sendRenewalReminders() {
const active = getAllSubscriptions().filter((s) => isSubscriptionActive(s));
for (const sub of active) {
const daysLeft = (sub.expiresAt!.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
if (daysLeft <= 3 && daysLeft > 0) {
const { url } = createPaymentRequest(sub.amountPaid);
await sendReminderEmail(sub.userId, {
daysLeft: Math.ceil(daysLeft),
renewalLink: url.toString(),
});
}
}
}
I send reminders at 3 days, 1 day, and on expiry day. My renewal rate is around 65%, which honestly isn’t bad for a manual process.
Use Cases That Work
I’ve seen this pattern work for a bunch of crypto-native products:
- Alpha groups — trading signals, whale watching alerts, market analysis
- Premium API access — rate-limited free tier, paid tier for serious users
- Gated content — research reports, educational courses, private repos
- Tool access — bots, dashboards, scanners that cost money to run
- Community membership — Discord/Telegram groups with real value
The common thread is that the audience already has crypto wallets. You’re not asking normies to set up Phantom just to buy your course. If your audience is crypto-native, this converts better than Stripe because there’s zero friction.
Crypto Payments vs Traditional Processors
I won’t pretend crypto subscriptions are perfect. Here’s where I stand:
What’s better: No chargebacks (this alone is huge), no payment processor blocking your business, instant settlement, global by default, no monthly fees eating your margins.
What’s worse: No automatic recurring charges, users need a wallet, volatile if you price in SOL, no built-in invoicing or tax handling.
For a crypto-native audience selling a crypto-related product, Solana Pay wins. For a SaaS selling to Fortune 500 companies, obviously use Stripe. Pick the tool that matches your audience.
Putting It All Together
Here’s the full Express app structure:
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/subscribe", async (req, res) => {
const { userId, walletAddress } = req.body;
const { url, reference } = createPaymentRequest(9.99);
const sub: Subscription = {
userId,
walletAddress,
reference,
activatedAt: null,
expiresAt: null,
amountPaid: 9.99,
txSignature: null,
status: "pending",
};
saveSubscription(sub);
res.json({ paymentUrl: url.toString(), reference });
});
app.get("/api/subscribe/verify/:reference", async (req, res) => {
const { reference } = req.params;
const sub = getSubscriptionByReference(reference);
if (!sub) return res.status(404).json({ error: "Not found" });
const result = await checkPayment(reference, sub.amountPaid);
if (result.verified) {
const activated = activateSubscription(sub, result.signature!);
saveSubscription(activated);
await notifyNewSubscription(activated);
return res.json({ status: "active", expiresAt: activated.expiresAt });
}
res.json({ status: "pending" });
});
app.listen(3000, () => console.log("Subscription server running on :3000"));
The flow is: user hits /subscribe, gets a payment URL, pays in their wallet, frontend polls /verify, and once confirmed they’re in.
Crypto payments remove gatekeepers. That’s the whole point. I spent two weeks fighting Stripe’s compliance team before I spent one weekend building this. For crypto-native products, the conversion rate is actually higher because your users already have wallets loaded with USDC. They don’t want to type in a credit card number — they want to click approve in Phantom and be done.
It’s not perfect. You lose automatic renewals and you have to remind people to pay again. But you gain something more important: nobody can shut you down.