Building AI Credit Systems: Usage Tracking and Fair Billing
Implement credit-based usage tracking for AI platforms with accurate cost attribution, quota management, overage protection, and transparent billing.

Implement credit-based usage tracking for AI platforms with accurate cost attribution, quota management, overage protection, and transparent billing.

TL;DR
Jump to Credit system design · Jump to Tracking implementation · Jump to Quota enforcement · Jump to Billing integration
AI platforms face a unique billing challenge: costs vary wildly based on usage. One user might consume $0.05 worth of tokens, another $50, in the same billing period. Credit systems solve this by converting unpredictable backend costs into understandable user-facing units.
This guide covers implementing a production credit system, drawing from Athenic's architecture where we track millions of credit transactions monthly across thousands of organizations with <0.1% billing discrepancies.
Key takeaways
- Abstract AI costs behind credits: 1 credit ≈ fixed dollar value ($0.01–$0.10 depending on pricing model).
- Deduct credits synchronously before operations to prevent quota violations.
- Log all transactions with immutable audit trails for billing disputes.
- Monitor credit burn rates to identify anomalous usage and prevent abuse.
Direct billing problems:
Credit system benefits:
| Model | Description | Example | Best for |
|---|---|---|---|
| Fixed conversion | 1 credit = fixed dollar value | 1 credit = $0.05 | Simple pricing, broad operations |
| Tiered | Credit value decreases with volume | 1-1K credits: $0.05 each, 1K-10K: $0.04 each | Enterprise customers |
| Operation-specific | Different operations cost different credits | Chat: 1 credit, analysis: 5 credits | Diverse workload types |
| Dynamic | Credit cost varies by resource usage | Actual token cost × markup | Cost-plus pricing |
At Athenic, we use fixed conversion: 1 credit = $0.10. This simplifies pricing and keeps calculations fast.
Operations have backend costs (OpenAI API, compute, storage). Map these to credit costs with a markup.
interface CreditCost {
operation: string;
base_cost_usd: number;
credit_cost: number;
markup_percent: number;
}
const creditCosts: CreditCost[] = [
{
operation: 'chat_message',
base_cost_usd: 0.003, // $0.003 per message avg
credit_cost: 1, // 1 credit per message
markup_percent: 233, // ($0.10 - $0.003) / $0.003
},
{
operation: 'research_task',
base_cost_usd: 0.025,
credit_cost: 3,
markup_percent: 120,
},
{
operation: 'code_generation',
base_cost_usd: 0.018,
credit_cost: 2,
markup_percent: 111,
},
];
Markup rationale: Cover variability (some operations cost more/less than average), infrastructure overhead, and profit margin.
"The developer experience improvements we've seen from AI tools are the most significant since IDEs and version control. This is a permanent shift in how software gets built." - Emily Freeman, VP of Developer Relations at AWS
Track credits at two levels: allocations (credit purchases/grants) and transactions (credit usage).
-- Credit allocations table
CREATE TABLE credit_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id TEXT NOT NULL,
amount INT NOT NULL, -- Credits allocated
source TEXT NOT NULL, -- 'purchase', 'grant', 'refund', 'trial'
reference_id TEXT, -- Payment ID, support ticket, etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- Optional expiry
);
-- Credit transactions table (immutable audit log)
CREATE TABLE credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id TEXT NOT NULL,
operation_type TEXT NOT NULL,
credits_used INT NOT NULL,
actual_cost_usd NUMERIC(10, 4), -- Backend cost
metadata JSONB, -- Job ID, agent name, etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
idempotency_key TEXT UNIQUE -- Prevent double-charging
);
-- Indexes
CREATE INDEX idx_allocations_org ON credit_allocations(org_id, created_at);
CREATE INDEX idx_transactions_org ON credit_transactions(org_id, created_at);
CREATE INDEX idx_transactions_idempotency ON credit_transactions(idempotency_key);
Balance = total allocated - total used.
async function getCreditBalance(orgId: string): Promise<number> {
const result = await db.query(`
WITH allocated AS (
SELECT COALESCE(SUM(amount), 0) AS total
FROM credit_allocations
WHERE
org_id = $1
AND (expires_at IS NULL OR expires_at > NOW())
),
used AS (
SELECT COALESCE(SUM(credits_used), 0) AS total
FROM credit_transactions
WHERE org_id = $1
)
SELECT
allocated.total - used.total AS balance
FROM allocated, used
`, [orgId]);
return result.rows[0].balance;
}
Performance note: Cache balances in Redis with TTL=60s to avoid recalculating on every request.
async function getCachedBalance(orgId: string): Promise<number> {
const cached = await redis.get(`credit_balance:${orgId}`);
if (cached) return parseInt(cached, 10);
const balance = await getCreditBalance(orgId);
await redis.setex(`credit_balance:${orgId}`, 60, balance);
return balance;
}
Use database transactions to ensure atomicity: check balance + deduct credits + record transaction happens together or not at all.
async function deductCredits(
orgId: string,
operation: string,
creditCost: number,
metadata: Record<string, any>,
idempotencyKey: string,
): Promise<void> {
await db.transaction(async (tx) => {
// Check for duplicate using idempotency key
const existing = await tx.creditTransactions.findOne({ idempotency_key: idempotencyKey });
if (existing) {
console.log(`Skipping duplicate transaction: ${idempotencyKey}`);
return; // Already processed
}
// Get current balance
const balance = await getCreditBalance(orgId);
if (balance < creditCost) {
throw new InsufficientCreditsError(`Need ${creditCost}, have ${balance}`);
}
// Record transaction
await tx.creditTransactions.insert({
org_id: orgId,
operation_type: operation,
credits_used: creditCost,
metadata,
idempotency_key: idempotencyKey,
});
// Invalidate cache
await redis.del(`credit_balance:${orgId}`);
});
}
Idempotency key: Prevents double-charging if request is retried due to network failure. Use ${jobId}-${operationType} format.
Enforce quotas before executing operations to prevent overages.
async function executeAgentWithQuota(orgId: string, task: string, estimatedCredits: number) {
const balance = await getCachedBalance(orgId);
if (balance < estimatedCredits) {
throw new InsufficientCreditsError(
`Task requires ~${estimatedCredits} credits, balance: ${balance}`,
);
}
const idempotencyKey = `${orgId}-${Date.now()}-${uuidv4()}`;
// Deduct credits upfront (reserve)
await deductCredits(orgId, 'agent_task_reserved', estimatedCredits, { task }, idempotencyKey);
try {
const result = await runAgent(task);
// Calculate actual cost
const actualCredits = calculateActualCredits(result);
if (actualCredits < estimatedCredits) {
// Refund difference
await refundCredits(orgId, estimatedCredits - actualCredits, idempotencyKey);
} else if (actualCredits > estimatedCredits) {
// Deduct additional (rare if estimates are good)
await deductCredits(orgId, 'agent_task_overage', actualCredits - estimatedCredits, { task }, `${idempotencyKey}-overage`);
}
return result;
} catch (error) {
// Refund on failure
await refundCredits(orgId, estimatedCredits, idempotencyKey);
throw error;
}
}
Estimation strategy: Use historical data to predict credit costs. For new users, use conservative estimates (75th percentile cost).
Limit organizations to maximum credits/hour to prevent runaway costs.
CREATE TABLE credit_rate_limits (
org_id TEXT PRIMARY KEY,
credits_per_hour INT NOT NULL DEFAULT 100,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
async function checkRateLimit(orgId: string): Promise<boolean> {
const limit = await db.creditRateLimits.findOne({ org_id: orgId });
const creditsPerHour = limit?.credits_per_hour || 100;
const used = await db.query(`
SELECT COALESCE(SUM(credits_used), 0) AS total
FROM credit_transactions
WHERE
org_id = $1
AND created_at > NOW() - INTERVAL '1 hour'
`, [orgId]);
return used.rows[0].total < creditsPerHour;
}
Alert users when they approach credit limits.
async function notifyIfLowBalance(orgId: string) {
const balance = await getCachedBalance(orgId);
const allocation = await db.creditAllocations.findOne({ org_id: orgId, order_by: 'created_at DESC' });
if (allocation && balance < allocation.amount * 0.1) {
// Less than 10% remaining
await sendEmail(orgId, 'low_credit_balance', {
balance,
purchase_url: 'https://getathenic.com/billing/credits',
});
}
}
Connect credit purchases to payment providers (Stripe, Paddle).
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function purchaseCredits(orgId: string, packageId: string) {
const packages = {
starter: { credits: 100, price_usd: 10 },
growth: { credits: 500, price_usd: 45 },
enterprise: { credits: 2000, price_usd: 160 },
};
const pkg = packages[packageId];
// Create Stripe checkout session
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
unit_amount: pkg.price_usd * 100, // Cents
product_data: { name: `${pkg.credits} AI Credits` },
},
quantity: 1,
}],
metadata: {
org_id: orgId,
credits: pkg.credits.toString(),
},
success_url: 'https://getathenic.com/billing/success',
cancel_url: 'https://getathenic.com/billing',
});
return session.url;
}
// Stripe webhook handler
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], WEBHOOK_SECRET);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Allocate credits
await db.creditAllocations.insert({
org_id: session.metadata.org_id,
amount: parseInt(session.metadata.credits, 10),
source: 'purchase',
reference_id: session.id,
});
await sendEmail(session.metadata.org_id, 'credits_purchased', {
credits: session.metadata.credits,
});
}
res.send({ received: true });
});
Grant promotional credits with expiry dates.
async function grantTrialCredits(orgId: string) {
await db.creditAllocations.insert({
org_id: orgId,
amount: 50, // 50 trial credits
source: 'trial',
expires_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days
});
}
async function grantPromotionCredits(orgId: string, promoCode: string) {
const promo = await db.promotions.findOne({ code: promoCode, active: true });
if (promo) {
await db.creditAllocations.insert({
org_id: orgId,
amount: promo.credit_amount,
source: 'promotion',
reference_id: promo.code,
expires_at: promo.expires_at,
});
}
}
Provide users with transparent usage breakdowns.
-- Credits by operation type (last 30 days)
SELECT
operation_type,
SUM(credits_used) AS total_credits,
COUNT(*) AS operation_count,
AVG(credits_used) AS avg_credits_per_op
FROM credit_transactions
WHERE
org_id = $1
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY operation_type
ORDER BY total_credits DESC;
-- Daily credit burn rate
SELECT
DATE_TRUNC('day', created_at) AS day,
SUM(credits_used) AS credits_used
FROM credit_transactions
WHERE
org_id = $1
AND created_at > NOW() - INTERVAL '30 days'
GROUP BY day
ORDER BY day;
async function exportTransactions(orgId: string, startDate: Date, endDate: Date): Promise<string> {
const transactions = await db.creditTransactions.findAll({
org_id: orgId,
created_at: { $gte: startDate, $lte: endDate },
order_by: 'created_at',
});
const csv = [
'Date,Operation,Credits,Cost USD,Reference',
...transactions.map(t =>
`${t.created_at.toISOString()},${t.operation_type},${t.credits_used},${t.actual_cost_usd || 'N/A'},${t.metadata?.job_id || ''}`
),
].join('\n');
return csv;
}
We process 150,000+ credit transactions monthly across 1,200+ organizations.
Metrics:
Pricing structure:
Operation costs:
| Operation | Credits | Avg backend cost | Margin |
|---|---|---|---|
| Chat message | 1 | $0.003 | 97% |
| Research task | 3 | $0.025 | 92% |
| Code generation | 2 | $0.018 | 91% |
| Partnership discovery (50 leads) | 15 | $0.42 | 72% |
Margin covers: Compute overhead (20%), support (15%), payment processing (3%), infrastructure (10%), profit (remaining).
Call-to-action (Activation stage) Download our credit system starter schema with tables, indexes, and example transactions.
Create negative credit transactions with source: 'refund'. These increase balance when calculating totals. Always link to original transaction via reference_id.
Depends on business model. Trial credits should expire (14-30 days). Purchased credits typically don't expire, or have long expiry (1-2 years).
Rate limit credit usage per hour/day, require verified payment methods for purchases, monitor for unusual patterns (spending entire balance in seconds), implement approval workflows for high-cost operations.
Yes, but carefully. Allow gifting/transferring credits only to verified organizations. Log transfers as transactions with both sender and recipient org_ids.
Maintain immutable transaction logs with full metadata (job IDs, timestamps, operation details). This audit trail resolves 95%+ of disputes when users can see exactly what consumed credits.
Credit systems translate variable AI costs into predictable user-facing units. Implement atomic transactions for balance checks and deductions, enforce quotas before operations, maintain immutable audit logs, and integrate with payment providers for purchases.
Next steps:
credit_allocations and credit_transactions tables.Internal links:
External references:
Crosslinks: