Implementing metered, usage-based billing across a multi-tenant platform introduces significant distributed systems challenges. Unlike static subscription tiers, usage-based models require real-time event capture, exact-once aggregation, zero-loss idempotency, and fault-tolerant synchronization with payment processors like Stripe. When combined with Stripe Connect—where platforms facilitate transactions on behalf of connected accounts while taking an application fee or managing direct charges—the complexity multiplies.
This guide details a production-grade architecture for ingesting, aggregating, and reporting usage data to Stripe, ensuring absolute financial accuracy for high-throughput SaaS platforms.
1. Architectural Blueprint: The Dual-Layer Ingestion Pipeline
Relying on synchronous API calls to Stripe for every user action (e.g., an API request, AI token generation, or compute minute) will quickly exhaust rate limits and introduce catastrophic points of failure. Instead, scalable systems rely on an asynchronous, decoupled ingestion pipeline split into two distinct tiers:
- The Edge Ingestion Layer: High-performance workers (Node.js/Go microservices or serverless edge functions) capture raw usage events, immediately acknowledging the client while pushing payload data to an immutable event log.
- The Aggregation & Reporting Layer: Background workers process the event stream, calculate rolling aggregates, enforce idempotency keys, and push usage records to Stripe’s Metered Billing API via batch reporting.
[ Client / API Gateway ]
│
▼ (Async Event Emission)
[ Redis Stream / Kafka ] ──► [ Worker Pool: Deduplication & Partitioning ]
│
▼
[ PostgreSQL: Raw Usage Ledger ]
│
▼ (Hourly Cron / Batcher)
[ Stripe Metering API ]
Database Indexing & Partitioning Strategy
To maintain sub-millisecond write performance during traffic spikes and efficient aggregation queries, the underlying usage ledger must be indexed correctly.
| Strategy / Component | Purpose | Trade-Offs & Considerations |
|---|---|---|
| Hyper-Tables (TimescaleDB) or Partitioned PostgreSQL | Automatically partition high-volume usage events by time (e.g., daily/hourly intervals). | Increases operational complexity; requires careful management of retention policies and drop-partition jobs. |
Composite Index: (tenant_id, metric_type, timestamp) |
Accelerates range-scans required for billing cycle summations. | Increases index size on disk; write performance degrades slightly as indexes grow beyond RAM. |
Idempotency Ledger (event_id UNIQUE) |
Prevents duplicate processing of ingested events during retries or network partitions. | Requires strict cleanup policies for old idempotency records to prevent unbounded table growth. |
2. Implementing the Event Ingestion and Aggregation Engine
Below is a production-ready TypeScript implementation using a robust pattern for ingestion, local aggregation, and scheduled synchronization to Stripe.
TypeScript Usage Ingestion & Aggregation Service
import { Stripe } from 'stripe';
import { Pool } from 'pg';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-02-28.acacia' });
const db = new Pool({ connectionString: process.env.DATABASE_URL });
interface UsageEvent {
eventId: string;
tenantId: string;
stripeSubscriptionItemId: string;
metricType: string;
quantity: number;
timestamp: number;
}
export async function ingestUsageEvent(event: UsageEvent): Promise<void> {
const client = await db.connect();
try {
await client.query('BEGIN');
// 1. Enforce idempotency via unique event constraint
const insertLedger = `
INSERT INTO raw_usage_ledger (event_id, tenant_id, subscription_item_id, metric_type, quantity, event_timestamp)
VALUES ($1, $2, $3, $4, $5, TO_TIMESTAMP($6))
ON CONFLICT (event_id) DO NOTHING;
`;
const res = await client.query(insertLedger, [
event.eventId,
event.tenantId,
event.stripeSubscriptionItemId,
event.metricType,
event.quantity,
event.timestamp
]);
// If rowCount is 0, the event was already processed (idempotent skip)
if (res.rowCount === 0) {
await client.query('ROLLBACK');
return;
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw new Error(`Failed to ingest usage event: ${(error as Error).message}`);
} finally {
client.release();
}
}
/**
* Scheduled job executed hourly to roll up and push usage to Stripe
*/
export async function syncUsageToStripe(subscriptionItemId: string, periodStart: number, periodEnd: number): Promise<void> {
const query = `
SELECT SUM(quantity) as total_quantity
FROM raw_usage_ledger
WHERE subscription_item_id = $1
AND event_timestamp >= TO_TIMESTAMP($2)
AND event_timestamp < TO_TIMESTAMP($3)
AND synced_to_stripe = FALSE;
`;
const { rows } = await db.query(query, [subscriptionItemId, periodStart, periodEnd]);
const totalQuantity = rows[0]?.total_quantity ? parseInt(rows[0].total_quantity, 10) : 0;
if (totalQuantity <= 0) return;
// Report usage record to Stripe Billing Meter / Usage Records API
await stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity: totalQuantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
},
{
idempotencyKey: `usage_${subscriptionItemId}_${periodStart}_${periodEnd}`,
}
);
// Mark local records as synced
await db.query(
`UPDATE raw_usage_ledger SET synced_to_stripe = TRUE WHERE subscription_item_id = $1 AND event_timestamp >= TO_TIMESTAMP($2) AND event_timestamp < TO_TIMESTAMP($3)`,
[subscriptionItemId, periodStart, periodEnd]
);
}
3. Handling Stripe Connect Nuances & Multi-Tenancy
When operating a platform utilizing Stripe Connect (Standard, Express, or Custom accounts), metered billing requires careful adherence to account separation. Depending on whether your platform acts as the merchant of record (Direct Charges / Destination Charges with platform fees) or passes the billing directly to connected accounts, subscription items must point to the correct Stripe API context.
Stripe Connect Configuration Considerations
- Platform-Level Billing: If the platform bills end-customers directly and pays out connected accounts via transfers, the Stripe subscription lives on the platform's main Stripe customer object. Metered usage tracking happens entirely within the platform's Stripe account.
- Connected-Account Billing: If connected accounts own their Stripe customers, your backend must initialize the Stripe SDK using the
Stripe-Accountheader:
// Initializing Stripe client for a specific connected account
const connectedStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-02-28.acacia',
stripeAccount: 'acct_1XXXXXXXXXXXXXXXXX',
});
// Create usage record on the connected account's subscription item
await connectedStripe.subscriptionItems.createUsageRecord('si_123456789', {
quantity: 50,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});
4. Error Handling, Retries, and Discrepancy Auditing
Network drops and Stripe API rate limits (429 Too Many Requests) are inevitable at scale. A resilient billing pipeline must implement exponential backoff with jitter and maintain a discrepancy audit trail.
Automated Reconciliation Script
Run a nightly reconciliation job that compares total aggregated local usage against Stripe’s returned summarized usage via stripe.subscriptionItems.listUsageRecordSummaries.
import stripe
import os
from datetime import datetime
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY")
def audit_stripe_usage_sync(subscription_item_id, start_time, end_time):
summaries = stripe.subscriptionItem.list_usage_record_summaries(
subscription_item_id,
limit=100
)
stripe_total = sum(summary.total_usage for summary in summaries.data)
# Compare with internal ledger total
# Implement alerting hook if discrepancy exceeds threshold (e.g., > 0)
print(f"Audited Item {subscription_item_id}: Stripe Total = {stripe_total}")
How BrickTry Accelerates & Powers This
Architecting, securing, and deploying complex multi-tenant billing engines requires deep infrastructure expertise and rigorous testing. BrickTry accelerates this entire lifecycle from prototype to production:
- BrickTry Lab Sandbox (
/lab): Instantly spin up a fully isolated, zero-setup in-browser Node.js/PostgreSQL container runtime. Test your event ingestion endpoints, simulate high-concurrency event streams, and validate Stripe webhook signatures in real time without local environment configuration friction. - AI-Human Dev Pairing: Leverage autonomous AI scaffolding to instantly generate database migrations, Redis ingestion queues, and Stripe webhook handlers. Simultaneously, our senior engineering pods review your code for distributed systems anti-patterns, race conditions, and ledger consistency.
- Automated AST Security Auditing: Continuously analyze your TypeScript and SQL codebases against OWASP Top 10 vulnerabilities, ensuring that tenant isolation boundaries, authorization checks, and webhook signature verification logic remain uncompromised.
- Interactive Scoping Engine: Transform complex business requirements—such as tiered pricing matrixes, multi-currency conversions, and Stripe Connect account hierarchies—into precise architectural milestones and production-ready code modules.
- 100% Source Code Ownership: Retain complete ownership of your GitHub repositories, Docker configurations, and PostgreSQL schemas with absolute zero vendor lock-in.
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.