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 creationStore
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 creationDocs: 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 creationDocs: 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 active5 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.urlWhat 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 subscriberFor 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 minutesThrough 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).
