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:
-
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
-
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:
-
User
: Beyond your regular user fields, we track their current credit balance. It’s a single number that we’ll update atomically. -
Usage
: Every time credits are spent, we log it. This helps with:- Debugging issues
- Showing usage history
- Audit trails
- Understanding usage patterns
-
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:
- 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.
- 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.
- 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:
- Create checkout session
- Handle successful payment
- 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:
- Authenticates the user.
- Receives the desired
packageId
from the request body. - Validates the package and finds the price and credits.
- 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! IncludeuserId
,credits
(to add), andpricePaid
(for logging). Store these as strings. Optionally passcustomer: user.stripeCustomerId
if available, orcustomer_email
otherwise. - 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:
Key Steps in the Webhook Handler:
- Verify Signature: Absolutely essential. Use
stripe.webhooks.constructEvent
with the raw request body, theStripe-Signature
header, and your webhook signing secret (process.env.STRIPE_WEBHOOK_SECRET
). Reject requests with invalid signatures immediately (400 Bad Request). - Check Idempotency: Before processing, query your
Purchase
table using the uniquecheckoutSessionId
from the event (event.data.object.id
). If a purchase with statusCOMPLETED
already exists, return200 OK
immediately to prevent adding credits twice if Stripe retries the webhook. - Start Transaction: Perform the database updates within a
prisma.$transaction
. - Add Credits: Parse
userId
andcredits
from the event’smetadata
(remember they are strings). Update theUser
record, incrementing thecredits
. - Log Purchase: Create or update the
Purchase
record. Store thestripeCheckoutSessionId
,stripePaymentIntentId
(fromevent.data.object.payment_intent
),creditsAdded
,pricePaid
, and set thestatus
toCOMPLETED
. Usingupsert
can handle potential retries gracefully. - Respond Correctly: Return
200 OK
to Stripe only if you successfully processed the event (or recognized it as a duplicate). Return5xx
if a temporary error occurred (e.g., database unavailable), signaling Stripe to retry. Return4xx
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:
- Storing user consent and configuration (threshold, amount, saved payment method ID) in the
User
model (see commented fields in schema). - Securely saving a payment method using Stripe Setup Intents (a separate flow).
- After deducting credits, checking if the balance is below the threshold and auto-topup is enabled.
- If needed, using the Stripe API to create and confirm an
off_session
Payment Intent using the saved payment method and customer ID. - Handling the
payment_intent.succeeded
andpayment_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
-
Monitor these metrics
- Failed payments
- Auto top-up success rate
- Credit usage patterns
- Balance distribution
-
Regular maintenance
- Clean old usage logs
- Archive completed purchases
- Verify credit totals
-
Performance
- Index frequently queried fields
- Cache user balances
- Batch usage logging
-
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.
- Use the Stripe CLI to test webhook handling locally (
-
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 foruseCredits
). - 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.