How to Implement Usage-Based Billing in a SaaS App

How to Implement Usage-Based Billing in a SaaS App

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

How to Implement Usage-Based Billing in a SaaS App

Usage-based billing is deceptively complex. You're not just charging customers differently—you're fundamentally changing how you track product usage, store billing data, handle edge cases, and present charges to users. The companies that implement usage-based billing incorrectly end up with billing disputes, revenue leakage from unmetered usage, and database performance problems that emerge only at scale when fixing them requires migration downtime.

This guide covers the complete implementation: metering architecture that prevents double-counting and handles distributed systems correctly, pricing calculation that accounts for tiers and overage charges, invoice generation that matches what customers expect, and the database schema decisions that determine whether your billing system scales to millions of events. You'll see production-tested patterns from companies that successfully migrated from subscription to usage-based billing and the specific mistakes they had to fix along the way.

We'll start with metering infrastructure, move through pricing logic and calculation, then cover invoicing, payment collection, and the customer-facing billing dashboard components.

Why Usage-Based Billing Is Harder Than Subscription Billing

Subscription billing is deterministic: charge $X every month. Usage-based billing is event-driven: sum thousands or millions of metered events, apply pricing rules that change by tier and time period, handle partial months correctly, and generate invoices that customers can audit and verify. Every piece of this pipeline introduces failure modes that subscription billing doesn't have.

The hidden complexity appears in edge cases. What happens when a customer upgrades mid-month and your pricing tiers change? How do you bill for usage that spans calendar months? What's the source of truth when your database shows 10,458 API calls but your customer's logs show 10,461? These aren't theoretical—they're the billing disputes that create support tickets and churn.

The technical challenge is that metering must be both accurate (every event counted exactly once) and performant (recording millions of events without impacting application latency). Synchronous database writes achieve accuracy but kill performance. Async event queues achieve performance but introduce eventual consistency where events might be delayed or, in failure scenarios, lost. The solution requires specific architectural patterns.

Key Insight: Usage-based billing is not a feature you add to your app. It's infrastructure that touches every layer: application code emits usage events, background workers aggregate them, pricing engines calculate charges, billing systems generate invoices, and payment processors collect money. Treat it as a system, not a feature, and plan accordingly.

Metering Architecture: Counting Events Accurately

Metering is the foundation of usage-based billing. You must count every billable event (API calls, compute minutes, data processed) exactly once, without double-counting or losing events. The architecture you choose determines accuracy, performance, and debuggability.

Pattern 1: Synchronous Database Writes

The simplest approach: write a usage record to the database immediately when the billable event occurs.

// Synchronous usage metering
async function handleAPIRequest(req, res) {
  const user = await authenticateRequest(req);

  // Record usage before processing request
  await db.usageEvents.create({
    userId: user.id,
    tenantId: user.tenantId,
    eventType: 'api_call',
    endpoint: req.path,
    timestamp: new Date(),
    metadata: {
      method: req.method,
      statusCode: null // Set after response
    }
  });

  // Process the actual API request
  const result = await processRequest(req);

  // Update status code after processing
  await db.usageEvents.updateOne(
    { userId: user.id, timestamp: new Date() },
    { $set: { 'metadata.statusCode': result.statusCode } }
  );

  res.json(result);
}

This approach guarantees accuracy—every request is metered before processing. The problem is performance. Each API request now requires two database writes (insert usage event, update with status). At 100 requests/second, you're writing 200 records/second to your database. This adds 10-30ms of latency to every request and creates a database bottleneck that limits your API throughput.

Pattern 2: Async Event Queue with At-Least-Once Delivery

Better approach: emit usage events to a queue asynchronously, then process them in batches. This decouples metering from request handling, eliminating the performance penalty.

const EventEmitter = require('events');
const meteringEmitter = new EventEmitter();

// Emit usage event without blocking request
function recordUsage(userId, eventType, metadata) {
  meteringEmitter.emit('usage', {
    userId,
    eventType,
    timestamp: new Date(),
    metadata,
    eventId: generateUUID() // Unique ID for deduplication
  });
}

// In API handler - non-blocking
async function handleAPIRequest(req, res) {
  const user = await authenticateRequest(req);

  // Record usage asynchronously (doesn't await)
  recordUsage(user.id, 'api_call', {
    endpoint: req.path,
    method: req.method
  });

  // Process request without waiting for metering
  const result = await processRequest(req);
  res.json(result);
}

// Background worker processes usage events in batches
const usageBuffer = [];
const BATCH_SIZE = 100;
const FLUSH_INTERVAL = 5000; // 5 seconds

meteringEmitter.on('usage', (event) => {
  usageBuffer.push(event);

  if (usageBuffer.length >= BATCH_SIZE) {
    flushUsageEvents();
  }
});

setInterval(flushUsageEvents, FLUSH_INTERVAL);

async function flushUsageEvents() {
  if (usageBuffer.length === 0) return;

  const batch = usageBuffer.splice(0, usageBuffer.length);

  try {
    // Batch insert for performance
    await db.usageEvents.insertMany(batch, { ordered: false });
  } catch (error) {
    console.error('Failed to write usage events:', error);

    // Push to dead letter queue for retry
    await deadLetterQueue.addBatch(batch);
  }
}

This reduces database load by 95-99% (100 events written as one batch vs 100 individual writes). Latency impact on API requests drops to near-zero. The tradeoff: usage events are eventually consistent. If your server crashes, events in the buffer are lost. The solution is idempotency and dead letter queues for retry.

Ensuring Exactly-Once Metering with Idempotency

The critical requirement: each billable event must be counted exactly once, not zero times (lost events) and not twice (double counting). Idempotency keys solve this.

// Idempotent usage event ingestion
async function ingestUsageEvent(event) {
  const { eventId, userId, eventType, timestamp, metadata } = event;

  // Check if this event was already recorded
  const existing = await db.usageEvents.findOne({ eventId });

  if (existing) {
    console.log(`Event ${eventId} already recorded, skipping`);
    return { status: 'duplicate', eventId };
  }

  // Insert with unique constraint on eventId
  try {
    await db.usageEvents.insertOne({
      eventId,
      userId,
      eventType,
      timestamp,
      metadata,
      ingestedAt: new Date()
    });

    return { status: 'recorded', eventId };
  } catch (error) {
    if (error.code === 11000) { // Duplicate key error
      console.log(`Race condition: event ${eventId} already inserted`);
      return { status: 'duplicate', eventId };
    }
    throw error;
  }
}

// Retry failed events from dead letter queue
async function processDeadLetterQueue() {
  const failedEvents = await deadLetterQueue.getNext(50);

  for (const event of failedEvents) {
    try {
      await ingestUsageEvent(event);
      await deadLetterQueue.markComplete(event.id);
    } catch (error) {
      console.error(`Retry failed for event ${event.eventId}:`, error);
      await deadLetterQueue.incrementRetryCount(event.id);

      // After 5 retries, alert and manual intervention needed
      if (event.retryCount >= 5) {
        await alertOnCall('Usage event failed after 5 retries', event);
      }
    }
  }
}

The idempotency key (eventId) ensures that retrying failed events doesn't create duplicates. Even if an event is sent to the queue twice (network retry, application crash and restart), only one record is created in the database.

Metering Approach Accuracy Performance Impact When to Use
Sync DB writes Perfect (no event loss) High (+20-50ms per request) Low-throughput APIs (<10 req/s)
Async buffer + batch 99.9% (buffer loss risk) Minimal (<1ms) Medium throughput (10-1000 req/s)
Message queue (Kafka, SQS) 99.99% (durable queue) Minimal (<1ms) High throughput (1000+ req/s)
Third-party (Stripe Billing, Lago) Provider-dependent Minimal (API call overhead) Early-stage, want to outsource complexity
Warning: Don't meter usage in application logs and count log lines for billing. Logs rotate, get compressed, or get dropped under high load. Logs are for debugging, not financial transactions. Always write usage events to a dedicated metering system with durability guarantees.

Database Schema for Usage-Based Billing

The schema determines query performance, storage costs, and your ability to handle billing disputes. Poor schema design shows up months later when aggregation queries that were instant at 100K events take minutes at 10M events.

Core Tables Structure

-- Usage events table (raw metered data)
CREATE TABLE usage_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_id VARCHAR(255) UNIQUE NOT NULL, -- Idempotency key
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  event_type VARCHAR(100) NOT NULL, -- 'api_call', 'compute_minutes', 'storage_gb'
  quantity DECIMAL(20, 6) DEFAULT 1, -- Can be fractional for compute time
  timestamp TIMESTAMPTZ NOT NULL,
  metadata JSONB,
  ingested_at TIMESTAMPTZ DEFAULT NOW(),

  -- Indexes for common queries
  INDEX idx_user_timestamp (user_id, timestamp),
  INDEX idx_tenant_timestamp (tenant_id, timestamp),
  INDEX idx_event_type_timestamp (event_type, timestamp)
);

-- Pre-aggregated usage summaries (for faster billing)
CREATE TABLE daily_usage_summary (
  id BIGSERIAL PRIMARY KEY,
  user_id UUID NOT NULL,
  tenant_id UUID NOT NULL,
  date DATE NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  total_quantity DECIMAL(20, 6) NOT NULL,
  event_count INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE (user_id, date, event_type),
  INDEX idx_user_date (user_id, date),
  INDEX idx_tenant_date (tenant_id, date)
);

-- Invoices
CREATE TABLE invoices (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  invoice_number VARCHAR(50) UNIQUE NOT NULL,
  period_start DATE NOT NULL,
  period_end DATE NOT NULL,
  subtotal_cents BIGINT NOT NULL,
  tax_cents BIGINT NOT NULL,
  total_cents BIGINT NOT NULL,
  currency VARCHAR(3) DEFAULT 'USD',
  status VARCHAR(50) NOT NULL, -- 'draft', 'open', 'paid', 'void'
  due_date DATE,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  INDEX idx_user_created (user_id, created_at),
  INDEX idx_status_due (status, due_date)
);

-- Invoice line items (what was charged)
CREATE TABLE invoice_line_items (
  id BIGSERIAL PRIMARY KEY,
  invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  description TEXT NOT NULL,
  event_type VARCHAR(100),
  quantity DECIMAL(20, 6) NOT NULL,
  unit_price_cents BIGINT NOT NULL,
  amount_cents BIGINT NOT NULL,
  metadata JSONB,

  INDEX idx_invoice (invoice_id)
);

The critical design decision: separate raw events from aggregated summaries. Raw events provide audit trail and dispute resolution. Aggregated summaries provide query performance for billing calculations. You query summaries for invoice generation but can always drill into raw events when customers question charges.

Aggregation Jobs

Run daily aggregation jobs that sum raw usage events into summary tables. This makes billing calculations instant instead of scanning millions of raw events.

// Daily aggregation job
async function aggregateDailyUsage(date) {
  const startTime = new Date(date);
  startTime.setHours(0, 0, 0, 0);

  const endTime = new Date(date);
  endTime.setHours(23, 59, 59, 999);

  // Aggregate usage events by user and event type
  const aggregates = await db.usageEvents.aggregate([
    {
      $match: {
        timestamp: { $gte: startTime, $lte: endTime }
      }
    },
    {
      $group: {
        _id: {
          userId: '$userId',
          tenantId: '$tenantId',
          eventType: '$eventType',
          date: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } }
        },
        totalQuantity: { $sum: '$quantity' },
        eventCount: { $sum: 1 }
      }
    }
  ]);

  // Upsert into summary table
  for (const agg of aggregates) {
    await db.dailyUsageSummary.upsert(
      {
        userId: agg._id.userId,
        tenantId: agg._id.tenantId,
        date: agg._id.date,
        eventType: agg._id.eventType
      },
      {
        totalQuantity: agg.totalQuantity,
        eventCount: agg.eventCount,
        createdAt: new Date()
      }
    );
  }

  console.log(`Aggregated ${aggregates.length} usage summaries for ${date}`);
}
Pro Tip: Partition your usage_events table by month or quarter using PostgreSQL table partitioning or sharding. A single table with 100 million events becomes slow even with indexes. Partitioning keeps recent data fast while archiving old data to cheaper storage. Most billing queries only look at the current month—partition optimization can speed those queries by 10-100x.

Pricing Calculation: From Usage to Dollars

Converting metered usage into charges requires implementing your pricing model accurately. This is where business logic (tiered pricing, volume discounts, minimum commits) meets data (actual usage events).

Tiered Pricing Implementation

Most usage-based pricing uses tiers: first X units at price A, next Y units at price B, remaining at price C. The calculation must handle tier boundaries correctly.

// Tiered pricing calculator
const PRICING_TIERS = [
  { upTo: 1000, pricePerUnit: 0.10 },      // $0.10 per unit for first 1000
  { upTo: 10000, pricePerUnit: 0.08 },     // $0.08 per unit for next 9000
  { upTo: 100000, pricePerUnit: 0.05 },    // $0.05 per unit for next 90000
  { upTo: Infinity, pricePerUnit: 0.03 }   // $0.03 per unit above 100000
];

function calculateTieredPrice(usage) {
  let remaining = usage;
  let totalCost = 0;
  let previousTier = 0;

  for (const tier of PRICING_TIERS) {
    const tierSize = tier.upTo - previousTier;
    const unitsInTier = Math.min(remaining, tierSize);

    if (unitsInTier > 0) {
      const tierCost = unitsInTier * tier.pricePerUnit;
      totalCost += tierCost;
      remaining -= unitsInTier;

      console.log(`Tier ${previousTier}-${tier.upTo}: ${unitsInTier} units @ $${tier.pricePerUnit} = $${tierCost.toFixed(2)}`);
    }

    previousTier = tier.upTo;

    if (remaining <= 0) break;
  }

  return {
    usage,
    totalCost: Math.round(totalCost * 100), // Convert to cents
    breakdown: calculateBreakdown(usage)
  };
}

// Example usage
const charge = calculateTieredPrice(15000);
// Output:
// Tier 0-1000: 1000 units @ $0.10 = $100.00
// Tier 1000-10000: 9000 units @ $0.08 = $720.00
// Tier 10000-100000: 5000 units @ $0.05 = $250.00
// Total: $1070.00 (107000 cents)

Volume Pricing (Package Tiers)

Alternative to graduated tiers: package pricing where you pay a flat rate based on your tier. 0-1000 units costs $100, 1001-5000 costs $400, etc.

// Package/volume pricing
const VOLUME_PACKAGES = [
  { upTo: 1000, price: 100 },
  { upTo: 5000, price: 400 },
  { upTo: 25000, price: 1500 },
  { upTo: 100000, price: 5000 },
  { upTo: Infinity, price: 15000 }
];

function calculateVolumePrice(usage) {
  for (const pkg of VOLUME_PACKAGES) {
    if (usage <= pkg.upTo) {
      return {
        usage,
        totalCost: pkg.price * 100, // Convert to cents
        tier: `Up to ${pkg.upTo.toLocaleString()} units`,
        priceDisplay: `$${pkg.price}/month`
      };
    }
  }
}

// Example: 3500 units falls in 1001-5000 tier = $400
const charge = calculateVolumePrice(3500);

Minimum Commits and Overages

Enterprise contracts often include minimum commits (pay for X units minimum) with overage pricing for usage beyond the commit.

// Minimum commit with overage pricing
function calculateWithMinimumCommit(usage, commitment) {
  const {
    minimumUnits,
    commitmentPrice, // What they pay regardless of usage
    overageRate       // Price per unit above commitment
  } = commitment;

  if (usage <= minimumUnits) {
    // Under commitment, pay commitment price
    return {
      usage,
      includedUnits: minimumUnits,
      overageUnits: 0,
      commitmentCharge: commitmentPrice,
      overageCharge: 0,
      totalCost: commitmentPrice,
      utilizationPercent: (usage / minimumUnits * 100).toFixed(1)
    };
  } else {
    // Over commitment, pay commitment + overages
    const overageUnits = usage - minimumUnits;
    const overageCharge = Math.round(overageUnits * overageRate * 100);

    return {
      usage,
      includedUnits: minimumUnits,
      overageUnits,
      commitmentCharge: commitmentPrice,
      overageCharge,
      totalCost: commitmentPrice + overageCharge,
      utilizationPercent: (minimumUnits / usage * 100).toFixed(1)
    };
  }
}

// Example: 50K unit commitment at $5000, $0.08 overage
// Usage: 65K units
const charge = calculateWithMinimumCommit(65000, {
  minimumUnits: 50000,
  commitmentPrice: 500000, // $5000 in cents
  overageRate: 0.08
});

// Result:
// - Commitment: $5000 (50K units)
// - Overage: 15K units * $0.08 = $1200
// - Total: $6200
Pricing Model Best For Complexity Customer Preference
Flat per-unit Simple products (APIs, storage) Low Easy to predict costs
Graduated tiers Volume products (API calls, emails) Medium Rewards scale with discounts
Package/volume Tiered plans (SMB vs Enterprise) Low Predictable monthly bill
Minimum commit + overage Enterprise contracts High Preferred by procurement (guaranteed spend)
Critical Error: Never round intermediate calculations in tiered pricing. Only round the final total. If you round each tier's cost to cents before summing, errors accumulate. With $0.025 per unit and 10K units, rounding each unit gives wrong results. Calculate all tier costs as floats, sum them, then round once to cents. This is the number one cause of "our invoice doesn't match our usage" disputes.

Invoice Generation

Invoices must be generated reliably, reconcile with usage data, and present charges clearly enough that customers can audit them.

Monthly Invoice Generation Job

// Generate invoices for all users at month end
async function generateMonthlyInvoices(year, month) {
  const periodStart = new Date(year, month - 1, 1);
  const periodEnd = new Date(year, month, 0); // Last day of month

  // Get all users with usage in this period
  const usersWithUsage = await db.dailyUsageSummary.distinct('userId', {
    date: { $gte: periodStart, $lte: periodEnd }
  });

  console.log(`Generating invoices for ${usersWithUsage.length} users`);

  for (const userId of usersWithUsage) {
    try {
      await generateUserInvoice(userId, periodStart, periodEnd);
    } catch (error) {
      console.error(`Failed to generate invoice for user ${userId}:`, error);
      await alertBillingTeam('Invoice generation failed', { userId, error });
    }
  }
}

async function generateUserInvoice(userId, periodStart, periodEnd) {
  const user = await db.users.findById(userId);

  // Get usage summary for this period
  const usage = await db.dailyUsageSummary.aggregate([
    {
      $match: {
        userId,
        date: { $gte: periodStart, $lte: periodEnd }
      }
    },
    {
      $group: {
        _id: '$eventType',
        totalQuantity: { $sum: '$totalQuantity' },
        eventCount: { $sum: '$eventCount' }
      }
    }
  ]);

  // Calculate charges for each event type
  const lineItems = [];
  let subtotal = 0;

  for (const usageType of usage) {
    const pricing = getPricingForEventType(usageType._id, user.plan);
    const charge = calculatePrice(usageType.totalQuantity, pricing);

    lineItems.push({
      description: `${usageType._id} (${usageType.totalQuantity.toLocaleString()} units)`,
      eventType: usageType._id,
      quantity: usageType.totalQuantity,
      unitPrice: pricing.baseRate * 100, // cents
      amount: charge.totalCost
    });

    subtotal += charge.totalCost;
  }

  // Calculate tax if applicable
  const taxRate = await getTaxRate(user.country, user.state);
  const taxAmount = Math.round(subtotal * taxRate);
  const total = subtotal + taxAmount;

  // Create invoice
  const invoice = await db.invoices.create({
    userId,
    tenantId: user.tenantId,
    invoiceNumber: generateInvoiceNumber(),
    periodStart,
    periodEnd,
    subtotalCents: subtotal,
    taxCents: taxAmount,
    totalCents: total,
    currency: 'USD',
    status: 'open',
    dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days
    createdAt: new Date()
  });

  // Create line items
  for (const item of lineItems) {
    await db.invoiceLineItems.create({
      invoiceId: invoice.id,
      ...item
    });
  }

  // Send invoice email to customer
  await sendInvoiceEmail(user, invoice);

  // Attempt to charge payment method on file
  if (user.paymentMethodId) {
    await attemptPayment(invoice, user.paymentMethodId);
  }

  return invoice;
}

Pro-rated Charges for Partial Months

When customers upgrade/downgrade mid-month, calculate pro-rated charges based on days in each tier.

function calculateProRatedCharge(usage, periodStart, periodEnd, planChanges) {
  const lineItems = [];

  // Sort plan changes chronologically
  planChanges.sort((a, b) => a.effectiveDate - b.effectiveDate);

  let currentDate = periodStart;

  for (let i = 0; i < planChanges.length; i++) {
    const change = planChanges[i];
    const nextChange = planChanges[i + 1];
    const segmentEnd = nextChange ? nextChange.effectiveDate : periodEnd;

    const daysInSegment = Math.ceil((segmentEnd - currentDate) / (1000 * 60 * 60 * 24));
    const daysInPeriod = Math.ceil((periodEnd - periodStart) / (1000 * 60 * 60 * 24));

    // Allocate usage proportionally to days in each tier
    const segmentUsage = Math.round(usage * (daysInSegment / daysInPeriod));

    const pricing = getPricingForPlan(change.planId);
    const charge = calculatePrice(segmentUsage, pricing);

    lineItems.push({
      description: `${change.planName} (${daysInSegment} days, ${segmentUsage} units)`,
      dateRange: `${currentDate.toISOString().split('T')[0]} to ${segmentEnd.toISOString().split('T')[0]}`,
      quantity: segmentUsage,
      amount: charge.totalCost
    });

    currentDate = segmentEnd;
  }

  return {
    totalCharge: lineItems.reduce((sum, item) => sum + item.amount, 0),
    lineItems
  };
}

Customer-Facing Usage Dashboard

Customers need real-time visibility into usage and costs. This prevents surprise bills and reduces billing disputes.

Real-Time Usage API

// API endpoint for current usage
app.get('/api/usage/current', authenticateUser, async (req, res) => {
  const user = req.user;
  const now = new Date();
  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);

  // Get usage for current month
  const usage = await db.dailyUsageSummary.aggregate([
    {
      $match: {
        userId: user.id,
        date: { $gte: monthStart, $lte: now }
      }
    },
    {
      $group: {
        _id: '$eventType',
        totalQuantity: { $sum: '$totalQuantity' }
      }
    }
  ]);

  // Calculate estimated cost based on current usage
  const costBreakdown = usage.map(u => {
    const pricing = getPricingForEventType(u._id, user.plan);
    const charge = calculatePrice(u.totalQuantity, pricing);

    return {
      eventType: u._id,
      usage: u.totalQuantity,
      cost: charge.totalCost / 100, // Convert cents to dollars
      tier: determineCurrentTier(u.totalQuantity, pricing)
    };
  });

  const totalEstimated = costBreakdown.reduce((sum, item) => sum + item.cost, 0);

  res.json({
    periodStart: monthStart,
    periodEnd: new Date(now.getFullYear(), now.getMonth() + 1, 0),
    usage: costBreakdown,
    estimatedTotal: totalEstimated,
    lastUpdated: now
  });
});

Usage Alerts and Limits

Proactively notify customers when they approach plan limits or spending thresholds.

// Check usage against thresholds
async function checkUsageThresholds(userId) {
  const user = await db.users.findById(userId);
  const usage = await getCurrentMonthUsage(userId);

  const plan = await db.plans.findById(user.planId);

  for (const [eventType, quantity] of Object.entries(usage)) {
    const limit = plan.limits[eventType];

    if (!limit) continue; // No limit for this event type

    const percentUsed = (quantity / limit) * 100;

    // Alert at 80%, 90%, 100%
    if (percentUsed >= 80 && percentUsed < 90 && !user.alertSent80) {
      await sendUsageAlert(user, eventType, percentUsed, quantity, limit);
      await db.users.update(userId, { alertSent80: true });
    }

    if (percentUsed >= 90 && percentUsed < 100 && !user.alertSent90) {
      await sendUsageAlert(user, eventType, percentUsed, quantity, limit);
      await db.users.update(userId, { alertSent90: true });
    }

    if (percentUsed >= 100 && !user.alertSent100) {
      await sendUsageAlert(user, eventType, percentUsed, quantity, limit);
      await db.users.update(userId, { alertSent100: true });

      // Optionally throttle/block additional usage
      if (plan.hardLimits) {
        await throttleUsage(userId, eventType);
      }
    }
  }

  // Reset alert flags at start of new month
  const now = new Date();
  if (now.getDate() === 1) {
    await db.users.update(userId, {
      alertSent80: false,
      alertSent90: false,
      alertSent100: false
    });
  }
}
Pro Tip: Show customers their usage in both absolute terms (45,231 API calls) and relative terms (45% of your plan limit, on track for $234 this month). Absolute numbers provide transparency. Relative context helps them understand if they're on track or heading for overages. Include projected end-of-month usage based on current daily average.

Payment Collection and Dunning

Usage-based billing invoices vary month-to-month, creating more payment failures than fixed subscription pricing. Implement dunning (retry logic and communication) to recover failed payments.

Payment Retry Logic

// Attempt payment with retry schedule
async function attemptPayment(invoice, paymentMethodId, attempt = 1) {
  try {
    const charge = await stripe.paymentIntents.create({
      amount: invoice.totalCents,
      currency: invoice.currency,
      payment_method: paymentMethodId,
      customer: invoice.customerId,
      confirm: true,
      metadata: {
        invoiceId: invoice.id,
        userId: invoice.userId
      }
    });

    if (charge.status === 'succeeded') {
      await db.invoices.update(invoice.id, {
        status: 'paid',
        paidAt: new Date()
      });

      await sendPaymentSuccessEmail(invoice);
      return { success: true };
    }
  } catch (error) {
    console.error(`Payment attempt ${attempt} failed for invoice ${invoice.id}:`, error);

    await db.invoices.update(invoice.id, {
      lastPaymentAttempt: new Date(),
      paymentAttempts: attempt
    });

    // Retry schedule: Day 3, Day 5, Day 7, Day 10
    const retrySchedule = [3, 5, 7, 10];

    if (attempt <= retrySchedule.length) {
      const nextRetry = retrySchedule[attempt - 1];
      await schedulePaymentRetry(invoice.id, paymentMethodId, attempt + 1, nextRetry);

      await sendPaymentFailedEmail(invoice, attempt, nextRetry);
    } else {
      // Max retries exceeded
      await db.invoices.update(invoice.id, { status: 'overdue' });
      await sendFinalPaymentNotice(invoice);

      // Optionally suspend account
      await suspendAccount(invoice.userId);
    }

    return { success: false, attempt, error: error.message };
  }
}

Frequently Asked Questions

Should I build usage-based billing from scratch or use a service?

Use a service (Stripe Billing, Lago, Metronome) if you're early-stage or have standard use cases (API calls, storage, compute time). Building from scratch requires engineering months of effort to handle metering, aggregation, invoicing, and payment collection correctly. The ROI makes sense only if you have complex custom pricing (multi-dimensional usage, custom discounting logic) that third-party services can't model, or if you're at scale where service fees exceed build costs (typically 100K+ monthly invoices). Most companies that build custom billing systems regret it and migrate to services later.

How do I prevent customers from disputing charges?

Transparency prevents disputes. Provide real-time usage dashboards showing current usage and estimated cost. Send usage alerts at 50%, 80%, and 100% of thresholds. Make invoices detailed with per-day or per-API-endpoint breakdowns. Offer usage export in CSV/JSON so customers can reconcile against their logs. When disputes occur, being able to show "Here are the exact API calls we billed you for" ends most arguments. Store request IDs and timestamps for 90 days so you can provide proof.

What granularity should I use for metering—per request or batched?

Meter individual events (per API call) in your application code, but aggregate to daily or hourly summaries in your database. Individual events provide dispute resolution capability—you can show exact request logs when challenged. Aggregated summaries provide query performance—billing calculations query summaries, not raw events. The pattern: raw events table (partitioned by month, archived after 90 days) and daily summary table (kept indefinitely for billing history).

How do I handle usage that spans calendar months?

