Building a Credit-Based Billing System for AI SaaS Apps
How to design token-based billing for LLM-powered SaaS products — plan tiers, cost math, and the edge cases that break production.
Most tutorials on AI SaaS billing stop at "charge per API call." That breaks the moment you have multiple LLM providers, embedding costs, and tiered plans with different markup ratios. This guide covers the actual architecture — and the production bugs I found and fixed — while building a credit system for a RAG (Retrieval-Augmented Generation) application.
Why Per-Call Billing Doesn't Work
LLM cost isn't flat. A single request can consume:
- Input tokens – priced differently across models (e.g., Gemini Flash-Lite vs. GPT-4o)
- Output tokens – usually 2–4x the cost of input tokens
- Embedding tokens – separate pricing tier for RAG/vector search calls
- Retries and function calls – multi-turn tool use multiplies token consumption per user action
Charging a flat "1 credit per request" either underprices heavy users or overprices light ones. The fix is a token-to-credit conversion layer sitting between your provider bill and your user-facing pricing.
1. Core Schema Design
Track usage at the token level, not the request level:
// models/UsageLog.js
const usageLogSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
provider: { type: String, enum: ["gemini", "openai", "claude"], required: true },
model: { type: String, required: true },
inputTokens: { type: Number, required: true },
outputTokens: { type: Number, required: true },
embeddingTokens: { type: Number, default: 0 },
creditsCharged: { type: Number, required: true },
createdAt: { type: Date, default: Date.now, index: true }
});
usageLogSchema.index({ userId: 1, createdAt: -1 });
2. The creditConfig.js Pattern (and Its Bugs)
Centralize your pricing logic in one config file — never scatter cost math across routes. Here's the pattern, along with five bugs that commonly slip into production versions of this exact file:
// config/creditConfig.js
const PLAN_LIMITS = {
free: { monthlyCredits: 100, maxCreditsPerRequest: 5 },
pro: { monthlyCredits: 5000, maxCreditsPerRequest: 50 },
enterprise: { monthlyCredits: Infinity, maxCreditsPerRequest: Infinity }
};
const TOKENS_PER_CREDIT = {
free: 1000, // 1 credit = 1000 tokens
pro: 1500, // cheaper per-token rate at higher tier
enterprise: 2000
};
function tokensToCredits(totalTokens, plan) {
const ratio = TOKENS_PER_CREDIT[plan] ?? TOKENS_PER_CREDIT.free;
return Math.ceil(totalTokens / ratio);
}
module.exports = { PLAN_LIMITS, TOKENS_PER_CREDIT, tokensToCredits };
Bugs to check for in your own implementation:
- Negative env var handling – if
MONTHLY_CREDITS_OVERRIDEis read fromprocess.envwithout validation, a misconfigured negative value silently grants unlimited usage instead of throwing. Infinityserialization –Infinitydoesn't surviveJSON.stringify()(becomesnull). If plan limits are cached in Redis or sent to the frontend, enterprise limits appear asnullinstead of unlimited.- Mutable plan budgets – if
PLAN_LIMITSobjects are passed by reference and mutated per-request (e.g., decrementing a shared object instead of a per-user copy), one user's usage can corrupt another's limit. - Non-monotonic tokens-per-credit ratio – pricing that gets cheaper per token at higher tiers (as shown above) needs to be intentional. If it's accidental, downgrading users mid-cycle can make their remaining credits worth less than expected, causing support tickets.
- Rounding direction – always round token-to-credit conversion up (
Math.ceil), never down. Rounding down means you absorb the cost of partial credits at scale.
3. Enforcing Limits Before the LLM Call
Check and reserve credits before calling the provider API — not after — or a slow user can burn through their limit with concurrent requests before the deduction lands:
// middleware/checkCredits.js
const User = require("../models/User");
const { PLAN_LIMITS } = require("../config/creditConfig");
async function checkCredits(req, res, next) {
const user = await User.findById(req.user.id);
const limit = PLAN_LIMITS[user.plan];
if (user.creditsUsedThisMonth >= limit.monthlyCredits) {
return res.status(429).json({
error: "CREDIT_LIMIT_EXCEEDED",
message: "Monthly credit limit reached. Upgrade your plan to continue.",
creditsRemaining: 0
});
}
// Optimistic reservation — reconcile actual cost after the LLM response
req.creditReservation = Math.min(limit.maxCreditsPerRequest, limit.monthlyCredits - user.creditsUsedThisMonth);
next();
}
module.exports = checkCredits;
Reconcile the reservation with actual token usage in the response handler, and use findOneAndUpdate with $inc for atomic credit deduction — never read-modify-write on the user document, which race-conditions under concurrent requests.
4. Cost Analysis Before You Set Prices
Before launching a plan, model your margin per tier using real provider pricing:
| Plan | Monthly Price | Credits | Est. Tokens | Provider Cost | Gross Margin |
|---|---|---|---|---|---|
| Free | $0 | 100 | ~100K | ~$0.02 | Loss leader |
| Pro | $19 | 5,000 | ~7.5M | ~$1.10 | ~94% |
| Enterprise | Custom | Unlimited | Variable | Metered | Negotiated |
Run this analysis per-provider (Gemini Flash-Lite, GPT-4o-mini, Claude Haiku) since embedding and inference pricing shifts frequently — hard-coding a single provider's rates into your margin model is a common mistake when providers update pricing without notice.
FAQ: AI SaaS Billing Systems
Should I bill by tokens or by requests?
Tokens. Request-based billing works only if every request has near-identical cost, which isn't true once you support multiple models, tool calls, or RAG retrieval steps.
How do I handle users who exceed their limit mid-request (e.g., during a long tool-use chain)?
Reserve an estimated credit budget before the call starts, cap it at maxCreditsPerRequest, and hard-stop the chain if the reservation is exhausted — don't let a single request go uncapped even for paying tiers, or one runaway agent loop can wipe out your margin on that user for the month.
Is Redis or MongoDB better for tracking real-time credit balances?
Redis for the hot path (checking/decrementing balance on every request — sub-millisecond reads), MongoDB for the source-of-truth ledger (usage logs, monthly resets, billing reconciliation). Sync Redis from MongoDB on a scheduled job, not the other way around.
Need help architecting a billing system for your AI product? View my SaaS development services or contact me — I build credit-based billing, usage metering, and LLM cost optimization for SaaS founders.