TL;DR
Flat-rate pricing forces a dilemma: too cheap and heavy users drain your margin; too expensive and light users churn. Usage-based billing (metered pricing) ties revenue to the value you deliver.
Here's the 5-file implementation using Stripe Meter API + Supabase + Next.js:
| File | Role |
|---|---|
| Stripe Dashboard | Create Meter + metered Price |
supabase/migrations/add_usage_events.sql |
Store usage locally for real-time display |
app/api/usage/record/route.ts |
Record events to both Stripe and Supabase |
app/api/stripe/webhook/route.ts |
Handle invoice.payment_succeeded
|
components/UsageMeter.tsx |
Show real-time usage gauge in dashboard |
Step 0: Create a Stripe Meter
In Stripe Dashboard → Billing → Meters → Create meter:
| Field | Example |
|---|---|
| Meter name | ai_tokens |
| Event name | ai_token_consumed |
| Aggregation | SUM |
Then create a metered Price under Products. Set Billing type to Per unit and link it to your Meter.
Step 1: Supabase Usage Table
-- supabase/migrations/add_usage_events.sql
CREATE TABLE IF NOT EXISTS usage_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
event_type text NOT NULL,
quantity integer NOT NULL DEFAULT 1,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON usage_events (user_id, event_type, created_at DESC);
CREATE OR REPLACE VIEW usage_monthly AS
SELECT
user_id,
event_type,
date_trunc('month', created_at) AS month,
SUM(quantity) AS total_quantity
FROM usage_events
GROUP BY user_id, event_type, date_trunc('month', created_at);
Why Supabase too? Stripe Meter is aggregated for billing — not great for real-time UI. Supabase lets you show used: 47,200 / 100,000 tokens in the dashboard without extra API calls.
Step 2: Record Usage (Stripe + Supabase)
// app/api/usage/record/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { eventType, quantity, metadata } = await req.json()
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
await Promise.all([
supabase.from('usage_events').insert({ user_id: user.id, event_type: eventType, quantity, metadata }),
profile?.stripe_customer_id
? stripe.billing.meterEvents.create({
event_name: eventType,
payload: {
stripe_customer_id: profile.stripe_customer_id,
value: String(quantity),
},
})
: Promise.resolve(),
])
return NextResponse.json({ ok: true })
}
Call this from your AI feature route — fire-and-forget so it doesn't block the response:
// After your AI call resolves
const tokensUsed = completion.usage?.total_tokens ?? 0
fetch('/api/usage/record', {
method: 'POST',
body: JSON.stringify({ eventType: 'ai_token_consumed', quantity: tokensUsed }),
})
Step 3: Handle Invoice Webhook
// app/api/stripe/webhook/route.ts (metered billing section)
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) break
await supabase.from('billing_cycles').upsert({
user_id: profile.id,
period_start: new Date(invoice.period_start! * 1000).toISOString(),
period_end: new Date(invoice.period_end! * 1000).toISOString(),
amount_paid: invoice.amount_paid,
})
break
}
Step 4: Real-Time Usage Meter Component
// components/UsageMeter.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function UsageMeter({
userId, eventType, limit, label,
}: {
userId: string; eventType: string; limit?: number; label?: string
}) {
const [used, setUsed] = useState(0)
const supabase = createClient()
useEffect(() => {
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()
supabase
.from('usage_events')
.select('quantity')
.eq('user_id', userId).eq('event_type', eventType).gte('created_at', monthStart)
.then(({ data }) => setUsed((data ?? []).reduce((s, r) => s + r.quantity, 0)))
}, [userId, eventType, supabase])
const pct = limit ? Math.min((used / limit) * 100, 100) : null
return (
<div className="space-y-1">
<div className="flex justify-between text-sm text-gray-600">
<span>{label ?? eventType}</span>
<span>{used.toLocaleString()}{limit ? ` / ${limit.toLocaleString()}` : ''}</span>
</div>
{pct != null && (
<div className="h-2 w-full rounded bg-gray-200">
<div
className={`h-2 rounded ${pct >= 90 ? 'bg-red-500' : 'bg-blue-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
)
}
When Usage-Based Billing Makes Sense
| Product Type | Fit |
|---|---|
| AI-powered features (tokens, API calls) | ✅ Cost-aligned |
| Storage / bandwidth dependent | ✅ Cost-aligned |
| Uniform usage patterns | ⚠️ Flat rate is simpler |
| B2C individual users | ⚠️ Some users dislike unpredictable bills |
| B2B team usage | ✅ Usage scales with team value |
Practical hybrid for indie hackers: Base fee (e.g., $10/month) + overage charges. This avoids Stripe's minimum invoice amount issue and gives users a predictable floor.
Full design rationale and Japanese-language version at: masatoman.net/articles/indie-dev-usage-based-billing-2026
Top comments (0)