The Problem
Pricing API-based SaaS products is deceptively complex. You need to track API consumption per user, enforce rate limits across different tiers, prevent quota abuse, handle billing edge cases (what if usage spikes mid-cycle?), and provide real-time usage visibility—all while maintaining performance at scale. Most developers hardcode limits in their codebase, creating a maintenance nightmare when tiers change or new features launch. Others use basic counters that don’t handle distributed systems, leading to race conditions where users exceed quotas. Stripe offers metered billing, but it doesn’t enforce limits or track real-time consumption. This implementation solves the complete problem: a production-ready pricing tier system with Redis-backed rate limiting, PostgreSQL for persistent tracking, Stripe for billing, real-time usage dashboards, automatic tier enforcement, and quota notifications—all designed to scale from 100 to 100,000 API calls per second.
Tech Stack & Prerequisites
- Node.js v20+ and npm/pnpm
- TypeScript 5+
- Express.js v4.18+ for API server
- PostgreSQL v15+ for usage tracking
- Redis v7+ for rate limiting
- Stripe Account with API keys
- Prisma v5+ for database ORM
- ioredis v5+ for Redis client
- stripe npm package v14+
- express-rate-limit v7+ (optional baseline)
- dotenv for environment variables
- Bull v4+ for background job processing (quota enforcement)
Step-by-Step Implementation
Step 1: Setup
Initialize your Node.js project:
mkdir saas-pricing-tiers
cd saas-pricing-tiers
npm init -y
npm install express stripe @prisma/client ioredis bull dotenv
npm install -D typescript @types/node @types/express tsx nodemon prisma
Initialize TypeScript:
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Initialize Prisma:
npx prisma init
Create prisma/schema.prisma:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
apiKey String @unique
tier Tier @default(FREE)
stripeCustomerId String? @unique
stripeSubscriptionId String?
subscriptionStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usageRecords UsageRecord[]
@@index([apiKey])
@@index([stripeCustomerId])
}
enum Tier {
FREE
STARTER
PRO
ENTERPRISE
}
model UsageRecord {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String
method String
statusCode Int
timestamp DateTime @default(now())
billingPeriodStart DateTime
billingPeriodEnd DateTime
@@index([userId, timestamp])
@@index([billingPeriodStart, billingPeriodEnd])
}
model TierConfig {
id String @id @default(cuid())
tier Tier @unique
displayName String
monthlyPrice Int // in cents
requestsPerMonth Int
requestsPerMinute Int
features Json
stripePriceId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Create project structure:
mkdir -p src/middleware src/services src/routes
touch src/index.ts src/config.ts src/middleware/auth.ts src/middleware/rate-limit.ts src/services/usage-tracker.ts src/services/stripe-service.ts src/routes/api.ts
Update package.json scripts:
{
"scripts": {
"dev": "nodemon --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"seed": "tsx src/seed.ts"
}
}
Step 2: Configuration
Create .env file:
# .env
PORT=3000
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/saas_pricing
# Redis
REDIS_URL=redis://localhost:6379
# Stripe
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# App
NODE_ENV=development
API_BASE_URL=http://localhost:3000
Add to .gitignore:
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
Create src/config.ts:
// src/config.ts
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT || 3000,
env: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL || '',
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
},
api: {
baseUrl: process.env.API_BASE_URL || 'http://localhost:3000',
},
} as const;
// Tier configurations (can also be stored in database)
export const TIER_CONFIGS = {
FREE: {
displayName: 'Free',
monthlyPrice: 0,
requestsPerMonth: 1000,
requestsPerMinute: 10,
features: {
basicEndpoints: true,
advancedEndpoints: false,
analytics: false,
support: 'community',
},
stripePriceId: null,
},
STARTER: {
displayName: 'Starter',
monthlyPrice: 2900, // $29.00
requestsPerMonth: 50000,
requestsPerMinute: 100,
features: {
basicEndpoints: true,
advancedEndpoints: true,
analytics: true,
support: 'email',
},
stripePriceId: process.env.STRIPE_PRICE_STARTER,
},
PRO: {
displayName: 'Pro',
monthlyPrice: 9900, // $99.00
requestsPerMonth: 500000,
requestsPerMinute: 500,
features: {
basicEndpoints: true,
advancedEndpoints: true,
analytics: true,
support: 'priority',
webhooks: true,
},
stripePriceId: process.env.STRIPE_PRICE_PRO,
},
ENTERPRISE: {
displayName: 'Enterprise',
monthlyPrice: 49900, // $499.00
requestsPerMonth: -1, // unlimited
requestsPerMinute: 2000,
features: {
basicEndpoints: true,
advancedEndpoints: true,
analytics: true,
support: 'dedicated',
webhooks: true,
customIntegrations: true,
sla: true,
},
stripePriceId: process.env.STRIPE_PRICE_ENTERPRISE,
},
} as const;
const requiredEnvVars = ['DATABASE_URL', 'REDIS_URL', 'STRIPE_SECRET_KEY'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
Create src/seed.ts to populate tier configurations:
// src/seed.ts
import { PrismaClient, Tier } from '@prisma/client';
import { TIER_CONFIGS } from './config';
const prisma = new PrismaClient();
async function seed() {
console.log('Seeding tier configurations...');
for (const [tier, config] of Object.entries(TIER_CONFIGS)) {
await prisma.tierConfig.upsert({
where: { tier: tier as Tier },
update: {
displayName: config.displayName,
monthlyPrice: config.monthlyPrice,
requestsPerMonth: config.requestsPerMonth,
requestsPerMinute: config.requestsPerMinute,
features: config.features,
stripePriceId: config.stripePriceId || null,
},
create: {
tier: tier as Tier,
displayName: config.displayName,
monthlyPrice: config.monthlyPrice,
requestsPerMonth: config.requestsPerMonth,
requestsPerMinute: config.requestsPerMinute,
features: config.features,
stripePriceId: config.stripePriceId || null,
},
});
}
console.log('Tier configurations seeded successfully!');
await prisma.$disconnect();
}
seed().catch((error) => {
console.error('Seed error:', error);
process.exit(1);
});
Run seed:
npm run seed
Step 3: Core Logic
3.1: Usage Tracker Service
Create src/services/usage-tracker.ts:
// src/services/usage-tracker.ts
import { PrismaClient, Tier, User } from '@prisma/client';
import Redis from 'ioredis';
import { config, TIER_CONFIGS } from '../config';
const prisma = new PrismaClient();
const redis = new Redis(config.redis.url);
export class UsageTracker {
/**
* Get current billing period boundaries
*/
private getBillingPeriod(): { start: Date; end: Date } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
return { start, end };
}
/**
* Get Redis key for monthly usage
*/
private getMonthlyUsageKey(userId: string): string {
const { start } = this.getBillingPeriod();
const month = start.toISOString().slice(0, 7); // YYYY-MM
return `usage:monthly:${userId}:${month}`;
}
/**
* Get Redis key for rate limiting (per minute)
*/
private getRateLimitKey(userId: string): string {
const now = new Date();
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM
return `ratelimit:${userId}:${minute}`;
}
/**
* Check if user has exceeded monthly quota
*/
async hasExceededMonthlyQuota(user: User): Promise<boolean> {
const tierConfig = TIER_CONFIGS[user.tier];
// Enterprise has unlimited requests
if (tierConfig.requestsPerMonth === -1) {
return false;
}
const monthlyUsage = await this.getMonthlyUsage(user.id);
return monthlyUsage >= tierConfig.requestsPerMonth;
}
/**
* Check if user has exceeded rate limit (requests per minute)
*/
async hasExceededRateLimit(user: User): Promise<boolean> {
const tierConfig = TIER_CONFIGS[user.tier];
const rateLimitKey = this.getRateLimitKey(user.id);
const currentCount = await redis.get(rateLimitKey);
const count = currentCount ? parseInt(currentCount) : 0;
return count >= tierConfig.requestsPerMinute;
}
/**
* Get monthly usage count
*/
async getMonthlyUsage(userId: string): Promise<number> {
const monthlyKey = this.getMonthlyUsageKey(userId);
const count = await redis.get(monthlyKey);
return count ? parseInt(count) : 0;
}
/**
* Get remaining requests for current month
*/
async getRemainingRequests(user: User): Promise<number> {
const tierConfig = TIER_CONFIGS[user.tier];
if (tierConfig.requestsPerMonth === -1) {
return -1; // unlimited
}
const monthlyUsage = await this.getMonthlyUsage(user.id);
return Math.max(0, tierConfig.requestsPerMonth - monthlyUsage);
}
/**
* Increment usage counters
*/
async incrementUsage(
user: User,
endpoint: string,
method: string,
statusCode: number
): Promise<void> {
const monthlyKey = this.getMonthlyUsageKey(user.id);
const rateLimitKey = this.getRateLimitKey(user.id);
const { start, end } = this.getBillingPeriod();
// Increment Redis counters
const pipeline = redis.pipeline();
// Monthly counter (expires at end of month)
const monthEndSeconds = Math.floor((end.getTime() - Date.now()) / 1000);
pipeline.incr(monthlyKey);
pipeline.expire(monthlyKey, monthEndSeconds);
// Rate limit counter (expires after 1 minute)
pipeline.incr(rateLimitKey);
pipeline.expire(rateLimitKey, 60);
await pipeline.exec();
// Store in database for analytics (async, don't block response)
prisma.usageRecord
.create({
data: {
userId: user.id,
endpoint,
method,
statusCode,
billingPeriodStart: start,
billingPeriodEnd: end,
},
})
.catch((err) => console.error('Failed to store usage record:', err));
}
/**
* Get usage analytics for user
*/
async getUsageAnalytics(userId: string) {
const { start, end } = this.getBillingPeriod();
const monthlyUsage = await this.getMonthlyUsage(userId);
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('User not found');
}
const tierConfig = TIER_CONFIGS[user.tier];
// Get endpoint breakdown from database
const endpointStats = await prisma.usageRecord.groupBy({
by: ['endpoint'],
where: {
userId,
timestamp: {
gte: start,
lte: end,
},
},
_count: {
endpoint: true,
},
});
return {
tier: user.tier,
billingPeriod: {
start,
end,
},
usage: {
current: monthlyUsage,
limit: tierConfig.requestsPerMonth,
remaining: await this.getRemainingRequests(user),
percentUsed:
tierConfig.requestsPerMonth === -1
? 0
: (monthlyUsage / tierConfig.requestsPerMonth) * 100,
},
rateLimit: {
requestsPerMinute: tierConfig.requestsPerMinute,
},
endpointBreakdown: endpointStats.map((stat) => ({
endpoint: stat.endpoint,
count: stat._count.endpoint,
})),
};
}
/**
* Reset usage for testing
*/
async resetUsage(userId: string): Promise<void> {
const monthlyKey = this.getMonthlyUsageKey(userId);
await redis.del(monthlyKey);
}
}
export const usageTracker = new UsageTracker();
3.2: Authentication Middleware
Create src/middleware/auth.ts:
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { PrismaClient, User } from '@prisma/client';
const prisma = new PrismaClient();
export interface AuthenticatedRequest extends Request {
user?: User;
}
/**
* Authenticate user via API key
*/
export async function authenticate(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).json({
error: 'Missing API key',
message: 'Please provide an API key in the X-API-Key header',
});
}
const user = await prisma.user.findUnique({
where: { apiKey },
});
if (!user) {
return res.status(401).json({
error: 'Invalid API key',
message: 'The provided API key is not valid',
});
}
req.user = user;
next();
} catch (error) {
console.error('Authentication error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
}
3.3: Rate Limiting Middleware
Create src/middleware/rate-limit.ts:
// src/middleware/rate-limit.ts
import { Response, NextFunction } from 'express';
import { AuthenticatedRequest } from './auth';
import { usageTracker } from '../services/usage-tracker';
import { TIER_CONFIGS } from '../config';
/**
* Enforce tier-based rate limiting and quotas
*/
export async function enforceRateLimit(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: 'User not authenticated' });
}
// Check monthly quota
const exceededMonthly = await usageTracker.hasExceededMonthlyQuota(user);
if (exceededMonthly) {
return res.status(429).json({
error: 'Monthly quota exceeded',
message: `You have exceeded your monthly limit of ${TIER_CONFIGS[user.tier].requestsPerMonth} requests. Please upgrade your plan.`,
tier: user.tier,
upgradeUrl: '/pricing',
});
}
// Check rate limit (requests per minute)
const exceededRateLimit = await usageTracker.hasExceededRateLimit(user);
if (exceededRateLimit) {
return res.status(429).json({
error: 'Rate limit exceeded',
message: `You have exceeded the rate limit of ${TIER_CONFIGS[user.tier].requestsPerMinute} requests per minute.`,
tier: user.tier,
retryAfter: 60,
});
}
// Track usage and continue
const endpoint = req.path;
const method = req.method;
// Increment usage after response
res.on('finish', () => {
usageTracker
.incrementUsage(user, endpoint, method, res.statusCode)
.catch((err) => console.error('Failed to track usage:', err));
});
next();
} catch (error) {
console.error('Rate limit error:', error);
res.status(500).json({ error: 'Rate limiting failed' });
}
}
/**
* Attach usage info to response headers
*/
export async function attachUsageHeaders(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) {
try {
const user = req.user;
if (user) {
const remaining = await usageTracker.getRemainingRequests(user);
const tierConfig = TIER_CONFIGS[user.tier];
res.setHeader('X-RateLimit-Limit', tierConfig.requestsPerMonth.toString());
res.setHeader('X-RateLimit-Remaining', remaining === -1 ? 'unlimited' : remaining.toString());
res.setHeader('X-RateLimit-Reset', new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toISOString());
}
next();
} catch (error) {
console.error('Usage headers error:', error);
next(); // Don't block request if headers fail
}
}
3.4: Stripe Service
Create src/services/stripe-service.ts:
// src/services/stripe-service.ts
import Stripe from 'stripe';
import { PrismaClient, Tier } from '@prisma/client';
import { config, TIER_CONFIGS } from '../config';
const stripe = new Stripe(config.stripe.secretKey, {
apiVersion: '2024-11-20.acacia',
typescript: true,
});
const prisma = new PrismaClient();
export class StripeService {
/**
* Create checkout session for tier upgrade
*/
async createCheckoutSession(userId: string, tier: Tier) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
const tierConfig = TIER_CONFIGS[tier];
if (!tierConfig.stripePriceId) {
throw new Error('This tier does not support Stripe checkout');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: tierConfig.stripePriceId,
quantity: 1,
},
],
success_url: `${config.api.baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.api.baseUrl}/pricing`,
customer_email: user.email,
client_reference_id: user.id,
metadata: {
userId: user.id,
tier,
},
});
return session;
}
/**
* Handle successful checkout
*/
async handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.client_reference_id;
const tier = session.metadata?.tier as Tier;
if (!userId || !tier) {
throw new Error('Missing user ID or tier in session metadata');
}
await prisma.user.update({
where: { id: userId },
data: {
tier,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
subscriptionStatus: 'active',
},
});
console.log(`User ${userId} upgraded to ${tier}`);
}
/**
* Handle subscription update
*/
async handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const user = await prisma.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (!user) {
console.error('User not found for subscription:', subscription.id);
return;
}
await prisma.user.update({
where: { id: user.id },
data: {
subscriptionStatus: subscription.status,
},
});
// Downgrade if subscription canceled
if (subscription.status === 'canceled' || subscription.status === 'unpaid') {
await prisma.user.update({
where: { id: user.id },
data: {
tier: 'FREE',
},
});
}
}
/**
* Create customer portal session
*/
async createPortalSession(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.stripeCustomerId) {
throw new Error('No Stripe customer found for this user');
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${config.api.baseUrl}/dashboard`,
});
return session;
}
}
export const stripeService = new StripeService();
3.5: API Routes
Create src/routes/api.ts:
// src/routes/api.ts
import { Router } from 'express';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import { enforceRateLimit, attachUsageHeaders } from '../middleware/rate-limit';
import { usageTracker } from '../services/usage-tracker';
import { stripeService } from '../services/stripe-service';
import { TIER_CONFIGS } from '../config';
import { Tier } from '@prisma/client';
const router = Router();
/**
* Public endpoint - get pricing tiers
*/
router.get('/pricing', (req, res) => {
const tiers = Object.entries(TIER_CONFIGS).map(([tier, config]) => ({
tier,
displayName: config.displayName,
monthlyPrice: config.monthlyPrice / 100, // Convert cents to dollars
requestsPerMonth: config.requestsPerMonth,
requestsPerMinute: config.requestsPerMinute,
features: config.features,
}));
res.json({ tiers });
});
/**
* Protected endpoint - get usage analytics
*/
router.get(
'/usage',
authenticate,
async (req: AuthenticatedRequest, res) => {
try {
const analytics = await usageTracker.getUsageAnalytics(req.user!.id);
res.json(analytics);
} catch (error) {
console.error('Usage analytics error:', error);
res.status(500).json({ error: 'Failed to fetch usage analytics' });
}
}
);
/**
* Sample API endpoint with rate limiting
*/
router.get(
'/data',
authenticate,
attachUsageHeaders,
enforceRateLimit,
(req: AuthenticatedRequest, res) => {
res.json({
message: 'Success',
data: {
timestamp: new Date().toISOString(),
user: req.user!.email,
tier: req.user!.tier,
},
});
}
);
/**
* Create checkout session for tier upgrade
*/
router.post('/upgrade', authenticate, async (req: AuthenticatedRequest, res) => {
try {
const { tier } = req.body;
if (!tier || !Object.keys(TIER_CONFIGS).includes(tier)) {
return res.status(400).json({ error: 'Invalid tier' });
}
const session = await stripeService.createCheckoutSession(
req.user!.id,
tier as Tier
);
res.json({ url: session.url });
} catch (error) {
console.error('Upgrade error:', error);
res.status(500).json({ error: 'Failed to create checkout session' });
}
});
/**
* Create customer portal session
*/
router.post('/portal', authenticate, async (req: AuthenticatedRequest, res) => {
try {
const session = await stripeService.createPortalSession(req.user!.id);
res.json({ url: session.url });
} catch (error) {
console.error('Portal error:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
export default router;
3.6: Main Application
Create src/index.ts:
// src/index.ts
import express from 'express';
import Stripe from 'stripe';
import { config } from './config';
import apiRoutes from './routes/api';
import { stripeService } from './services/stripe-service';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
const app = express();
const prisma = new PrismaClient();
const stripe = new Stripe(config.stripe.secretKey, {
apiVersion: '2024-11-20.acacia',
});
// Stripe webhook - MUST use raw body
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
if (!sig) {
return res.status(400).send('Missing stripe-signature header');
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
config.stripe.webhookSecret
);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case 'checkout.session.completed':
await stripeService.handleCheckoutComplete(
event.data.object as Stripe.Checkout.Session
);
break;
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await stripeService.handleSubscriptionUpdate(
event.data.object as Stripe.Subscription
);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
} catch (error) {
console.error('Webhook handling error:', error);
res.status(500).json({ error: 'Webhook handler failed' });
}
}
);
// Other routes use JSON parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// API routes
app.use('/api', apiRoutes);
// Home page
app.get('/', (req, res) => {
res.send(`
<html>
<head><title>SaaS API Pricing</title></head>
<body style="font-family: Arial; max-width: 1000px; margin: 50px auto; padding: 20px;">
<h1>API Pricing Tiers</h1>
<p>Scalable pricing for your API needs</p>
<a href="/api/pricing">View Pricing →</a>
</body>
</html>
`);
});
// Create test user endpoint
app.post('/create-user', async (req, res) => {
try {
const { email } = req.body;
const apiKey = crypto.randomBytes(32).toString('hex');
const user = await prisma.user.create({
data: {
email,
apiKey,
tier: 'FREE',
},
});
res.json({
message: 'User created successfully',
user: {
id: user.id,
email: user.email,
apiKey: user.apiKey,
tier: user.tier,
},
});
} catch (error) {
console.error('User creation error:', error);
res.status(500).json({ error: 'Failed to create user' });
}
});
// Start server
app.listen(config.port, () => {
console.log(`Server running on http://localhost:${config.port}`);
console.log(`Webhook endpoint: http://localhost:${config.port}/webhook`);
});
Step 4: Testing
4.1: Start Services
Start PostgreSQL:
# Using Docker
docker run --name postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres
Start Redis:
# Using Docker
docker run --name redis -p 6379:6379 -d redis
Run migrations and seed:
npx prisma migrate dev
npm run seed
Start server:
npm run dev
4.2: Create Test User
curl -X POST http://localhost:3000/create-user \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
Response:
{
"message": "User created successfully",
"user": {
"id": "clxxx",
"email": "test@example.com",
"apiKey": "abc123...",
"tier": "FREE"
}
}
Save the API key for testing.
4.3: Test Rate Limiting
Test with free tier (10 requests/minute):
# Set your API key
API_KEY="your_api_key_here"
# Make 15 requests rapidly
for i in {1..15}; do
curl -H "X-API-Key: $API_KEY" http://localhost:3000/api/data
echo ""
done
Expected: First 10 succeed, next 5 return 429 rate limit error.
4.4: Test Usage Tracking
Check usage analytics:
curl -H "X-API-Key: $API_KEY" http://localhost:3000/api/usage
Response:
{
"tier": "FREE",
"billingPeriod": {
"start": "2026-03-01T00:00:00.000Z",
"end": "2026-03-31T23:59:59.000Z"
},
"usage": {
"current": 10,
"limit": 1000,
"remaining": 990,
"percentUsed": 1
},
"rateLimit": {
"requestsPerMinute": 10
},
"endpointBreakdown": [
{
"endpoint": "/api/data",
"count": 10
}
]
}
4.5: Test Monthly Quota
Simulate exceeding monthly quota:
# Manually set Redis counter for testing
redis-cli SET "usage:monthly:user_id:2026-03" 1001
Then make a request:
curl -H "X-API-Key: $API_KEY" http://localhost:3000/api/data
Expected: 429 error with “Monthly quota exceeded”.
4.6: Test Tier Upgrade
Set up Stripe webhook forwarding:
stripe listen --forward-to localhost:3000/webhook
Create upgrade session:
curl -X POST http://localhost:3000/api/upgrade \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"tier": "PRO"}'
Complete checkout in browser, verify tier updated.
Common Errors & Troubleshooting
1. Error: Rate limit counter not resetting after 1 minute
Cause: Redis key TTL not set correctly, or system clock skew between application servers.
Fix: Ensure TTL is set atomically with counter increment:
// WRONG - race condition
await redis.incr(rateLimitKey);
await redis.expire(rateLimitKey, 60);
// CORRECT - atomic pipeline
const pipeline = redis.pipeline();
pipeline.incr(rateLimitKey);
pipeline.expire(rateLimitKey, 60);
await pipeline.exec();
For distributed systems with clock skew, use sliding window:
async hasExceededRateLimit(user: User): Promise<boolean> {
const tierConfig = TIER_CONFIGS[user.tier];
const now = Date.now();
const windowKey = `ratelimit:window:${user.id}`;
// Add current timestamp
await redis.zadd(windowKey, now, `${now}-${Math.random()}`);
// Remove timestamps older than 1 minute
await redis.zremrangebyscore(windowKey, 0, now - 60000);
// Count requests in last minute
const count = await redis.zcard(windowKey);
// Set expiry
await redis.expire(windowKey, 60);
return count >= tierConfig.requestsPerMinute;
}
2. Error: Usage counters drift between Redis and PostgreSQL
Cause: Asynchronous database writes fail silently, or Redis data is lost before being persisted.
Fix: Implement reconciliation job and error handling:
// Add to UsageTracker class
async reconcileUsage(userId: string): Promise<void> {
const { start, end } = this.getBillingPeriod();
// Count from database (source of truth)
const dbCount = await prisma.usageRecord.count({
where: {
userId,
timestamp: { gte: start, lte: end },
},
});
// Get Redis count
const redisCount = await this.getMonthlyUsage(userId);
// Reconcile if drift detected
if (Math.abs(dbCount - redisCount) > 10) {
console.warn(`Usage drift detected for ${userId}: DB=${dbCount}, Redis=${redisCount}`);
const monthlyKey = this.getMonthlyUsageKey(userId);
await redis.set(monthlyKey, dbCount);
}
}
Schedule reconciliation:
import Queue from 'bull';
const reconciliationQueue = new Queue('reconciliation', config.redis.url);
reconciliationQueue.process(async (job) => {
const { userId } = job.data;
await usageTracker.reconcileUsage(userId);
});
// Run every hour for all active users
setInterval(async () => {
const users = await prisma.user.findMany({ where: { tier: { not: 'FREE' } } });
for (const user of users) {
await reconciliationQueue.add({ userId: user.id });
}
}, 3600000);
3. Error: Tier upgrade doesn’t immediately increase rate limits
Cause: Cached tier configuration in middleware, or rate limit keys include tier in key name.
Fix: Use user ID only in rate limit keys and fetch tier dynamically:
// WRONG - tier cached in key
const rateLimitKey = `ratelimit:${user.tier}:${user.id}:${minute}`;
// CORRECT - fetch fresh tier config
async hasExceededRateLimit(user: User): Promise<boolean> {
// Refetch user to get latest tier
const freshUser = await prisma.user.findUnique({
where: { id: user.id },
});
const tierConfig = TIER_CONFIGS[freshUser!.tier];
const rateLimitKey = this.getRateLimitKey(user.id); // No tier in key
const currentCount = await redis.get(rateLimitKey);
return (currentCount ? parseInt(currentCount) : 0) >= tierConfig.requestsPerMinute;
}
Add cache invalidation on tier change:
async upgradeTier(userId: string, newTier: Tier): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { tier: newTier },
});
// Clear rate limit cache immediately
const minute = new Date().toISOString().slice(0, 16);
await redis.del(`ratelimit:${userId}:${minute}`);
}
Security Checklist
- Generate cryptographically secure API keys using
crypto.randomBytes(32) - Never expose API keys in URLs – always use headers
- Implement API key rotation with grace periods for key updates
- Store API keys hashed in database using bcrypt or similar
- Rate limit authentication attempts to prevent brute force attacks on API keys
- Use Redis authentication in production with strong passwords
- Encrypt sensitive data at rest including user emails and payment info
- Implement webhook signature verification for all Stripe webhooks
- Use HTTPS only in production to prevent man-in-the-middle attacks
- Validate all user inputs before processing (tier names, user IDs, etc.)
- Implement request logging for security audits and fraud detection
- Set up monitoring alerts for unusual usage patterns (sudden spikes, quota abuse)
- Implement IP-based rate limiting in addition to API key limits
- Use separate Redis instances for production and development
- Regularly audit user access patterns for suspicious activity
- Implement soft deletes for users to maintain usage history
- Use database connection pooling to prevent connection exhaustion attacks
- Set maximum request payload sizes to prevent memory exhaustion
- Implement CORS properly for browser-based API access
- Use environment-specific Stripe keys and never commit secrets to git
Related Resources
For complete payment infrastructure, see our guide on integrating Stripe usage-based billing with Next.js to handle metered pricing and subscription management.
When implementing comprehensive API security, check out our tutorial on securing APIs with OAuth 2.0 and JWT for authentication best practices.
For production API design patterns, explore our article on designing scalable REST APIs for SaaS applications covering versioning, pagination, and error handling.
To implement advanced rate limiting strategies, read our guide on API rate limiting for high-traffic applications with distributed system patterns.
For teams choosing between API architectures, our GraphQL vs REST comparison for SaaS helps evaluate which approach fits your pricing model.

Finly Insights Team is a group of software developers, cloud engineers, and technical writers with real hands-on experience in the tech industry. We specialize in cloud computing, cybersecurity, SaaS tools, AI automation, and API development. Every article we publish is thoroughly researched, written, and reviewed by people who have actually worked in these fields.




