opncrafter

Stripe for AI Apps

Dec 30, 2025 • 20 min read

You've built the agent. Now let's get paid. Stripe is the dominant payment infrastructure for developer-built SaaS products, and for good reason: excellent documentation, a powerful API, and built-in support for the billing patterns AI apps need: subscriptions, usage-based billing, and credit systems. This guide covers everything from first checkout session to a fully protected, usage-tracked AI app.

1. Choosing Your Billing Model

Before writing any code, choose the model that matches your business:

Flat Subscription$X/month for Y messages

Simple, predictable for both sides. Best for uniform usage patterns.

Metered BillingCharge per token/request

Fair but complex. Users pay only what they use. Best for power-users.

Credit SystemBuy 1000 credits upfront

Great UX. Protects your margins. Feels like buying tokens.

Freemium + UpgradeFree tier + paid upgrade

Growth-focused. Best for getting users hooked before charging.

2. Setup: Installing and Configuring Stripe

npm install stripe @stripe/stripe-js

# .env.local (never commit these)
STRIPE_SECRET_KEY=sk_live_...          # Server-side only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...  # Client-safe
STRIPE_WEBHOOK_SECRET=whsec_...        # From Stripe dashboard

# Create a Stripe client singleton
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

3. Stripe Checkout: Getting Your First Payment

Stripe Checkout is a hosted payment page — Stripe handles the form, card validation, 3DS auth, and PCI compliance. This is the fastest path to accepting payments:

// app/api/checkout/route.ts (Next.js App Router)
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',  // or 'payment' for one-time
    customer_email: session.user.email,
    client_reference_id: session.user.id,  // Your internal user ID
    line_items: [{
      price: 'price_1234567890',  // From Stripe dashboard
      quantity: 1,
    }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    metadata: { userId: session.user.id },
  });

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

// Client: redirect to Stripe
const response = await fetch('/api/checkout', { method: 'POST' });
const { url } = await response.json();
window.location.href = url;  // Redirect to Stripe hosted page

4. Webhooks: Reacting to Payment Events

Stripe communicates back to your server via webhooks. Never rely on polling or redirect URL parameters — webhooks are the authoritative source of truth for payment status:

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

export async function POST(request: Request) {
  const body = await request.text();
  const sig = headers().get('stripe-signature')!;

  let event;
  try {
    // ALWAYS verify webhook signature — prevents spoofing
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed':
      const checkoutSession = event.data.object;
      // User just paid! Grant them access
      await db.user.update({
        where: { id: checkoutSession.metadata.userId },
        data: {
          stripeCustomerId: checkoutSession.customer,
          subscriptionId: checkoutSession.subscription,
          subscriptionStatus: 'active',
          planType: 'pro',
        }
      });
      break;

    case 'customer.subscription.deleted':
    case 'invoice.payment_failed':
      // Subscription ended or payment failed — revoke access
      await db.user.update({
        where: { stripeCustomerId: event.data.object.customer },
        data: { subscriptionStatus: 'inactive', planType: 'free' }
      });
      break;
  }

  return Response.json({ received: true });
}

5. Metered Billing: Charge Per Token

Stripe's metered billing lets you report real usage at the end of each billing period. Report after every LLM call:

// After each LLM completion
const completion = await openai.chat.completions.create(...);
const tokensUsed = completion.usage.total_tokens;

// Find the user's Stripe subscription item ID
const subscriptionItemId = await getSubscriptionItemId(userId);

// Report to Stripe (can be batched, Stripe aggregates daily)
await stripe.subscriptionItems.createUsageRecord(
  subscriptionItemId,
  {
    quantity: tokensUsed,
    timestamp: Math.floor(Date.now() / 1000),
    action: 'increment', // Adds to existing count (vs 'set')
  }
);

// Equivalent pricing: 1M tokens @ $5 in Stripe = 
// Price the Stripe usage product at $0.000005 per unit

6. Credit System Implementation

Credits are a popular alternative that provides clear user expectations and protects your margin:

// Database schema
model User {
  id            String   @id
  credits       Int      @default(50)  // Start with 50 free credits
  stripeCustomerId String?
}

// Credit costs by feature
const CREDIT_COSTS = {
  chat_message: 5,      // Basic Q&A
  doc_analysis: 25,     // Long-context with RAG
  image_gen: 50,        // Expensive multimodal
} as const;

// Middleware: check credits before LLM call
async function checkAndDeductCredits(userId: string, feature: keyof typeof CREDIT_COSTS) {
  const cost = CREDIT_COSTS[feature];
  
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user || user.credits < cost) {
    throw new Error('Insufficient credits. Purchase more to continue.');
  }
  
  // Atomic deduction — prevents race conditions
  await db.user.update({
    where: { id: userId },
    data: { credits: { decrement: cost } }
  });
}

// Stripe: sell credit packs as one-time purchases
// Create a Stripe Payment Link for "500 Credits — $5"
// On checkout.session.completed webhook:
await db.user.update({
  where: { id: userId },
  data: { credits: { increment: 500 } }
});

7. Protecting Routes by Subscription Status

// middleware.ts — protect all /api/ai/* routes
export async function requirePro(request: Request) {
  const session = await auth();
  if (!session?.user) return Response.json({ error: 'Login required' }, { status: 401 });

  const user = await db.user.findUnique({ where: { id: session.user.id } });
  
  if (user?.subscriptionStatus !== 'active') {
    return Response.json({
      error: 'Pro subscription required',
      upgradeUrl: '/pricing',
    }, { status: 402 }); // 402 Payment Required
  }
}

// In any API route:
export async function POST(request: Request) {
  const gateResponse = await requirePro(request);
  if (gateResponse) return gateResponse;  // Blocked
  
  // User is active paying subscriber, proceed
  const result = await runAgent(await request.json());
  return Response.json(result);
}

8. Customer Portal: Self-Service Billing

Let users manage their own subscriptions without contacting support:

// app/api/customer-portal/route.ts
export async function POST() {
  const user = await getAuthenticatedUser();

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  });

  return Response.json({ url: portalSession.url });
}
// The portal lets users: upgrade/downgrade plan, update payment method, 
// view invoice history, cancel subscription

Frequently Asked Questions

Should I use Stripe Radar for fraud prevention?

Yes — enable it on your Stripe dashboard at no extra cost. For AI apps specifically, watch for card testing attacks (small test charges) and free trial abuse (multiple accounts). Add rate limiting on checkout creation and require email verification before trial activation.

How do I test webhooks locally?

Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe. This creates a tunnel and forwards real Stripe events to your local server, letting you test the entire webhook flow without deployment.

What's the fastest way to verify a user's subscription status in middleware?

Store subscription status in your own database, updated via webhooks — never call the Stripe API on every request. Direct Stripe status checks add 100-300ms of latency per request. Your database query should take less than 5ms.

Conclusion

Stripe handles the hardest parts of payments: PCI compliance, international cards, 3DS authentication, subscription lifecycle management, and tax compliance. Your job is to model your billing correctly, handle webhooks reliably, and protect your AI features behind subscription gates. With this foundation, you can ship a monetized AI product in a weekend.

Continue Reading

👨‍💻
Written by

Vivek

AI Engineer

Full-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.

GPT-4oLangChainNext.jsVector DBsRAGVercel AI SDK