Skip to main content

Payments

In one line: Free with a generous cap → $10–$20/mo for "remove the cap." Stripe Checkout + a webhook is the entire backend.

In plain English

Solo AI products almost always converge on the same pricing shape: free tier with auth and a daily limit, paid tier that removes (or massively raises) the limit. You don't need usage-based metered billing on day one; a flat monthly fee with a generous quota beats it for simplicity, and your users will tell you when they need more.

The default pricing shape

For a solo AI tool in 2026:

TierPriceLimitWho buys
Free$010–50 requests / dayBrowsers, evaluators
Indie$5–$15/mo500–2,000 requests / dayHobbyists, power users
Pro$20–$50/mo10,000 / day, faster modelWork-tool users
CustomTalk to meAnything aboveThe 1 customer that pays

A single Stripe price ID per tier, monthly subscription. No annual at v0 (the discount isn't worth the support burden). No usage-based until the simple version is being out-grown by real customers.

Why flat-rate beats usage-based at v0

  • Users hate surprise bills more than they hate higher prices. $20/mo is easier to swallow than $0.003/request when they can't predict request count.
  • Stripe Checkout handles subscriptions natively with one URL; metered billing requires you to call the API on every request to record usage.
  • Cost certainty for you, too. A flat tier with a generous limit caps your per-user blast radius.

When to switch to usage-based: when you have one customer paying $500/mo on the Pro plan because they actually use it, and one customer paying $20/mo using it as much as the $500 customer. That asymmetry is the signal.

The minimal Stripe wiring

Two endpoints. One for creating the Checkout session, one for the webhook.

Step 1: create a Checkout session

// app/api/checkout/route.ts
import Stripe from "stripe";
import { auth } from "@clerk/nextjs/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return new Response("unauthorized", { status: 401 });

const { tier } = await req.json(); // "indie" or "pro"
const priceId = tier === "pro"
? process.env.STRIPE_PRICE_PRO!
: process.env.STRIPE_PRICE_INDIE!;

const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/app?upgraded=1`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId, tier },
});

return Response.json({ url: session.url });
}

Step 2: handle the webhook

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { supabaseAdmin } from "@/lib/supabase";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature")!;
const body = await req.text();
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);

if (event.type === "checkout.session.completed") {
const s = event.data.object;
await supabaseAdmin.from("subscriptions").upsert({
user_id: s.metadata!.userId,
tier: s.metadata!.tier,
stripe_customer_id: s.customer as string,
stripe_subscription_id: s.subscription as string,
status: "active",
});
}

if (event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated") {
const sub = event.data.object;
await supabaseAdmin.from("subscriptions")
.update({ status: sub.status })
.eq("stripe_subscription_id", sub.id);
}

return new Response("ok");
}

That's the entire backend. The metadata: { userId, tier } is what bridges Stripe's customer ID and your user — same trick as in any Stripe integration.

Reading the user's tier

When checking rate limits or unlocking features, read the user's tier from your DB:

const { data: sub } = await supabaseAdmin
.from("subscriptions")
.select("tier, status")
.eq("user_id", userId)
.single();

const tier = (sub?.status === "active" ? sub.tier : "free") ?? "free";
const dailyLimit = { free: 10, indie: 500, pro: 10000 }[tier];

Cache this (or join into your auth context) so you're not hitting the DB on every request.

Pass-through vs marked-up

A perennial solo-AI question: do you mark up the LLM cost, or pass it through transparently?

Pass-through ("you pay $20/mo, that gets you $19 of Claude credits, we keep $1 for the wrapper"):

  • Honest, defensible, often pitched in the FAQ.
  • Works when your interface is the value, not the prompt engineering.
  • Common pattern in 2026 chat-with-X products.

Marked-up ("$20/mo gets you our prompt + our UI + the model"):

  • Standard SaaS pricing.
  • Works when your prompt is doing real work (legal review, code review, summarization with QA).
  • Don't itemize the cost — sell the outcome.

For most solo tools, mark up. Your prompt is the product. A user paying $15/mo for "summarize my meetings" doesn't care that the underlying API call cost $0.40 — they care that they saved an hour.

Polar and Lemon Squeezy

If you have international customers, sales tax becomes a non-trivial problem. Two alternatives:

  • Polar.sh — built for indie devs, handles VAT/sales-tax-as-merchant-of-record, GitHub integration, similar API surface to Stripe.
  • Lemon Squeezy — same value prop; been around longer; now owned by Stripe but still operates as merchant-of-record.

Both add ~5% on top of Stripe's standard fee in exchange for absorbing the tax compliance. For a US-only beta, plain Stripe is fine. For a global launch, Polar or Lemon Squeezy saves you from learning EU VAT rules.

Worked example: tier + cost math

You charge $15/mo for the Indie tier with a 1000-requests/day limit.

Assume average use: 30 requests/day (most users hit the tier ceiling rarely). Cost per request: ~$0.01 (Claude Sonnet, modest input/output). Cost per user per month: 30 × 30 × $0.01 = $9.

Gross margin: ($15 − $9) / $15 = 40%. Tight but workable.

If average use jumps to 200 req/day, margin flips negative. The fix: raise the price, lower the limit, OR move heavy users to Pro. Build the dashboard that shows you per-user request counts (see observability) so you can spot this before it bites.

Highlight: don't ship a trial

For a $10–$20/mo AI tool, a 14-day free trial creates more support burden than it earns subscribers. The free tier with a daily limit is the trial. Users who want more upgrade; users who don't, churn anyway. The fix is one less surface to maintain: free tier OR paid tier, no trial.

Common mistakes

Where people commonly trip up
  • Granting access on the success_url redirect. Users bookmark /app?upgraded=1 and "upgrade" for free. The fix is to grant access only in the webhook handler — the redirect is UX, not proof.
  • Forgetting customer.subscription.deleted. Users cancel, but you keep treating them as Pro for months. The fix is to handle the full lifecycle from day one.
  • Not handling failed payments. A card declines, Stripe retries, eventually marks the sub past_due. If you don't react, you've given them weeks of free Pro. The fix is to listen for customer.subscription.updated and downgrade on non-active statuses.
  • Per-request usage-based on a unit users don't understand. "$0.001 per 1k tokens output" is incomprehensible to non-AI people. The fix is flat-rate with simple limits or "credits" (1 credit = 1 generation), priced as a pack.
  • Testing only with 4242 4242 4242 4242. Never sees a decline, never sees 3DS, never sees the redirect-then-webhook race. The fix is to also run 4000 0025 0000 3155 (3DS) and 4000 0000 0000 0341 (auth succeeds, charge fails later) before going live.

Page checkpoint

Self-check:

  • Does the webhook (not the redirect) flip the user to a paid tier?
  • Do you handle customer.subscription.deleted and .updated events?
  • Do you know your gross margin per tier on a typical-user assumption?
🤔 Quick checkQuick check

What's next

→ Continue to Deployment where we'll lock in the git push → live pipeline and avoid the most common preview-env disaster.