Academy28 Jul 202512 min read

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.

MB
Max Beech
Head of Content

TL;DR

  • Credit systems translate variable AI costs (tokens, API calls, compute) into predictable user-facing units.
  • Track credits at operation granularity (per agent run, per tool call) for accurate attribution.
  • Implement quota enforcement before execution to prevent surprise overages.
  • Use database transactions and idempotency keys to ensure exactly-once credit deduction.

Jump to Credit system design · Jump to Tracking implementation · Jump to Quota enforcement · Jump to Billing integration

Building AI Credit Systems: Usage Tracking and Fair Billing

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.

Credit system design

Why credits instead of direct billing?

Direct billing problems:

  • Users confused by variable monthly bills ($23.47 one month, $197.83 the next)
  • Hard to predict costs before operations
  • Difficult to enforce spending limits in real-time

Credit system benefits:

  • Predictable purchase: "Buy 1,000 credits for $50"
  • Real-time visibility: "This operation costs 5 credits"
  • Hard limits: "You have 42 credits remaining"

Credit pricing models

ModelDescriptionExampleBest for
Fixed conversion1 credit = fixed dollar value1 credit = $0.05Simple pricing, broad operations
TieredCredit value decreases with volume1-1K credits: $0.05 each, 1K-10K: $0.04 eachEnterprise customers
Operation-specificDifferent operations cost different creditsChat: 1 credit, analysis: 5 creditsDiverse workload types
DynamicCredit cost varies by resource usageActual token cost × markupCost-plus pricing

At Athenic, we use fixed conversion: 1 credit = $0.10. This simplifies pricing and keeps calculations fast.

Credit-to-cost mapping

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.

Tracking implementation

Track credits at two levels: allocations (credit purchases/grants) and transactions (credit usage).

Schema design

-- 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);

Credit balance calculation

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;
}

Deducting credits

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.

Quota enforcement

Enforce quotas before executing operations to prevent overages.

Pre-flight checks

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).

Rate limiting by credits

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;
}

Overage notifications

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://athenic.com/billing/credits',
    });
  }
}

Billing integration

Connect credit purchases to payment providers (Stripe, Paddle).

Credit purchase flow

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://athenic.com/billing/success',
    cancel_url: 'https://athenic.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 });
});

Trial credits and promotions

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,
    });
  }
}

Usage analytics and reporting

Provide users with transparent usage breakdowns.

Usage dashboard queries

-- 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;

CSV export for billing records

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;
}

Real-world case study: Athenic credit system

We process 150,000+ credit transactions monthly across 1,200+ organizations.

Metrics:

  • Average transaction processing time: 12ms
  • Billing discrepancy rate: 0.08% (1 in 1,250)
  • Cache hit rate on balance checks: 87%
  • Credit purchase conversion: 34% of free users upgrade

Pricing structure:

  • Starter: 100 credits / $10 ($0.10 per credit)
  • Growth: 500 credits / $45 ($0.09 per credit)
  • Enterprise: 2000 credits / $160 ($0.08 per credit)

Operation costs:

OperationCreditsAvg backend costMargin
Chat message1$0.00397%
Research task3$0.02592%
Code generation2$0.01891%
Partnership discovery (50 leads)15$0.4272%

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.

FAQs

How do I handle refunds?

Create negative credit transactions with source: 'refund'. These increase balance when calculating totals. Always link to original transaction via reference_id.

Should credits expire?

Depends on business model. Trial credits should expire (14-30 days). Purchased credits typically don't expire, or have long expiry (1-2 years).

How do I prevent credit fraud?

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.

Can users buy credits for other organizations?

Yes, but carefully. Allow gifting/transferring credits only to verified organizations. Log transfers as transactions with both sender and recipient org_ids.

How do I handle credit disputes?

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.

Summary and next steps

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:

  1. Design credit-to-operation mapping with appropriate markup.
  2. Implement credit_allocations and credit_transactions tables.
  3. Build pre-flight quota checks for all billable operations.
  4. Integrate Stripe/Paddle for credit purchases.
  5. Create usage dashboard showing credit breakdown by operation.

Internal links:

External references:

Crosslinks: