Stripe metered billing: tracking API calls and charging per unit

Flat-rate pricing is simple to build but poorly aligned with how customers actually use an API product. A customer using 100 API calls per month and one using 10 million calls pay the same price. Metered billing charges based on actual consumption, which aligns cost with value and enables usage-based pricing models that are increasingly standard in developer-facing SaaS. Stripe has two mechanisms for this: the legacy usage records API and the newer Meters API introduced in 2023.

Setting up a metered price in Stripe

A metered price is attached to a product. The price specifies the billing scheme (per unit), the unit amount, and whether usage is aggregated by summing all units in the period or by taking the maximum reported value.

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Create a metered price for API calls ($0.001 per call)
const price = await stripe.prices.create({
  currency: 'usd',
  unit_amount: 1,          // 1 cent = $0.01 ... but we want $0.001 so use recurring.usage_type
  product: 'prod_xyz',
  recurring: {
    interval: 'month',
    usage_type: 'metered',  // Key: enables usage recording
    aggregate_usage: 'sum', // 'sum' adds all records; 'max' uses the highest single report
  },
  // For fractional cents, use transform_quantity or tiers
  transform_quantity: {
    divide_by: 1000,
    round: 'up',
  },
});

// Create a subscription with the metered price
const subscription = await stripe.subscriptions.create({
  customer: stripeCustomerId,
  items: [{ price: price.id }],
});

// Store the subscription item ID — needed for usage reports
await db.orgs.update(
  { id: orgId },
  { stripeSubscriptionItemId: subscription.items.data[0].id }
);

Recording usage with the usage records API

The legacy usage records approach reports usage per subscription item. You call stripe.subscriptionItems.createUsageRecord with the subscription item ID, a quantity, and an optional timestamp.

// Record usage after each API call (synchronous — adds latency)
async function recordAPIUsage(orgId: string, quantity: number = 1) {
  const org = await db.orgs.findById(orgId);
  if (!org.stripeSubscriptionItemId) return;

  await stripe.subscriptionItems.createUsageRecord(
    org.stripeSubscriptionItemId,
    {
      quantity,
      timestamp: Math.floor(Date.now() / 1000),
      action: 'increment',  // 'increment' adds to the total; 'set' replaces it
    }
  );
}

// Better: batch usage records to avoid per-request Stripe calls
// Increment a Redis counter, flush to Stripe every minute
async function incrementUsageCounter(orgId: string, quantity: number = 1) {
  const key = `usage:${orgId}:${getCurrentBillingMinute()}`;
  await redis.incrby(key, quantity);
  await redis.expire(key, 120);  // 2-minute TTL on the bucket
}

// Cron job: flush usage counters to Stripe every minute
async function flushUsageToStripe() {
  const pattern = `usage:*:${getPreviousBillingMinute()}`;
  const keys = await redis.keys(pattern);

  for (const key of keys) {
    const [, orgId] = key.split(':');
    const quantity = parseInt(await redis.get(key) ?? '0');
    if (quantity > 0) {
      await recordAPIUsage(orgId, quantity);
      await redis.del(key);
    }
  }
}

The Stripe Meters API

The Meters API (in beta as of late 2023) is the modern approach. Instead of attaching usage to a subscription item, you create a meter, send events to it, and attach the meter to a price. The meter handles aggregation server-side at Stripe.

// Create a meter (do this once during setup)
const meter = await stripe.billing.meters.create({
  display_name: 'API Calls',
  event_name: 'api_call',
  default_aggregation: { formula: 'sum' },
  customer_mapping: {
    event_payload_key: 'stripe_customer_id',
    type: 'by_id',
  },
});

// Send a meter event (fire-and-forget per API call)
await stripe.billing.meterEvents.create({
  event_name: 'api_call',
  payload: {
    stripe_customer_id: org.stripeCustomerId,
    value: '1',
  },
  timestamp: Math.floor(Date.now() / 1000),
});

// Meter events can be batched and sent asynchronously via a queue
// The Stripe Meter API handles deduplication via idempotency keys
Do not call Stripe synchronously on every API request. The Stripe API has a p99 latency around 200-500ms. Making a synchronous usage record call would add that latency to every one of your API calls. Use a local counter (Redis INCR) and flush asynchronously, or use a message queue to process usage events out of band.

Quota enforcement before billing ends

Metered billing accumulates usage through the billing period. If a customer runs up a huge bill without warning, they will dispute the charge and you will lose the dispute. Proactive quota enforcement protects both parties.

// Soft limit: warn at 80% of expected usage for the period
// Hard limit: block at 110% to prevent unlimited overage
async function checkUsageQuota(orgId: string): Promise<QuotaStatus> {
  const org = await db.orgs.findById(orgId);
  const plan = await getPlanLimits(org.planId);

  if (!plan.monthlyAPICallLimit) {
    return { allowed: true, remaining: null };
  }

  const usage = await getCurrentPeriodUsage(orgId);
  const remaining = plan.monthlyAPICallLimit - usage;
  const usagePercent = (usage / plan.monthlyAPICallLimit) * 100;

  if (usagePercent >= 80 && !org.usageWarningsSent.includes('80pct')) {
    await sendUsageWarningEmail(orgId, usage, plan.monthlyAPICallLimit, 80);
    await db.orgs.update({ id: orgId }, {
      usageWarningsSent: [...org.usageWarningsSent, '80pct'],
    });
  }

  // Hard limit: allow 10% over to absorb batch requests
  if (usage > plan.monthlyAPICallLimit * 1.1) {
    return {
      allowed: false,
      remaining: 0,
      overagePercent: usagePercent,
      upgradeUrl: 'https://app.example.com/billing/upgrade',
    };
  }

  return { allowed: true, remaining: Math.max(0, remaining) };
}

// In the API middleware
app.use('/api/v1', async (req, res, next) => {
  const quota = await checkUsageQuota(req.org.id);
  if (!quota.allowed) {
    return res.status(429).json({
      error: 'usage_limit_exceeded',
      upgradeUrl: quota.upgradeUrl,
    });
  }
  res.setHeader('X-RateLimit-Remaining', quota.remaining ?? 'unlimited');
  next();
});
← Back to blog Try Bastionary free →