I spent two weeks integrating Stripe into a crypto data API. Two weeks of webhook hell, subscription logic, and fighting with their dashboard. Then my first user asked: “Can I just pay in SOL?”
That’s when it clicked. I’m building tools for crypto people. They already have wallets full of SOL. Why am I forcing them through traditional payment rails?
So I ripped out Stripe and built something simpler. User sends SOL to my wallet, my backend verifies the transaction on-chain, and it issues an API key. No payment processor, no KYC, no monthly subscription drama.
Here’s exactly how I built it.
1. Project Setup
You need three packages. Express for the server, @solana/web3.js for on-chain verification, and uuid for generating API keys.
npm init -y
npm install express @solana/web3.js uuid
Create your main server file:
import express from "express";
import { Connection, PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { v4 as uuidv4 } from "uuid";
const app = express();
app.use(express.json());
const connection = new Connection("https://api.mainnet-beta.solana.com", {
commitment: "confirmed",
});
const MERCHANT_WALLET = new PublicKey("YOUR_WALLET_ADDRESS_HERE");
const PRICE_IN_SOL = 0.1; // 0.1 SOL per API key
app.listen(3000, () => {
console.log("API server running on port 3000");
});
Nothing fancy yet. Just Express and a Solana connection.
2. Simple API Key Storage
I know, I know — you could use Postgres or Redis. But when I was prototyping, a simple in-memory store with a JSON backup worked fine. Start simple, scale later.
import fs from "fs";
interface ApiKeyRecord {
key: string;
wallet: string;
credits: number;
createdAt: string;
txSignature: string;
}
let apiKeys: ApiKeyRecord[] = [];
// Load from disk on startup
if (fs.existsSync("./keys.json")) {
apiKeys = JSON.parse(fs.readFileSync("./keys.json", "utf-8"));
}
function saveKeys() {
fs.writeFileSync("./keys.json", JSON.stringify(apiKeys, null, 2));
}
function createApiKey(wallet: string, txSignature: string, credits: number): string {
const key = `sk_${uuidv4().replace(/-/g, "")}`;
apiKeys.push({
key,
wallet,
credits,
createdAt: new Date().toISOString(),
txSignature,
});
saveKeys();
return key;
}
The sk_ prefix is intentional. It tells users (and your logs) that this is a secret key. I stole that pattern from Stripe, ironically.
3. Payment Verification Endpoint
This is the core of the whole thing. A user sends SOL to your wallet, then hits this endpoint with their transaction signature. Your backend verifies everything on-chain.
app.post("/api/verify-payment", async (req, res) => {
const { txSignature, walletAddress } = req.body;
if (!txSignature || !walletAddress) {
return res.status(400).json({ error: "Missing txSignature or walletAddress" });
}
// Check if this transaction was already used
const alreadyUsed = apiKeys.find((k) => k.txSignature === txSignature);
if (alreadyUsed) {
return res.status(400).json({ error: "Transaction already redeemed" });
}
try {
const tx = await connection.getParsedTransaction(txSignature, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (!tx || !tx.meta) {
return res.status(400).json({ error: "Transaction not found" });
}
if (tx.meta.err) {
return res.status(400).json({ error: "Transaction failed on-chain" });
}
// Find the SOL transfer to our wallet
const preBalance = tx.meta.preBalances;
const postBalance = tx.meta.postBalances;
const accounts = tx.transaction.message.accountKeys;
let receivedAmount = 0;
for (let i = 0; i < accounts.length; i++) {
if (accounts[i].pubkey.equals(MERCHANT_WALLET)) {
receivedAmount = (postBalance[i] - preBalance[i]) / LAMPORTS_PER_SOL;
break;
}
}
if (receivedAmount < PRICE_IN_SOL) {
return res.status(400).json({
error: `Insufficient payment. Expected ${PRICE_IN_SOL} SOL, received ${receivedAmount} SOL`,
});
}
// Calculate credits based on payment
const credits = Math.floor(receivedAmount / PRICE_IN_SOL) * 1000;
const apiKey = createApiKey(walletAddress, txSignature, credits);
return res.json({
apiKey,
credits,
message: "Payment verified. Your API key is ready.",
});
} catch (err) {
return res.status(500).json({ error: "Failed to verify transaction" });
}
});
I used to just check if the transaction existed. That’s wrong. You need to verify the exact amount reached your wallet. Otherwise someone could send 0.001 SOL and claim they paid.
4. API Key Middleware
Every protected route needs to check the API key and deduct credits. Here’s the middleware I use:
function requireApiKey(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing API key" });
}
const key = authHeader.split(" ")[1];
const record = apiKeys.find((k) => k.key === key);
if (!record) {
return res.status(401).json({ error: "Invalid API key" });
}
if (record.credits <= 0) {
return res.status(403).json({
error: "No credits remaining. Send more SOL to top up.",
});
}
// Deduct one credit per request
record.credits -= 1;
saveKeys();
req.apiKeyRecord = record;
next();
}
One credit per request. Simple. I tried usage-based pricing where different endpoints cost different amounts. It confused everyone. Flat rate per call is easier to reason about.
5. Protected Endpoints
Now build the actual API people are paying for. Here’s a sample endpoint that returns token price data:
app.get("/api/token/:mint/price", requireApiKey, async (req, res) => {
const { mint } = req.params;
try {
const response = await fetch(
`https://api.jup.ag/price/v2?ids=${mint}`
);
const data = await response.json();
if (!data.data[mint]) {
return res.status(404).json({ error: "Token not found" });
}
return res.json({
mint,
price: data.data[mint].price,
creditsRemaining: req.apiKeyRecord.credits,
});
} catch (err) {
return res.status(500).json({ error: "Failed to fetch price" });
}
});
app.get("/api/wallet/:address/balance", requireApiKey, async (req, res) => {
const { address } = req.params;
try {
const pubkey = new PublicKey(address);
const balance = await connection.getBalance(pubkey);
return res.json({
address,
balanceSol: balance / LAMPORTS_PER_SOL,
creditsRemaining: req.apiKeyRecord.credits,
});
} catch (err) {
return res.status(500).json({ error: "Invalid wallet address" });
}
});
I always return creditsRemaining in every response. Users shouldn’t have to call a separate endpoint to check their balance.
6. Rate Limiting
Even with API keys, you need rate limiting. One bad actor shouldn’t be able to hammer your server.
const rateLimits = new Map<string, { count: number; resetAt: number }>();
function rateLimit(maxRequests: number, windowMs: number) {
return (req, res, next) => {
const key = req.apiKeyRecord?.key || req.ip;
const now = Date.now();
const entry = rateLimits.get(key);
if (!entry || now > entry.resetAt) {
rateLimits.set(key, { count: 1, resetAt: now + windowMs });
return next();
}
if (entry.count >= maxRequests) {
return res.status(429).json({ error: "Rate limit exceeded. Slow down." });
}
entry.count++;
next();
};
}
// 60 requests per minute per API key
app.use("/api", rateLimit(60, 60 * 1000));
I set mine to 60 per minute. That’s generous enough for most use cases but tight enough to protect the server.
7. Webhook-Based Payment Detection
The verify-payment endpoint works, but it requires the user to send you the transaction signature. A better approach is detecting payments automatically using Helius webhooks.
// Register a webhook with Helius (do this once via their API)
// POST https://api.helius.dev/v0/webhooks
// {
// "webhookURL": "https://yourapi.com/api/helius-webhook",
// "transactionTypes": ["TRANSFER"],
// "accountAddresses": ["YOUR_WALLET_ADDRESS"],
// "webhookType": "enhanced"
// }
app.post("/api/helius-webhook", async (req, res) => {
const events = req.body;
for (const event of events) {
if (event.type !== "TRANSFER") continue;
const transfer = event.nativeTransfers?.find(
(t) => t.toUserAccount === MERCHANT_WALLET.toBase58()
);
if (!transfer) continue;
const solAmount = transfer.amount / LAMPORTS_PER_SOL;
if (solAmount < PRICE_IN_SOL) continue;
const credits = Math.floor(solAmount / PRICE_IN_SOL) * 1000;
const senderWallet = transfer.fromUserAccount;
// Check if we already processed this
const existing = apiKeys.find((k) => k.txSignature === event.signature);
if (existing) continue;
const apiKey = createApiKey(senderWallet, event.signature, credits);
console.log(`New payment from ${senderWallet}: ${solAmount} SOL → ${credits} credits`);
// In production, send the API key to the user via your frontend
}
res.status(200).send("OK");
});
Helius is my go-to for this. QuickNode has similar webhook support. Either works. The point is your backend gets notified the moment someone pays — no polling, no user action needed after the transfer.
8. Credit Top-Up Flow
Users will run out of credits. Make it easy to buy more. The same wallet can have multiple API keys, or you can let them top up an existing one:
app.post("/api/top-up", async (req, res) => {
const { txSignature, apiKey } = req.body;
const record = apiKeys.find((k) => k.key === apiKey);
if (!record) {
return res.status(404).json({ error: "API key not found" });
}
const alreadyUsed = apiKeys.some((k) => k.txSignature === txSignature);
if (alreadyUsed) {
return res.status(400).json({ error: "Transaction already used" });
}
const tx = await connection.getParsedTransaction(txSignature, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (!tx || !tx.meta || tx.meta.err) {
return res.status(400).json({ error: "Invalid transaction" });
}
const accounts = tx.transaction.message.accountKeys;
let receivedAmount = 0;
for (let i = 0; i < accounts.length; i++) {
if (accounts[i].pubkey.equals(MERCHANT_WALLET)) {
receivedAmount = (tx.meta.postBalances[i] - tx.meta.preBalances[i]) / LAMPORTS_PER_SOL;
break;
}
}
const newCredits = Math.floor(receivedAmount / PRICE_IN_SOL) * 1000;
record.credits += newCredits;
saveKeys();
return res.json({
credits: record.credits,
added: newCredits,
});
});
9. Pricing Strategy
I experimented with a few models. Here’s what I landed on:
- 0.1 SOL = 1,000 API calls for basic endpoints (price checks, balances)
- 0.5 SOL = 5,000 calls with a 10% bonus (5,500 actual credits)
- 1 SOL = 10,000 calls with a 20% bonus (12,000 actual credits)
Volume discounts work. They encourage bigger purchases and reduce the number of small transactions you process.
I also learned not to price in USD. Your users think in SOL. If SOL is $150 and you charge 0.1 SOL, that’s $15 for 1,000 calls — cheap enough that nobody thinks twice. If SOL moons to $300, your service is still worth it because your costs are denominated in fiat but your users' wealth is in SOL.
10. Production Considerations
A few things I learned the hard way:
Don’t trust finalized for everything. I started with finalized commitment for payment verification. It works but takes 30+ seconds. Users thought the payment failed. Switch to confirmed — it’s fast enough and practically never rolls back.
Store transaction signatures forever. If you don’t, someone can reuse the same transaction to get multiple API keys. I had this bug in production for two days before someone exploited it.
Use a real database in production. The JSON file approach is fine for prototyping. But the moment you have 100+ users, you want SQLite at minimum. I use Turso because it’s SQLite with replication and it’s free for small projects.
Add a status endpoint that doesn’t require auth:
app.get("/api/status", (req, res) => {
res.json({
status: "operational",
pricePerThousandCalls: `${PRICE_IN_SOL} SOL`,
paymentWallet: MERCHANT_WALLET.toBase58(),
});
});
Users need to know where to send SOL before they have an API key.
On-chain payments remove so much friction for crypto-native users. No sign-up form, no credit card, no waiting for approval. They send SOL, they get an API key. That’s the whole flow.
I’ve been running this model for a few months now, and the conversion rate is way higher than when I used Stripe. Turns out, when your users already have the payment method in their browser extension, buying becomes a one-click decision instead of a five-minute checkout process.
Build for your audience. If your audience lives on-chain, your payments should too.