Building a sustainable usage-based SaaS revenue model requires high-throughput event tracking, reliable data aggregation, and strict idempotency handling. When customers pay per API call, compute minute, or gigabyte transferred, missing an event means lost revenue, while double-counting risks severe customer churn.
This guide details a production-grade architecture for ingesting high-volume usage events, caching metrics in Redis, flushing periodic usage reports to Stripe via metered billing, and processing Stripe webhooks without race conditions.
System Architecture and Flow
A robust metering pipeline decouples real-time user actions from slow third-party API calls. Direct HTTP requests to the Stripe Metering API on every application transaction will quickly exhaust your rate limits and introduce unacceptable latency.
[Client App] --> (1. API Request) --> [Node.js/Next.js Service]
|
(2. Increment Counter)
v
[Redis Cluster]
|
(3. Periodic Cron Flusher)
v
[Stripe Meter API]
|
(4. Webhook Event)
v
[Webhook Ingest + Idempotency]
|
[PostgreSQL DB]
Architectural Comparison
| Layer | Technology | Function | Failure Mode / Mitigation |
|---|---|---|---|
| Ingestion | Node.js / Redis ZINCRBY | Real-time atomic event counter | Redis failover mitigated by persistent AOF / replica setup. |
| Aggregation | Distributed Cron Job | Batch-rolls usage every hour | Lock contention mitigated via distributed Redis locks (Redlock). |
| Billing API | Stripe Metered Billing | Records usage quantities | Rate limits mitigated by exponential backoff and batching. |
| Webhook Handler | Express / Next.js / Laravel | Synchronizes state post-billing | Duplicate events handled via atomic DB uniqueness constraints. |
1. High-Throughput Event Ingestion via Redis
Instead of writing a database row for every usage event, use Redis sorted sets or hash maps to atomically increment usage counters in memory.
The following TypeScript snippet demonstrates how an API gateway or middleware increments usage counters efficiently using Redis pipelines.
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
interface TrackUsageParams {
tenantId: string;
metric: 'api_requests' | 'compute_seconds' | 'storage_gb';
quantity?: number;
}
export async function trackUsage({ tenantId, metric, quantity = 1 }: TrackUsageParams): Promise<void> {
const currentHour = new Date().toISOString().slice(0, 13); // Format: YYYY-MM-DDTHH
const redisKey = `meter:${tenantId}:${metric}:${currentHour}`;
// Atomic increment using Redis INCRBY
await redis.incrby(redisKey, quantity);
// Set a 7-day TTL on the key to prevent memory leaks if aggregation fails
await redis.expire(redisKey, 60 * 60 * 24 * 7);
}
2. Flushing Aggregated Metrics to Stripe
A background worker (executed via Kubernetes cronjob or a persistent Node.js worker) runs every hour, sweeps active Redis keys, calculates total consumption, and submits the usage payloads to Stripe's Billing Meter Events API.
import Stripe from 'stripe';
import { Redis } from 'ioredis';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-02-28.acacia' });
const redis = new Redis(process.env.REDIS_URL!);
export async function flushHourlyMetrics() {
const previousHour = new Date(Date.now() - 3600000).toISOString().slice(0, 13);
const pattern = `meter:*:*:${previousHour}`;
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
for (const key of keys) {
const [, tenantId, metric] = key.split(':');
const countStr = await redis.get(key);
if (!countStr) continue;
const quantity = parseInt(countStr, 10);
try {
// Send usage record to Stripe Billing Meter Events
await stripe.v2.billing.meterEvents.create({
event_name: metric,
payload: {
stripe_customer_id: tenantId,
value: quantity.toString(),
},
});
// Optionally clear or archive the Redis key after successful submission
await redis.del(key);
} catch (error) {
console.error(`Failed to report usage for tenant ${tenantId}:`, error);
// Retain key for retry logic during the next sync cycle
}
}
} while (cursor !== '0');
}
3. Webhook Ingestion and Idempotency
Stripe webhooks inform your application about invoice creation, payment failures, or threshold breaches. Because Stripe may retry webhooks upon network timeouts, your endpoint must guarantee idempotency.
The database schema below enforces idempotency using a unique constraint on incoming Stripe event IDs.
CREATE TABLE processed_stripe_webhooks (
id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'processed',
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_webhooks_type_created ON processed_stripe_webhooks(event_type, created_at);
Webhook Verification Middleware (Node.js/Express)
import express, { Request, Response } from 'express';
import Stripe from 'stripe';
import { Pool } from 'pg';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Use raw body parser strictly for Stripe signature verification
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return res.status(400).send(`Webhook Signature Error: ${err.message}`);
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check idempotency guard
const existing = await client.query(
'SELECT id FROM processed_stripe_webhooks WHERE id = $1',
[event.id]
);
if (existing.rows.length > 0) {
await client.query('COMMIT');
return res.status(200).json({ received: true, duplicate: true });
}
// Insert event to prevent concurrent race conditions
await client.query(
'INSERT INTO processed_stripe_webhooks (id, event_type, payload) VALUES ($1, $2, $3)',
[event.id, event.type, JSON.stringify(event.data.object)]
);
// Handle specific event types
switch (event.type) {
case 'invoice.payment_failed':
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
case 'customer.subscription.updated':
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdate(subscription);
break;
default:
console.warn(`Unhandled event type: ${event.type}`);
}
await client.query('COMMIT');
return res.status(200).json({ received: true });
} catch (err) {
await client.query('ROLLBACK');
console.error('Webhook processing failed:', err);
return res.status(500).send('Webhook processing internal error');
} finally {
client.release();
}
});
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Lock tenant account or trigger grace period logic
console.log(`Payment failed for customer: ${invoice.customer}`);
}
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
// Sync status to local tenant database
console.log(`Subscription updated: ${subscription.id} status=${subscription.status}`);
}
How BrickTry Accelerates & Powers This
Implementing scalable usage-based billing from scratch requires wiring Redis clusters, background cron workers, Stripe API keys, and idempotent database constraints. BrickTry streamlines this entire process through a unified development ecosystem:
- BrickTry Lab Sandbox (
/lab): Instantly spin up a zero-setup, in-browser container runtime pre-configured with Node.js, Redis, and PostgreSQL. Prototype and test your Stripe webhook signatures and event flusher scripts live without dealing with local port-forwarding or ngrok tunnels. - AI-Human Dev Pairing: Let autonomous AI scaffolding generate your initial Stripe webhook handlers, database migration scripts, and TypeScript Zod validation schemas, while dedicated senior engineering pods review your code for race conditions, Redis memory leaks, and idempotency edge cases.
- Interactive Scoping Engine: Feed your billing requirements (e.g., tier-based limits, multi-currency support, overage charges) into BrickTryโs Scoping Engine to instantly generate modular architectural milestones, schema definitions, and production deployment checklists.
- Unified Importer: Seamlessly import existing legacy billing repositories or third-party CodeCanyon scripts, automatically refactoring monolithic payment routes into clean, asynchronous, event-driven architectures.
- 100% Source Code Ownership: Retain complete ownership of your GitHub repositories, Docker configurations, and PostgreSQL schemas with zero vendor lock-in. Deploy directly to your own AWS, GCP, or Kubernetes infrastructure with confidence.
Build, Test, and Scale This on BrickTry
BrickTry pairs you with autonomous AI scaffolding supervised by dedicated senior full-stack software engineers in an interactive in-browser development sandbox. Test, build, and deploy production-grade software with 100% source code ownership and zero vendor lock-in.