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
Person coding on laptop with HTML code for web development

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.

"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

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://getathenic.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://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 });
});

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: