← Back to blog
Insights

How Stripe's usage-based billing actually works (with code)

Ben Foster
By Ben Foster·Founder

Ben has built fintech products and scaled technology teams from an early stage through to unicorn. He was previously VP Engineering at TrueLayer and SVP Engineering at Checkout.com.

Stripe is the default payment processor for most startups. When those startups need to charge customers based on actual usage rather than flat monthly fees, Stripe Billing is the natural first choice. But Stripe's metered billing system is more complex than it appears from the docs landing page.

This article walks through exactly how Stripe's usage-based billing works under the hood. Every API call, every object dependency, every webhook you need to handle. The scenario throughout: an AI image generation product charging $0.05 per image.

What is usage-based billing in Stripe?

Usage-based billing charges customers based on their actual consumption of a product or service during each billing cycle. In Stripe, this is implemented through metered subscriptions: you report usage events throughout the month, and Stripe aggregates them into an invoice at cycle end.

Stripe calls this "metered billing." You create a Billing Meter to define the event type, attach it to a recurring Price, and subscribe customers. Usage accumulates. At the end of the billing period, Stripe calculates the total, generates an invoice, and attempts to collect payment.

This is real-time metering with deferred billing. Stripe knows what your customers are using as it happens, but charges are collected later. For many SaaS products, this works well. For products with high per-unit costs (like AI inference), the gap between usage and payment creates financial exposure.

The object chain: meter, product, price, customer, subscription

Every Stripe metered billing implementation requires five dependent objects, created in strict order. Each step depends on the previous one.

Step 1: create a Billing Meter

Defines how usage events are aggregated over the billing period.

// POST /v1/billing/meters
const meter = await stripe.billing.meters.create({
  display_name: "Images Generated",
  event_name:   "image_generated",
  default_aggregation: { formula: "sum" },
});
// → meter.id — required for price creation

Store meter.id. It is required when creating the metered Price in the next step.

Docs: Billing Meters API

Step 2: create a Product

The product represents your service in Stripe's catalog.

// POST /v1/products
const product = await stripe.products.create({
  name: "AI Image Generation",
});
// → product.id — required for price creation

Docs: Products API

Step 3: create a metered Price

The price defines the rate and links the product to the meter.

// POST /v1/prices
const price = await stripe.prices.create({
  product: product.id,
  currency: "usd",
  billing_scheme: "per_unit",
  unit_amount_decimal: "5",  // $0.05 per image
  recurring: {
    interval: "month",
    usage_type: "metered",
    meter: meter.id,
  },
});
// → price.id — required at subscription creation

Docs: Prices API

Step 4: create a Customer

Register the customer with a default payment method.

// POST /v1/customers
const customer = await stripe.customers.create({
  name: "Jane Doe", email: "jane@acme.com",
  invoice_settings: { default_payment_method: paymentMethodId },
});

Step 5: create a Subscription

Links the customer to the metered price. The subscription is the active billing relationship.

// POST /v1/subscriptions
const sub = await stripe.subscriptions.create({
  customer: customer.id,
  items: [{ price: price.id }],
  payment_behavior: "default_incomplete",
  expand: ["latest_invoice.payment_intent"],
});
// Must confirm the payment intent before sub is active

5 API calls, 5 object IDs to store. The chain is strictly ordered; each step depends on the previous.

Docs: Subscriptions API · Build a Subscription

How meter events work

Each time a customer generates an image, report a meter event to Stripe. Stripe aggregates these over the billing period and includes them in the end-of-cycle invoice.

// After image generation completes:
await stripe.billing.meterEvents.create({
  event_name: "image_generated",
  payload: {
    stripe_customer_id: customer.id,
    value: "1",  // one image
  },
  timestamp: Math.floor(Date.now() / 1000),
});
// Usage queued. Customer NOT charged yet.
// $0.05 collected at end of billing period.

Usage accumulates until the billing cycle ends. There is no immediate charge.

For an AI product generating thousands of images per customer per day, this means you are fronting compute costs until Stripe collects at period end. Stripe processes meter events asynchronously. There is no synchronous confirmation that a specific event was accepted and will appear on the invoice.

Docs: Meter Events API · Pay-As-You-Go Guide


The webhook infrastructure you need

Stripe's deferred billing model means invoices are generated and payments collected asynchronously. If you're building any kind of usage display, balance tracking, or access control on top of Stripe, you need a webhook listener to keep your application state in sync.

For example, if you want to show customers their billing status, suspend access after a failed payment, or update an internal usage dashboard when an invoice finalises, you must handle these events server-side:

app.post("/stripe/webhook", express.raw({ type: "*/*" }), async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers["stripe-signature"], WEBHOOK_SECRET
  );
  switch (event.type) {
    case "invoice.payment_succeeded":
      await updateCustomerBillingStatus(event.data.object.customer, "paid");
      break;
    case "invoice.payment_failed":
      await suspendCustomerAccess(event.data.object.customer);
      break;
    case "customer.subscription.updated":
      await syncSubscriptionState(event.data.object);
      break;
  }
  res.json({ received: true });
});

Minimum 3 event types to handle. Any custom billing UI or access control logic depends on these webhooks staying reliable.

This webhook handler is not optional if you're building usage visibility or access gating. It is infrastructure you build, deploy, monitor, and maintain. If it goes down, your application's billing state drifts from Stripe's. Common issues with Stripe metered billing often trace back to webhook reliability.

Docs: Stripe Webhooks · Billing Webhooks


What Stripe's customer portal covers (and what it doesn't)

Stripe provides a hosted Customer Portal out of the box, but it is invoice-focused. It shows billing history, lets customers download invoices, and manages payment methods.

const session = await stripe.billingPortal.sessions.create({
  customer: customer.id,
  return_url: "https://app.com/account",
});
// redirect to session.url

What you get for free:

  • Invoice history and PDF download
  • Payment method management
  • Subscription cancellation

What you must build yourself for a pay-as-you-go product:

  • Live usage counter (images generated this period)
  • Current period cost estimate
  • Usage history and breakdown

To build usage visibility you'll need to query stripe.billing.meters.listEventSummaries(), combine the data with subscription and pricing information, and render your own UI component. For teams implementing consumption-based pricing, this custom frontend work adds weeks to the timeline.

Docs: Customer Portal · Meter Event Summaries


How pricing changes work in Stripe

Price objects in Stripe are immutable. Changing your rate from $0.05 to $0.08 per image requires creating a new Price object and migrating every active subscription to it. There is no "update price" operation.

// 1. Create new price at $0.08
const newPrice = await stripe.prices.create({
  product: product.id, currency: "usd",
  unit_amount_decimal: "8",
  recurring: { interval: "month",
    usage_type: "metered", meter: meter.id },
});

// 2. Migrate each active subscription
await stripe.subscriptions.update(sub.id, {
  items: [{ id: sub.items.data[0].id, price: newPrice.id }],
});
// Repeat for every active subscriber

For large customer bases this is a bulk migration job, not a config change.

Docs: Update Subscription · Prices API


Key trade-offs with Stripe's metered billing approach

Stripe Billing is a comprehensive revenue platform. Its metered billing works well for many SaaS products. But certain architectural decisions create trade-offs worth understanding, especially for AI products with real-time costs.

Deferred billing creates financial exposure. Usage happens now; payment happens later. For AI products where every API call incurs infrastructure cost (GPU time, model inference, storage), you absorb that cost until the invoice cycle ends. If a customer racks up $10,000 in usage and the end-of-month invoice fails, you have already spent the money.

No real-time balance enforcement. Stripe meters usage but does not gate it. There is no built-in mechanism to check "does this customer have enough balance to generate this image?" before the action happens. You build that logic yourself or accept the risk.

Webhook dependency for billing UI and access control. If you build any custom usage display, balance tracking, or access gating, you need reliable webhook infrastructure to keep your application in sync with Stripe. Payment success, failure, and subscription changes all flow through webhooks.

Immutable pricing requires migration. Every pricing change means creating new Price objects and migrating subscriptions individually. This is a common pattern in billing systems, but it adds operational overhead when you're iterating on pricing frequently, especially at the early stages when finding the right billing model matters most.

Customer portal gaps. The hosted portal handles invoices, not usage. If your customers expect a experience like OpenAI's billing page (live usage tracking, current period costs, usage breakdowns), you build it from scratch.

These are not bugs. They are consequences of an invoice-native architecture applied to real-time use cases. For teams whose products incur costs per action and need balance control before usage happens, the question becomes whether to build these missing layers on top of Stripe or use infrastructure designed around real-time billing from the start.


How Credyt handles the same job

Credyt is real-time billing infrastructure built around wallets. Customers prepay into a balance; every usage event debits that balance immediately. There are no invoices, no billing cycles, and no end-of-period reconciliation. Here are the same five areas, using the same $0.05-per-image scenario.

Setup: product + customer in 2 API calls

One call defines the product, billing model, event type, and rate. A second call creates the customer with an auto-provisioned wallet and active subscription.

// POST /products
await fetch("https://api.credyt.ai/products", {
  method: "POST", headers,
  body: JSON.stringify({
    name: "AI Image Generation",
    code: "img_gen_v1",
    prices: [{
      name: "Per Image",
      type: "usage_based",
      billing_model: { type: "real_time" },
      usage_calculation: {
        event_type: "image_generated",
        usage_type: "unit",
      },
      pricing: [{ asset: "USD", values: [{ unit_price: 0.05 }] }],
    }],
    publish: true,
  }),
});
// POST /customers
const res = await fetch("https://api.credyt.ai/customers", {
  method: "POST", headers,
  body: JSON.stringify({
    name: "Jane Doe",
    external_id: "user_jane_123",
    subscriptions: [{ products: [{ code: "img_gen_v1" }] }],
  }),
});
const { id: credytId } = await res.json();
// Wallet auto-created. Subscription active immediately.

2 API calls total. No payment method required at creation; the wallet is funded separately via top-up.

Docs: Products API · Customers API

Usage events: real-time wallet debit

When an image is generated, send a usage event. Credyt debits the wallet $0.05 immediately. Optionally, check balance first to gate access in real time.

// Optional pre-authorization: check balance first
const { available } = await (await fetch(
  `https://api.credyt.ai/customers/${credytId}/wallet/default:USD`, { headers }
)).json();
if (available < 0.05) return res.status(402).json({ error: "Insufficient balance" });

// Generate image, then send event:
await fetch("https://api.credyt.ai/events", {
  method: "POST", headers,
  body: JSON.stringify({
    customer_external_id: "user_jane_123",
    events: [{
      id: crypto.randomUUID(),
      event_type: "image_generated",
      subject: imageId,
    }],
  }),
});
// Wallet debited $0.05. Balance updated immediately.

The balance check and the usage event are the entire billing integration for the hot path.

📄 Docs: Events API · Wallet API

Top-ups and customer portal

Credyt provides a hosted billing portal that covers top-ups, balance visibility, and account management in one place. One API call generates a session URL:

// POST /billing-portal/sessions
const { redirect_url } = await (await fetch(
  "https://api.credyt.ai/billing-portal/sessions", {
  method: "POST", headers,
  body: JSON.stringify({
    customer_id: credytId,
    return_url: "https://app.com/account",
  }),
})).json();
// Session link valid for 10 minutes

Through the portal, customers can top up their wallet via Stripe Checkout, view real-time balance and usage, configure auto-recharge (low-balance threshold and top-up amount), review full transaction history, and manage payment methods. Nothing to build on your side.

If your product needs to control the top-up amount directly rather than letting the customer choose, use the Top-up API:

// POST /top-ups (your product controls the amount)
const { redirect_url } = await (await fetch("https://api.credyt.ai/top-ups", {
  method: "POST", headers,
  body: JSON.stringify({
    customer_id: credytId, amount: 25.00, currency: "USD",
    return_url: "https://app.com/account",
  }),
})).json();
// Redirect → Stripe Checkout → wallet funded before return.

Both options are powered by Stripe's payment infrastructure. You get Stripe's checkout reliability, fraud protection, and card handling built in.

Docs: Billing Portal · Wallets & Top-ups · Top-ups API

Pricing changes: new version + migration

Changing the rate from $0.05 to $0.08 per image requires creating a new product version and migrating subscribers, similar to Stripe. The difference is that pricing, metering, and product configuration live in a single object rather than across separate Meter, Product, and Price resources.

// PATCH /products/img_gen_v1/default
await fetch("https://api.credyt.ai/products/img_gen_v1/default", {
  method: "PATCH", headers,
  body: JSON.stringify({
    prices: [{
      name: "Per Image",
      pricing: [{ asset: "USD", values: [{ unit_price: 0.08 }] }],
    }],
  }),
});
// Creates a new version. Existing subscribers stay on the previous rate
// until migrated to the new version.

Or update directly in the Credyt dashboard; no code needed at all.

Docs: Products API


Side-by-side comparison

Same product. Same customer. Same $0.05 per image.

Object chain: Stripe needs Meter → Product → Price → Customer → Subscription. Credyt needs Product → Customer (wallet auto-created).

API calls to go live: Stripe requires 6 calls across 5 object types (meter, product, price, customer, subscription, plus a meter event). Credyt requires 3 calls across 2 object types (product, customer, event).

Usage event billing: Stripe aggregates and bills at cycle end. Credyt debits the wallet immediately on event.

Balance gating: Stripe requires a custom build. Credyt provides a native wallet check in 1 API call.

Customer UI: Stripe's hosted portal covers invoices and payment methods. Usage visibility requires custom development using Meter Event Summaries. Credyt provides a hosted portal with live balance, usage, and self-service top-ups in 1 API call.

Pricing change: Both require creating a new price/version and migrating subscribers. Stripe spreads this across separate Price and Subscription objects. Credyt consolidates pricing, metering, and product config in a single resource.

Top-up flow: Stripe needs a Checkout Session, a webhook handler, and a Credit Grant API call before credits are available — and if your webhook fails, the customer has paid but has no balance. Credyt requires one API call and the wallet is funded immediately following a successful payment.

Architecture in one sentence: Stripe meters usage and invoices at cycle end (real-time metering, deferred billing). Credyt meters usage and debits wallets immediately (real-time metering, real-time billing).

Don't let monetization slow you down.

Free to start. Live in hours. No engineering team required.