Bill based on when usage occurred, not when you calculate the invoice. An API call made on January 31 belongs on the January invoice even if you generate invoices on February 1. Use event timestamps, not ingestion timestamps. Handle delayed events (those that arrive after invoice generation) by either: (1) including them in next month's invoice with notation, or (2) generating credit memos to add charges to prior invoices. Option 1 is simpler and acceptable if delayed events are rare (<1%). Option 2 is necessary if delays are common or amounts are significant.

What happens if my metering system loses events?

This is why idempotency and retry mechanisms matter. Implement dead letter queues for failed events, monitor queue depth, and alert when unprocessed events accumulate. For audit purposes, correlate billed usage against application logs periodically. Run monthly reconciliation: compare total API calls in access logs against metered events. Discrepancies >1% indicate metering problems. When you discover lost events, generate corrective invoices (credit or charge) to make customers whole. Being honest about metering errors builds more trust than trying to hide them.

How do I implement free tiers or included usage?

Track all usage events, but only charge for usage above the free tier threshold. In your pricing calculation, subtract the free tier amount before applying rates. For example, if the free tier includes 1000 API calls and a user makes 3500 calls, bill for 2500 calls. Show both numbers on invoices: "3500 total API calls, 1000 included, 2500 billable." This transparency shows value provided and prevents confusion about why usage and charges don't match directly.

Should I charge for failed API calls?

Depends on the failure type. Don't charge for 5xx errors (server failures—your fault). Do charge for 4xx errors (client mistakes—their fault) except for 401/403 (authentication failures which may indicate testing). The fairest approach: charge for all requests that consumed resources (processed by your API, hit databases) regardless of success, but don't charge for requests rejected before processing (rate limits, auth failures). Document this clearly in billing documentation to prevent disputes.

How do I handle customers who consistently go over budget?

Implement spending limits that customers configure. When they approach the limit, send alerts. At the limit, either: (1) soft limit - allow overage but send warning emails, or (2) hard limit - throttle requests and return 429 status codes until next billing period or they increase limit. Enterprise customers prefer soft limits (never want service interruption). Self-serve customers prefer hard limits (want cost control). Make it configurable per account. Always notify before throttling—surprise throttling creates angry support tickets.

What tax calculations do I need for usage-based billing?

Same as subscription billing, but calculated on the final usage total rather than a fixed amount. Use services like TaxJar or Avalara for automatic tax calculation based on customer location and local tax rules. For SaaS, you typically need to handle: US sales tax (varies by state, some exempt software-as-a-service), EU VAT (charged to EU customers, reverse charge for B2B), and country-specific rules. Don't build tax calculation yourself—regulations change constantly and errors create compliance liability.

How far back should I retain usage event data?

Keep raw events for 90 days in hot storage for dispute resolution and debugging. Archive to cold storage (S3 Glacier, tape backups) for 7 years for financial audit compliance. Keep aggregated daily/monthly summaries forever—they're small (few KB per customer per month) and necessary for historical reporting and customer retention analysis. Partition or archive old raw events to prevent database bloat. A year of high-volume API usage can generate terabytes of raw event logs.

Conclusion

Usage-based billing shifts complexity from pricing predictability to metering accuracy. The companies that implement it successfully treat metering as critical infrastructure with the same reliability standards as payment processing. They architect for exactly-once event counting, they separate raw event storage from aggregated billing summaries, and they provide transparent real-time visibility that prevents billing disputes.

Start with simple per-unit pricing and basic metering before adding tiered pricing and complex calculations. Validate metering accuracy with small-scale manual audits before scaling to production traffic. Implement usage dashboards and alerts before customers ask for them. The pattern: build metering first, prove accuracy, then add pricing complexity incrementally.

The mistake most teams make is underestimating the operational complexity. Usage-based billing isn't a feature you ship and forget—it's ongoing infrastructure that requires monitoring, reconciliation, and customer support. Budget engineering time not just for initial implementation but for the monthly invoice generation process, billing dispute resolution, and continuous optimization as usage scales. The teams that succeed at usage-based billing treat it as a system with the same rigor they apply to their core product.


Share on Social Media: