Implementing Pre-paid Usage Billing with Next.js and Stripe

·11 min read

blog/implementing-pre-paid-usage-billing-with-nextjs-and-stripe

Table of Contents

Introduction

If you’re building a SaaS product that needs to charge based on usage (like an API service or processing platform), you might have encountered Stripe’s usage-based billing API. While Stripe’s Metered Billing API is powerful for post-paid scenarios, many SaaS applications benefit from the simplicity and predictability of a pre-paid credit system.

In this guide, we’ll build a pre-paid credit system using Next.js and Stripe. We’ll focus on the backend implementation using Next.js API Routes, Prisma for data persistence, and Stripe for payments, covering how to track credits, handle usage, and manage purchases securely.

We’ll implement the backend logic within Next.js API Routes, using Prisma to interact with our database and Stripe to handle the payment processing.

Why Pre-paid Instead of Post-paid?

Let’s start by understanding why you might want to avoid post-paid billing:

  1. Post-paid billing (like Stripe’s metering API) means:

    • You trust customers to pay after using your service
    • Complex implementation with meter events
    • More webhook handling
    • Risk of unpaid usage
    • Better for enterprise customers who need flexible billing
  2. Pre-paid credits are better for most SaaS because:

    • No risk of unpaid usage
    • Simpler to implement
    • Easier for customers to understand
    • Better cash flow (money upfront)
    • Still flexible enough for most use cases

Core Data Model

The foundation of our pre-paid system is the data model. Here’s what we need to track:

model User {
  id               String    @id @default(cuid())
  email            String?   @unique
  name             String?
  credits          Int       @default(0) // Current available credits
  stripeCustomerId String?   @unique // Needed for saved payment methods / auto-topup

  // Auto Top-up Settings (Covered conceptually later)
  autoTopUp          Boolean   @default(false)
  topUpThreshold     Int?
  topUpAmountCredits Int?
  topUpPaymentMethod String? // Stripe PaymentMethod ID

  usageLog  Usage[]
  purchases Purchase[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Usage {
  id        String   @id @default(cuid())
  userId    String
  amount    Int      // Credits deducted
  reason    String?  // Optional: Why credits were used (e.g., "API Call")
  timestamp DateTime @default(now())
  success   Boolean  // Was the operation consuming credits successful?
  error     String?  // Error message if deduction failed

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, timestamp]) // Index for querying user usage history
}

model Purchase {
  id                      String   @id @default(cuid())
  userId                  String
  creditsAdded            Int      // How many credits were granted
  pricePaid               Int      // Amount paid (e.g., in cents)
  currency                String   @default("usd")
  stripeCheckoutSessionId String   @unique // ID from Stripe Checkout Session
  stripePaymentIntentId   String?  @unique // ID from the resulting Payment Intent
  status                  String   @default("PENDING") // PENDING, COMPLETED, FAILED
  timestamp               DateTime @default(now())
  updatedAt               DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId]) // Index for querying user purchase history
}

Let’s break down why each model matters:

  1. User: Beyond your regular user fields, we track their current credit balance. It’s a single number that we’ll update atomically.

  2. Usage: Every time credits are spent, we log it. This helps with:

    • Debugging issues
    • Showing usage history
    • Audit trails
    • Understanding usage patterns
  3. Purchase: Tracks every time credits are bought. Important for:

    • Payment reconciliation
    • Purchase history
    • Customer support
    • Financial reporting

Core Business Logic

The heart of our system is credit management. Here are the key operations we need to handle:

1. Checking Balance

Before any operation that uses credits, we need to check if the user has enough:

async function checkBalance(userId: string, required: number) {
  // Use Prisma's findUnique to get current balance
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { credits: true }
  });

  if (!user) throw new Error('User not found');
  return user.credits >= required;
}

2. Deducting Credits

Deducting credits reliably requires careful handling, especially under concurrent requests. The key steps are:

  1. Atomicity: Perform the balance check and the decrement in a single, atomic database operation. This prevents race conditions where two requests might both see sufficient balance before either deducts credits.
  2. Transaction: Wrap the credit deduction and the usage logging within a database transaction. This ensures that if logging fails, the credit deduction is rolled back, maintaining data integrity.
  3. Logging: Record every deduction attempt, whether successful or failed (e.g., due to insufficient funds).

Here’s how Prisma helps achieve atomicity within a transaction:

