Stripe Metered Billing in NestJS — Idempotent Usage Records at Scale
How to buffer usage events with Redis Streams and report them to Stripe without hitting 429s or double-charging customers, using a NestJS batch worker and deterministic idempotency keys.
Stripe Metered Billing in NestJS — Idempotent Usage Records at Scale
If you're charging per API call, per token, or per GB processed, the naive version of Stripe metered billing works fine — right up until it doesn't. Call stripe.subscriptionItems.createUsageRecord() on every request and you'll hit Stripe's rate limit (100 req/sec in live mode) the moment traffic gets real. Retry those failed calls without thinking it through and you'll double-bill someone. Neither is a fun incident to write up.
The fix isn't cleverer retry logic. It's not calling Stripe per-request at all.
The core problem: usage events and billing calls shouldn't be 1:1
Every incoming request that needs metering — an LLM call, an API hit, a processed file — doesn't need its own trip to Stripe. It needs to be recorded immediately, and reported on a schedule. Decoupling those two things is the whole trick.
Three failure modes show up once you're past toy scale:
| Failure Mode | What actually happens | Fix |
|---|---|---|
| Stripe 429s | Usage records silently drop under load, revenue leaks | Buffer in Redis, batch-submit on a cron |
| Duplicate reporting | Network retries or race conditions double-charge a customer | Deterministic idempotency keys, not random UUIDs |
| Invoice cutoff | Late events arrive after Stripe finalizes the invoice | Local audit ledger you can reconcile against, plus a grace window |
Step 1: Record events fast, without touching Stripe
The ingestion path just needs to be quick and non-blocking. A Redis Stream is a good fit here — you get an append-only log with consumer group semantics for free, and XADD is cheap enough to sit in a request path without adding meaningful latency.
// src/billing/usage-ingestion.service.ts
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class UsageIngestionService {
private readonly redis = new Redis(process.env.REDIS_URL!);
async recordUsageEvent(params: {
subscriptionItemId: string;
customerId: string;
quantity: number;
action: string;
}) {
const timestamp = Math.floor(Date.now() / 1000);
const eventId = `${params.subscriptionItemId}_${timestamp}_${Math.random().toString(36).slice(2, 7)}`;
await this.redis.xadd(
'metered:usage:stream',
'*',
'eventId', eventId,
'subscriptionItemId', params.subscriptionItemId,
'customerId', params.customerId,
'quantity', params.quantity.toString(),
'timestamp', timestamp.toString()
);
}
}
That's it. No Stripe SDK, no HTTP round trip to a third-party API on your hot path. If this call fails, you've lost a Redis write, not a billing record — much easier to reason about and much cheaper to make durable (Redis persistence, or a queue in front of it, depending on how paranoid you want to be).
Step 2: Aggregate and batch on a schedule
A cron worker — every minute is a sane default, tune it against how fast you need usage numbers to show up for customers — reads the stream, groups events by subscription item and minute bucket, and sums quantities. This is where the actual cost savings show up: a customer making 50,000 requests in a minute turns into one Stripe API call instead of 50,000.
// src/billing/stripe-sync.processor.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import Stripe from 'stripe';
import Redis from 'ioredis';
@Injectable()
export class StripeSyncProcessor {
private readonly logger = new Logger(StripeSyncProcessor.name);
private readonly stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
private readonly redis = new Redis(process.env.REDIS_URL!);
@Cron(CronExpression.EVERY_MINUTE)
async processUsageBatch() {
const streamResults = await this.redis.xrange('metered:usage:stream', '-', '+', 'COUNT', 1000);
if (!streamResults || streamResults.length === 0) return;
const aggregatedMap = new Map<string, { quantity: number; timestamp: number; messageIds: string[] }>();
for (const [msgId, fields] of streamResults) {
const fieldMap = new Map<string, string>();
for (let i = 0; i < fields.length; i += 2) {
fieldMap.set(fields[i], fields[i + 1]);
}
const subscriptionItemId = fieldMap.get('subscriptionItemId')!;
const quantity = parseInt(fieldMap.get('quantity')!, 10);
const timestamp = parseInt(fieldMap.get('timestamp')!, 10);
const minuteBucket = Math.floor(timestamp / 60) * 60;
const aggregateKey = `${subscriptionItemId}_${minuteBucket}`;
const existing = aggregatedMap.get(aggregateKey) ?? { quantity: 0, timestamp: minuteBucket, messageIds: [] };
existing.quantity += quantity;
existing.messageIds.push(msgId);
aggregatedMap.set(aggregateKey, existing);
}
for (const [aggregateKey, data] of aggregatedMap.entries()) {
const [subscriptionItemId, minuteBucket] = aggregateKey.split('_');
try {
await this.stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity: data.quantity,
timestamp: parseInt(minuteBucket, 10),
action: 'increment',
},
{
idempotencyKey: `usage_sync_${aggregateKey}`,
}
);
if (data.messageIds.length > 0) {
await this.redis.xdel('metered:usage:stream', ...data.messageIds);
}
} catch (err: unknown) {
this.logger.error(`Failed to submit usage record for ${aggregateKey}:`, err);
}
}
}
}
Notice what only gets deleted from the stream: events that were successfully acknowledged by Stripe. If the API call throws, the messages stay in the stream and get picked up — and re-aggregated — on the next run. No manual retry queue needed.
Step 3: The idempotency key is doing the real work
This is the part worth slowing down on, because it's easy to get subtly wrong. idempotencyKey: usage_sync_${subscriptionItemId}_${minuteBucket} is deterministic — it's built entirely from data that will be identical on a retry. Compare that to generating a random UUID per attempt, which is a common mistake: a random key means every retry is a new idempotency key as far as Stripe is concerned, so retries stop being safe at exactly the moment you need them to be.
Bucketing by minute also caps your blast radius. If a bug somehow double-processes a batch, the worst case is one minute's worth of usage for one subscription item — not an unbounded amount.
What this doesn't solve: invoice cutoffs
Redis Streams and idempotency keys handle rate limits and duplication. They don't handle a usage event arriving after Stripe has already finalized that customer's invoice — which will happen occasionally, whether from a delayed queue, a late-arriving webhook, or a worker that was down for a few minutes.
For that, you need two things this architecture doesn't give you for free: a persistent audit ledger (Postgres, not Redis — Redis Streams should be treated as a buffer you drain, not a system of record) that logs every usage event independently of whether Stripe accepted it, and a policy decision about late events — credit them to the next cycle, or eat them as a rounding error, depending on how much money is actually on the line. Don't skip the ledger. It's the only way you'll be able to answer a support ticket that says "why was I charged for X" six weeks from now.
The pattern in one sentence
Don't call a billing API per event — buffer, aggregate, and submit with keys that make retries free, and keep an independent record of what actually happened so you're not trusting Stripe's dashboard as your only source of truth.
Accelerate your Backend & Payments Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Give Your NestJS AI Agent a Real Memory (Without Blowing Up Your Token Bill)
A two-tier memory pattern for NestJS AI agents — Redis for the last few turns, pgvector for permanent facts — with the code, the tradeoffs, and where it breaks.
Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
How to wire Stripe's Payment Element into a React app so 3D Secure, Apple Pay, and Google Pay don't leave you with orphaned orders or duplicate charges.
MongoDB Change Streams in NestJS Keep Dropping Events. Here's the Fix
Change Streams look production-ready in a demo and fall apart on deploy day. Here's how to persist resume tokens, batch under load, and survive replica set elections in NestJS.