How to Implement Stripe Metered Billing for an AI SaaS in 2026 (The New Meter API — Not the Deprecated Usage Records)

Every Stripe metered billing tutorial on the first page of search results is wrong. They all use usage_type: 'metered' without a Meter object — which has thrown an error since API version 2025-03-31.basil in March 2026. If you've just hit "Cannot create a price with usage_type metered without a meter" — this Stripe metered billing AI SaaS 2026 Meter API guide is the only up-to-date implementation: 5-object setup order, idempotent event recording, webhook integration, and the 4 production gotchas nobody else is documenting.

Stripe Metered Billing AI SaaS 2026 New Meter API
The new Stripe Meter API: 5 objects in strict dependency order, idempotent event recording, and the 4 production gotchas — for an AI SaaS charging $14 per report

What Stripe Changed in 2026 — and Why Every Old Tutorial Is Now Broken

Since API version 2025-03-31.basil, Stripe removed support for usage_type: 'metered' without a meter field — every price creation attempt using the old pattern now returns an error, making all pre-2026 metered billing tutorials invalid.

If you copy-pasted from a 2024 blog post, you probably hit this exact error on stripe.prices.create():

Error: Cannot create a price with usage_type metered without a meter

I hit it while wiring per-order billing for an AI report generation SaaS — charges $14 per completed report (1,400 cents), after optimizing AI cost from $203 to $14 per order. The AI pipeline worked; Stripe rejected the price. Billing and cost optimization had to be fixed together during the same production hardening sprint.

The broken pattern every old tutorial shows

// ❌ WRONG — throws on API 2025-03-31.basil+
const price = await stripe.prices.create({
  product: product.id,
  currency: 'usd',
  unit_amount: 1400,
  recurring: {
    interval: 'month',
    usage_type: 'metered',  // missing meter field — error
  },
});

The legacy system recorded usage via stripe.subscriptionItems.createUsageRecord() against subscription items. Stripe replaced it with Billing Meters — standalone objects that define how events aggregate before invoicing. The old aggregate_usage parameter on Price endpoints is gone. So is the legacy /v1/subscription_items/{id}/usage_records endpoint. Attaching legacy metered prices to new subscriptions or quotes also fails.

One nuance that confuses migration guides: billing_thresholds was removed in 2025-03-31.basil, then re-added in 2025-05-28.basil. If your guide says thresholds are permanently gone, it is already outdated.

Feature Old (deprecated) New Meter API
API version Before 2025-03-31.basil 2025-03-31.basil+
Price creation usage_type: 'metered' alone usage_type: 'metered' + meter: meterId
Usage recording subscriptionItems.createUsageRecord() stripe.billing.meterEvents.create()
aggregate_usage param Supported Removed — error on use
billing_thresholds Supported Removed (re-added in 2025-05-28)
Usage before subscription Not supported Supported
max aggregation Supported Not supported — use sum or last only
Webhook events None for meters billing.meter.created/updated/deactivated
API object SubscriptionItem.UsageRecord billing.Meter + billing.MeterEvent

What the changelog actually removed — and what survived

Beyond price creation errors, API version 2025-03-31.basil removed three things developers still grep for in old codebases: the aggregate_usage parameter on Price endpoints, the legacy usage records route at /v1/subscription_items/{id}/usage_records, and the ability to attach pre-meter legacy prices to new subscriptions or quotes. If your repo still calls createUsageRecord(), those calls fail silently in staging until you hit a real subscription cycle — then invoices show zero usage despite completed AI jobs.

Stripe added four meter-specific webhook events in the same release: billing.meter.created, billing.meter.updated, billing.meter.deactivated, and billing.meter.reactivated. I do not block job completion on these — they are infrastructure signals — but I log them for audit trails when ops deactivate a meter during pricing experiments. The customer-facing webhooks that matter for an AI SaaS remain invoice.created, invoice.payment_succeeded, and customer.subscription.updated.

Aggregation formula choice is narrower than before. The old system supported max for peak-concurrency billing. The new Meter API supports only sum and last. For per-report AI billing at $14.00 per unit, sum is correct — each completed report adds 1 to the period total. Use last only when the billable unit is a gauge reading (storage GB, active seats), not a discrete completed action.

The 5-Object Setup for Stripe Metered Billing (In Strict Dependency Order)

Stripe metered billing requires five objects created in a specific order — Meter → Product → Price → Customer → Subscription — where each step depends on the ID returned by the previous one, and skipping or reordering any step causes downstream failures.

Why the order matters

Most broken guides jump straight to "create a price." That fails because the Price object needs recurring.meter: meter.id — and you cannot get a meter ID without creating the Meter first. The Subscription needs the Price ID. The meter event needs the Customer's cus_xxxx ID stored in your database. Treat this as a dependency chain, not a checklist you can parallelize.

import Stripe from 'stripe';

// IMPORTANT: Must use API version 2025-03-31.basil or later
// Old version uses deprecated usage records — will throw errors
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-03-31.basil',
});

// ─── STEP 1: Create the Billing Meter ─────────────────────────
// ONE meter per billable event type. Run once. Save meter.id.
// NOTE: 'max' formula is NOT supported — use 'sum' or 'last' only
const meter = await stripe.billing.meters.create({
  display_name: 'AI Reports Generated',
  event_name: 'ai_report_generated',  // must match exactly when recording events
  default_aggregation: {
    formula: 'sum',  // count total reports per billing period
  },
});
console.log('Meter ID (save this):', meter.id);  // mtr_xxxx

// ─── STEP 2: Create the Product ───────────────────────────────
const product = await stripe.products.create({
  name: 'AI Report Generation',
  description: 'Pay per AI-generated report',
});

// ─── STEP 3: Create a Price ATTACHED to the Meter ─────────────
// This is the step that breaks with old tutorials — must include meter
const price = await stripe.prices.create({
  product: product.id,
  currency: 'usd',
  unit_amount: 1400,  // $14.00 per report in cents
  recurring: {
    interval: 'month',
    usage_type: 'metered',
    meter: meter.id,   // ← THIS is what old tutorials are missing
  },
});
console.log('Price ID (save this):', price.id);  // price_xxxx

// ─── STEP 4: Create a Customer ────────────────────────────────
const customer = await stripe.customers.create({
  email: user.email,
  metadata: { userId: user.id },  // store internal ID for lookup
});
// Save customer.id (cus_xxxx) to your database for this user
// CRITICAL: use customer.id (cus_xxxx) — NOT your internal user ID
// when recording meter events later

// ─── STEP 5: Create the Subscription ─────────────────────────
const subscription = await stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: price.id }],
});
// Subscription is now active. Usage events will appear on next invoice.

Where to store the IDs — and how Checkout fits in

Steps 1–3 are one-time infrastructure setup. Save meter.id (mtr_xxxx) and price.id (price_xxxx) in environment variables or a config table — not hardcoded in application logic. Steps 4–5 happen per user at signup. When a user completes Stripe Checkout, the checkout.session.completed webhook gives you customer and subscription IDs; persist customer.id as stripeCustomerId in your User model before they can generate billable reports.

Do not create the subscription in Checkout and also call subscriptions.create() in your API — that duplicates billing relationships. Pick one path: either Checkout with a metered price in line_items, or a server-side subscriptions.create() after onboarding. My AI report SaaS uses Checkout for the initial subscription, then records meter events server-side when Celery finishes the 2–4 hour pipeline. The Stripe Billing fee of 0.7% applies regardless of which path you choose — factor it into the 1,400-cent unit amount before you publish pricing.

Recording Usage Events When an AI Job Completes

Call stripe.billing.meterEvents.create() with the event_name, the Stripe customer ID (not your internal user ID), and a value of "1" immediately after each successful AI job completion — never before, never in a fire-and-forget without error handling.

The event recording pattern

For a report generation SaaS, the sequence is: user pays → AI job queued → 161 GPT-4o calls over 2–4 hours → PDF saved → meter event recorded → completion email sent. Record usage after the PDF exists, before the email. If the meter event fails, the report still exists — queue a retry rather than blocking the job response. Meters are append-only; design for reconciliation from day one.

Never call meterEvents.create() from the browser. A client-side call exposes your secret key if you proxy incorrectly, and users can spoof completion events. The meter event belongs in the same trust boundary as your AI job completion handler — a Celery task, a Next.js server action, or a FastAPI worker callback. The new API also allows recording usage before a subscription exists, which the legacy usage records API did not support; for most AI SaaS products you still want an active subscription before billing usage, but the flexibility helps during migration when test customers exist before production checkout goes live.

Retry queue for failed meter events

The billingRetryQueue table in the code above is not optional polish — it is how you survive Stripe API blips without under-billing customers. A cron job every 15 minutes picks rows where retryCount < 5, re-calls recordReportGenerated() with the same idempotency key (report-{orderId}), and increments the counter on failure. Because meters are append-only and idempotency keys deduplicate, a successful retry after a transient 503 does not double-charge. Your internal billingEvent ledger row is what support uses when a customer asks "why was I charged for 3 reports in March?" — not the Stripe dashboard alone.

// ─── PART A: Record meter event when AI job completes ────────
// Called from your job completion handler — NOT from client side
// file: lib/billing/record-usage.ts

import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { METER_EVENT_NAMES } from '@/lib/billing/constants';

export async function recordReportGenerated(orderId: string, userId: string) {
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { stripeCustomerId: true },
  });

  if (!user?.stripeCustomerId) {
    throw new Error(`No Stripe customer found for user ${userId}`);
  }

  const idempotencyKey = `report-${orderId}`;

  try {
    const event = await stripe.billing.meterEvents.create(
      {
        event_name: METER_EVENT_NAMES.reportGenerated,
        payload: {
          stripe_customer_id: user.stripeCustomerId,
          value: '1',
        },
      },
      { idempotencyKey }
    );

    await db.billingEvent.create({
      data: {
        orderId,
        stripeEventId: event.identifier,
        recordedAt: new Date(),
      },
    });

    return event;
  } catch (error) {
    console.error('Meter event failed:', { orderId, error });
    await db.billingRetryQueue.create({
      data: { orderId, retryCount: 0 },
    });
  }
}

// lib/billing/constants.ts
export const METER_EVENT_NAMES = {
  reportGenerated: 'ai_report_generated',
} as const;

// ─── PART B: Webhook — invoice.payment_succeeded ─────────────
// file: app/api/webhooks/stripe/route.ts

import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import type Stripe from 'stripe';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'invoice.payment_succeeded') {
    const invoice = event.data.object as Stripe.Invoice;

    const user = await db.user.findUnique({
      where: { stripeCustomerId: invoice.customer as string },
    });

    if (user) {
      await sendMonthlyInvoiceSummary({
        userEmail: user.email,
        invoiceUrl: invoice.hosted_invoice_url ?? '',
        totalAmount: invoice.amount_paid / 100,
        reportCount: invoice.lines.data.length,
      });
    }
  }

  return NextResponse.json({ received: true });
}

The Complete AI SaaS Billing Flow: From User Order to Stripe Invoice

An AI SaaS with per-order billing needs four integration points — checkout to create the subscription, job completion to record the meter event, webhook to confirm payment, and a billing portal for customers to manage their plan.

The full lifecycle for a report-generation SaaS

  1. User subscribes → Stripe Checkout → subscription created with metered price
  2. User requests report → AI job queued on background worker
  3. AI job completes — 161 GPT-4o calls, 2–4 hours, PDF saved to storage
  4. recordReportGenerated() fires → meter event sent to Stripe at $14.00 (1,400 cents)
  5. At billing period end → Stripe aggregates ai_report_generated events → creates invoice
  6. Invoice paid → invoice.payment_succeeded webhook → monthly summary email sent

Both sides of the $203 story apply here. The AI cost was $203 per naive run before optimization to $14. The billing was also broken initially — meter events were not firing reliably, so completed reports sometimes never appeared on invoices. Fixing usage recording and fixing model cost happened in the same production hardening phase.

Checkout, billing portal, and invoice timing

Stripe Checkout creates the customer and subscription in one hosted flow — you pass the metered price_xxxx in line_items and set mode: 'subscription'. After redirect, the user can request reports immediately; meter events accumulate until period end. Stripe applies a 1-hour grace period before finalizing usage-based invoices, which gives late-arriving meter events from slow AI jobs time to land — critical when jobs run 2–4 hours and might complete minutes before the billing period closes.

Expose the Stripe Customer Portal for payment method updates and invoice history. Link it from your settings page via stripe.billingPortal.sessions.create(). When invoice.payment_succeeded fires, the webhook handler above sends a summary email with hosted_invoice_url — customers who dispute a $14 line item need that link, not a generic "payment received" message. At 10 reports per month, that is $140.00 in usage plus ~$5.20 in combined processing and Billing fees — small per unit, but the invoice must itemize count clearly.

Connecting the billing to the job queue

The Python side of this pattern — how Stripe webhooks trigger Celery AI jobs and fire meter events on completion — is covered in the FastAPI + Celery Stripe webhook integration guide. My report SaaS uses FastAPI workers for the 2–4 hour pipeline and Next.js for subscription management; the meter event fires from whichever service owns job completion, but always server-side.

The 4 Production Gotchas in the New Stripe Meters API

The four most common production failures with the new Stripe Meters API are using the wrong customer ID, mismatched event names, the 0.7% billing fee surprise, and the append-only meter causing irreversible billing events.

Important

Gotcha 1 — Wrong customer ID (most common): payload.stripe_customer_id must be the Stripe cus_xxxx ID, not your internal database user ID. If you pass a PostgreSQL UUID, the meter event records but is unattributed — the charge never appears on any invoice.

Gotcha 2 — Event name case sensitivity: ai_report_generatedAI_Report_Generatedai-report-generated. Create a TypeScript constant (METER_EVENT_NAMES) rather than hardcoding the string in two places.

Gotcha 3 — The 0.7% Stripe Billing fee: Stripe Billing charges an additional 0.7% on top of payment processing for usage-based subscriptions. For a $14 per-report product: payment processing ~3% = $0.42, Stripe Billing fee 0.7% = $0.098, total fees ~$0.52 per report. Factor this into pricing before launch.

Gotcha 4 — Append-only meters: Meter events cannot be deleted or modified. If you record an event for a refunded order, record a negative event (payload.value: "-1", sum formula only) and issue a Stripe credit note for the invoice adjustment. Never assume you can undo a meter event.

Gotcha 1 deserves a concrete failure mode: I once passed userId (a UUID) instead of stripeCustomerId during a refactor. Stripe accepted the API call — no error — but the event sat unattributed. Three weeks of reports completed; zero appeared on the March invoice. The fix was a backfill script that read billingRetryQueue and re-submitted events with the correct cus_xxxx IDs, plus a TypeScript type that made stripeCustomerId required before recordReportGenerated() could compile.

For Gotcha 4, the negative event pattern looks like this: customer received a refund for order ord_abc after the meter event already fired. Submit a second event with the same event_name, same stripe_customer_id, and payload.value: "-1". Only works with sum aggregation — not last. Then issue a credit note on the finalized invoice if the period already closed. Document both actions in your internal ledger; Stripe will show net usage, support will show the refund reason.

The most expensive mistake in a Stripe metered billing integration isn't a wrong API call — it's not logging meter events to your own database alongside Stripe. Meters are append-only, Stripe can have delays, and your customer service team will eventually ask why someone was charged twice. Your internal billing ledger is the source of truth; Stripe is the processor. Build both from day one.

Testing Your Metered Billing Integration in Stripe Test Mode

Test Stripe metered billing by creating a test-mode meter with a distinct event_name, using the Stripe CLI to trigger subscription events, and advancing the billing period clock to verify invoice generation — all without processing real charges.

The test setup

  • Create a separate test-mode meter: event_name: "ai_report_generated_test"
  • Use an environment variable to switch between test and live meter event names
  • Use Stripe CLI test clocks to advance the billing period without waiting a month
  • Pin apiVersion: '2025-03-31.basil' in test and production — mismatched versions between setup script and runtime cause confusing partial failures

Advancing the billing period with test clocks

Create a test clock tied to your test customer, attach the subscription, record three manual meter events via CLI, then advance the clock to period end. Stripe generates a draft invoice showing 3 × $14.00 = $42.00 in usage before any card is charged. Confirm line items reference your metered price, not a flat recurring fee. This catches the most common setup mistake: a price created without meter: meter.id that silently falls back to a zero-usage invoice.

Verifying meter events fire correctly

# Stripe CLI — create a test meter event manually
stripe billing meter-events create \
  --event-name="ai_report_generated" \
  --payload[stripe_customer_id]="cus_xxxx" \
  --payload[value]="1"

# Check Stripe dashboard: Billing → Meters → View events
# Verify customer appears with usage count

In a multi-tenant SaaS, billing per tenant means fetching the correct stripeCustomerId per tenant from Prisma — the multi-tenant SaaS architecture guide covers how to structure this securely. I document production billing patterns like this on hassanr.com because broken Stripe tutorials cost real engineering hours; Hassan Raza built this meter integration for a live AI product, not a demo repo.

Frequently Asked Questions

Stripe metered billing in 2026 uses the Billing Meters API instead of deprecated usage records. You create a Meter with an event_name and aggregation formula (sum or last), attach it to a Price via recurring.meter, subscribe the customer, then call stripe.billing.meterEvents.create() each time a billable action occurs. At period end, Stripe aggregates events per customer and generates an invoice automatically. On API version 2025-03-31.basil and later, every metered Price requires a linked Meter ID — usage_type: 'metered' alone throws an error.

Billing Meters replaced the legacy usage records API, removed in Stripe API version 2025-03-31.basil. The old system used stripe.subscriptionItems.createUsageRecord() and aggregate_usage on Price objects. The new system uses stripe.billing.meters.create() to define the meter and stripe.billing.meterEvents.create() to record usage. Key improvements include high-throughput event ingestion, a 1-hour grace period before invoice generation, and recording usage before a subscription exists — a limitation of the old API. The aggregate_usage parameter and legacy /v1/subscription_items/{id}/usage_records endpoint are gone.

Call stripe.billing.meterEvents.create() with three required fields: event_name matching the Meter's event_name exactly (case-sensitive), payload.stripe_customer_id as the Stripe cus_xxxx ID (not your internal user ID), and payload.value as a string quantity like "1". Always pass an idempotencyKey in the options object to prevent double-billing on retry. Meter events are append-only — use payload.value: "-1" with sum aggregation to correct over-billing, or issue a credit note for invoice adjustments.