async function useCredits(userId: string, amount: number) {
  if (amount <= 0) {
    throw new Error("Credit amount must be positive.");
  }

  // Use a transaction to ensure atomicity
  return await prisma.$transaction(async (tx) => {
    // Update credits and check balance in one operation
    const user = await tx.user.update({
      where: { 
        id: userId,
        credits: { gte: amount } // CRITICAL: Check balance *within* the update condition
      },
      data: { credits: { decrement: amount } } // Only happens if 'where' clause is met
    });

    // If update succeeded, update the log entry
    await tx.usage.create({
      data: {
        userId,
        amount,
        success: true
      }
    });

    return user;
  });

  // If the update fails (throws P2025 error), catch it and log failure:
  // catch (error) { if (error.code === 'P2025') { log failure... } }
}

Important: This logic should reside in a secure backend environment, like a Next.js API route, called internally by your application when a service consumes credits.

Handling Credit Purchases

When a user needs to buy credits, we’ll use Stripe Checkout for the payment flow. Here’s how it works:

1. Credit Packages

First, define your credit packages:

const CREDIT_PACKAGES = [
  { id: 'basic', name: 'Basic', credits: 100, price: 1000, currency: 'usd' }, // $10 for 100 credits
  { id: 'pro', name: 'Pro', credits: 1000, price: 8000, currency: 'usd' },  // $80 for 1000 (20% discount)
  { id: 'team', name: 'Team', credits: 10000, price: 60000, currency: 'usd' } // $600 for 10000 (40% discount)
] as const;

2. Purchase Flow

The flow has three parts:

  1. Create checkout session
  2. Handle successful payment
  3. Add credits to user balance

Step 1: Creating the Checkout Session (API Route)

Create a Next.js API route (e.g., /api/credits/checkout) that:

  1. Authenticates the user.
  2. Receives the desired packageId from the request body.
  3. Validates the package and finds the price and credits.
  4. Calls stripe.checkout.sessions.create with: mode: 'payment' line_items: Based on the selected package price and name. success_url: Your frontend URL for successful purchases (e.g., yoursite.com/purchase-success?session_id={CHECKOUT_SESSION_ID}). cancel_url: Your frontend URL for cancelled purchases. metadata: This is crucial! Include userId, credits (to add), and pricePaid (for logging). Store these as strings. Optionally pass customer: user.stripeCustomerId if available, or customer_email otherwise.
  5. Returns the session.url to the frontend, which then redirects the user to Stripe.

Step 2 & 3: Handling Successful Payment (Webhook)

Stripe notifies your application about successful payments via webhooks. You need a dedicated API route (e.g., /api/webhooks/stripe) to listen for the checkout.session.completed event. This is the most critical part for security and reliability.

Here’s the flow:

stripe

Key Steps in the Webhook Handler:

  1. Verify Signature: Absolutely essential. Use stripe.webhooks.constructEvent with the raw request body, the Stripe-Signature header, and your webhook signing secret (process.env.STRIPE_WEBHOOK_SECRET). Reject requests with invalid signatures immediately (400 Bad Request).
  2. Check Idempotency: Before processing, query your Purchase table using the unique checkoutSessionId from the event (event.data.object.id). If a purchase with status COMPLETED already exists, return 200 OK immediately to prevent adding credits twice if Stripe retries the webhook.
  3. Start Transaction: Perform the database updates within a prisma.$transaction.
  4. Add Credits: Parse userId and credits from the event’s metadata (remember they are strings). Update the User record, incrementing the credits.
  5. Log Purchase: Create or update the Purchase record. Store the stripeCheckoutSessionId, stripePaymentIntentId (from event.data.object.payment_intent), creditsAdded, pricePaid, and set the status to COMPLETED. Using upsert can handle potential retries gracefully.
  6. Respond Correctly: Return 200 OK to Stripe only if you successfully processed the event (or recognized it as a duplicate). Return 5xx if a temporary error occurred (e.g., database unavailable), signaling Stripe to retry. Return 4xx for permanent errors (e.g., bad request, invalid event data).

Optional Extension: Auto Top-ups

To prevent service interruption, you could implement an auto top-up feature. This typically involves:

  1. Storing user consent and configuration (threshold, amount, saved payment method ID) in the User model (see commented fields in schema).
  2. Securely saving a payment method using Stripe Setup Intents (a separate flow).
  3. After deducting credits, checking if the balance is below the threshold and auto-topup is enabled.
  4. If needed, using the Stripe API to create and confirm an off_session Payment Intent using the saved payment method and customer ID.
  5. Handling the payment_intent.succeeded and payment_intent.payment_failed webhook events to add credits or notify the user of failures.

This adds significant complexity and is beyond the scope of this initial guide but is a common pattern for usage-based systems.

Error Handling & Edge Cases

Here are crucial scenarios to handle:

1. Race Conditions

As highlighted in the ‘Deducting Credits’ section, the primary defense against race conditions is using Prisma’s atomic update (where: { credits: { gte: amount } }) within a transaction.

// Instead of separate check and update
await prisma.user.update({
  where: { 
    id: userId,
    credits: { gte: requiredAmount } // This ensures atomicity
  },
  data: { credits: { decrement: requiredAmount } }
});

2. Failed Payments

Track payment status using the status field in the Purchase model (PENDING, COMPLETED, FAILED). The webhook handler should update this.

model Purchase {
  // ... existing fields
  status    String @default("pending") // pending, completed, failed
}

3. Usage Limits

Implement rate limiting:

// Simple rate limit check
const RATE_LIMIT = 100; // credits per minute
const usage = await prisma.usage.count({
  where: {
    userId,
    timestamp: { gt: new Date(Date.now() - 60000) }
  }
});

if (usage > RATE_LIMIT) {
  throw new Error('Rate limit exceeded'); 
}

4. Webhook Failures & Reliability

Stripe expects a 200 OK response quickly from your webhook. If it fails or times out, Stripe will retry with exponential backoff. Ensure your webhook handler is:

  • Idempotent: Can safely process the same event multiple times (as shown with the idempotency check).
  • Resilient: Handles errors gracefully and logs issues.
  • Performant: Offload any long-running tasks (like sending emails) to a background job queue if necessary, don’t block the webhook response.

Production Considerations

  1. Monitor these metrics

    • Failed payments
    • Auto top-up success rate
    • Credit usage patterns
    • Balance distribution
  2. Regular maintenance

    • Clean old usage logs
    • Archive completed purchases
    • Verify credit totals
  3. Performance

    • Index frequently queried fields
    • Cache user balances
    • Batch usage logging
  4. Testing:

    • Use the Stripe CLI to test webhook handling locally (stripe listen --forward-to localhost:3000/api/webhooks/stripe).
    • Use Stripe’s test mode and test card numbers.
    • Write integration tests for your API routes and credit logic, covering success, insufficient funds, and error cases.
  5. Security:

    • Always verify webhook signatures. Never trust webhook data without verification.
    • Protect your API routes (checkout, credit deduction) with proper authentication and authorization.
    • Sanitize and validate all inputs.
    • Keep your Stripe secrets secure (use environment variables).

Integrating with Next.js

Throughout this guide, we’ve discussed backend logic. Here’s how it fits into a Next.js application:

  • API Routes: The core logic resides in API routes (e.g., /pages/api/credits/checkout.ts, /pages/api/webhooks/stripe.ts, and potentially internal routes for useCredits).
  • Frontend Interaction: Your frontend (React components) will trigger the purchase flow. A button click might call a function like:
// Example frontend function (simplified)
const purchaseCredits = async (packageId) => {
  try {
    const response = await fetch('/api/credits/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ packageId }),
    });
    const data = await response.json();
    if (data.checkoutUrl) {
      window.location.href = data.checkoutUrl; // Redirect to Stripe
    } else {
      // Handle error
    }
  } catch (error) {
    // Handle error
  }
};
  • Displaying Credits: Fetch the user’s current credit balance (e.g., via getServerSideProps, getStaticProps with revalidation, or a client-side fetch to a user data API route) and display it in the UI.
  • Environment Variables: Store your Stripe keys (STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY) and webhook secret (STRIPE_WEBHOOK_SECRET) securely in environment variables (.env.local).

Conclusion

Implementing a pre-paid credit system with Next.js, Prisma, and Stripe provides a robust, secure, and user-friendly billing model suitable for many SaaS applications. By focusing on atomic database operations for deductions, secure Stripe Checkout for purchases, and crucially, idempotent and verified webhook handling for fulfillment, you can build a reliable system.

Key takeaways include the importance of atomicity in credit deductions, the non-negotiable security practice of webhook signature verification, and designing for idempotency to handle network realities. While simpler than full metered billing in many ways, careful implementation of these core concepts is essential for a trustworthy platform.

Enjoyed this article? Subscribe for more!

Stay Updated

🎁 LLM Prompting Cheat Sheet for Developers

Plus get fresh content delivered to your inbox. No spam, ever.

Related PostsTags: Stripe, Nodejs, Next.js, Prisma, Billing, Development

© 2025 Comyoucom Ltd. Registered in England & Wales