Why Usage-Based Pricing Felt Right for My AI Tool — and How I Pulled It Of
The idea was simple:
Let people speak to an AI tutor about any topic, get real-time responses, and build their own companions.
But the economics weren’t.
Every voice session had a real cost. The API I used (Vapi) abstracted everything—speech-to-text, GPT calls, voice response—into one beautiful endpoint. But behind that endpoint were real tokens, compute, and pricing.
So when I launched Learnflow AI, I had to make a decision:
Should I charge monthly? Freemium? Or usage-based?
I went with usage-based pricing. Here’s why it made sense, where it went wrong, and how I implemented it in a way that didn’t kill the experience.
The Early Problem: Invisible Cost, Invisible Value
Before monetizing, I gave users 10 free voice sessions.
Each session started a conversation with a tutor and deducted 1 credit.
But it didn’t work as I expected.
People used a few sessions, then stopped.
When I spoke to early testers, a pattern emerged:
“I wasn’t sure what I was using up.”
“It just felt like a demo, not something I’d pay for.”
That’s when I realized:
- People didn’t understand what they were consuming
- They couldn’t perceive value without visible cost
I needed to make usage clear, intentional, and tied to pricing.
Why I Chose Usage-Based Pricing
A monthly plan would hide the economics. Power users would overload the system. Casual users wouldn’t convert.
And AI usage is spiky by nature.
I wanted:
- Fairness: pay for what you use
- Scalability: cost and revenue scale together
- Flexibility: let users try it before upgrading
So I designed a tiered usage-based system:
Plan | Price | Sessions | Notes |
---|---|---|---|
Free | $0 | 10/month | No card required |
Pro | $9/mo | 100/month | Extra features included |
Pay-as-you-go | $X | Unlimited | (Planned later) |
Designing the Usage System (Step-by-Step)
1. Tracking Usage per User
I used Convex as my backend. Every time a session started, I recorded it:
export const addSession = mutation({
args: { userId: v.id("users"), companionId: v.id("companions") },
handler: async (ctx, args) => {
await ctx.db.insert("sessions", {
userId: args.userId,
companionId: args.companionId,
timestamp: Date.now()
});
// Decrement credit
const user = await ctx.db.get(args.userId);
const credits = user.credits || 0;
await ctx.db.patch(args.userId, { credits: credits - 1 });
}
});
The users
table included:
users: defineTable({
email: v.string(),
credits: v.optional(v.number()),
plan: v.optional(v.string()),
})
Every voice call = 1 credit.
2. Blocking or Nudging on Zero Credits
In the client:
if (user.credits === 0) {
showModal("You're out of credits. Upgrade to continue.");
return;
}
On the backend:
if (user.credits <= 0 && user.plan === "free") {
throw new Error("Out of credits. Please upgrade.");
}
I didn’t want users to hit a hard block without understanding why.
So I added inline nudges before and after usage.
Making Usage Visible in the UI
The fix that worked?
Make credits a part of the experience.
Header Credit Counter
<div className="text-sm text-muted-foreground">
{user.credits} credits remaining
</div>
Usage Toasts
toast({
title: "Session Complete",
description: "1 credit used. You have 8 left."
});
Tutor Cards
<p className="text-xs text-gray-500">
{isProOnly ? "Pro feature" : "Free access"}
</p>
Users now understood the exchange: 1 session = 1 credit.
That clarity increased retention.
Flowchart: New User Journey
Orchestration: Where Everything Lives
Frontend (Next.js):
- Shows credit counter
- Blocks voice call if credits = 0
- Routes user to upgrade screen
Backend (Convex):
- User data:
user.credits
- Tracks session start
- Deducts credits
- Stores session data
Auth + Plans (Kinde):
- Hosted pricing screen on signup
- Metadata:
user.plan
- Guards Pro-only features
Implementing Kinde Roles and Billing
During signup, users are redirected to Kinde’s hosted pricing page.
Once selected, the plan is stored in metadata.
const { getUser } = getKindeServerSession();
const user = await getUser();
const plan = user?.user_metadata?.plan || "free";
I synced the plan
and credits
field in Convex as well, during onboarding:
export const syncUserMetadata = mutation({
args: { userId: v.id("users"), plan: v.string(), credits: v.number() },
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
plan: args.plan,
credits: args.credits,
});
}
});
That way, the backend didn’t rely on client state.
What Went Wrong
- Users didn’t understand credit = cost = value
- Usage was invisible
- Upgrade CTAs were buried
What Worked (Eventually)
- Visible credit tracking
- Plan-based access control
- Prompting on behavior (not just time)
- Kinde for pricing clarity
- Convex for metering + real-time sync
Key Lessons
- Make usage visible. Don’t just meter silently.
- Price based on behavior. Voice is expensive — show users how.
- Gate softly. Give warnings, not just walls.
- Sync state across systems. Auth + backend must stay aligned.
- Users convert after value. Delay upgrade prompts until after first good session.
Final Thoughts
Usage-based pricing isn’t just a business model. It’s a design constraint.
If users don’t know what they’re consuming, they can’t appreciate it.
If they don’t feel control, they won’t pay.
Learnflow AI became clearer, fairer, and more scalable once pricing and product aligned.
I didn’t need a full billing team. Just good primitives:
- Kinde for roles, trials, and upgrades
- Convex for data and logic
- Vapi for instant voice UX
If you’re building a usage-heavy AI app, price like it.
Track it. Show it. Nudge it. Sync it.
That’s the playbook.