Bonaventure OgetoBy Bonaventure Ogeto|

Usage-Based Billing with Paystack

Paystack does not support usage-based billing natively. Build it with charge_authorization: track usage in your database throughout the billing period, calculate the amount at the end of the period, and charge the customer using their stored authorization code. You manage the usage tracking, the billing calculation, the charge scheduling, and the failure handling. Paystack just processes each charge.

What Is Usage-Based Billing

Usage-based billing charges customers proportionally to their consumption. Instead of "5,000 NGN per month regardless of usage," it is "100 NGN per 1,000 API calls" or "50 NGN per GB of storage used."

Examples of products that use this model:

  • API platforms (charge per API call)
  • Messaging services (charge per message sent)
  • Cloud storage (charge per GB stored)
  • Transaction processors (charge per transaction processed)
  • Communication platforms (charge per SMS, per minute of call time)

The Paystack Subscriptions API cannot handle this because it charges a fixed amount. You need charge_authorization, where you control the amount on each charge.

This article is part of the subscriptions and recurring billing guide.

The Architecture

Usage-based billing has four components:

  1. Usage tracking: Record every billable event in real time.
  2. Billing calculator: At the end of the period, aggregate usage and calculate the charge.
  3. Charge execution: Call charge_authorization with the calculated amount.
  4. Invoice generation: Create a detailed invoice showing what the customer used and what they owe.
-- Usage events table
CREATE TABLE usage_events (
  id BIGSERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  event_type VARCHAR(50) NOT NULL,
  quantity NUMERIC NOT NULL DEFAULT 1,
  metadata JSONB,
  recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_usage_events_user_period ON usage_events(user_id, recorded_at);
CREATE INDEX idx_usage_events_type ON usage_events(event_type, recorded_at);

-- Pricing rules
CREATE TABLE usage_pricing (
  id SERIAL PRIMARY KEY,
  plan_id INTEGER REFERENCES billing_plans(id),
  event_type VARCHAR(50) NOT NULL,
  included_units NUMERIC DEFAULT 0,
  price_per_unit INTEGER NOT NULL,
  currency VARCHAR(3) DEFAULT 'NGN',
  tier_start NUMERIC,
  tier_end NUMERIC
);

Tracking Usage Events

// Call this every time a billable event happens
async function trackUsage(userId, eventType, quantity) {
  await db.query(
    'INSERT INTO usage_events (user_id, event_type, quantity, recorded_at) VALUES ($1, $2, $3, NOW())',
    [userId, eventType, quantity || 1]
  );
}

// Examples of where you call trackUsage:
// In your API middleware:
app.use('/api/v1/', function(req, res, next) {
  if (req.user) {
    trackUsage(req.user.id, 'api_call', 1);
  }
  next();
});

// In your messaging service:
async function sendMessage(userId, recipient, content) {
  await messageQueue.send({ recipient: recipient, content: content });
  await trackUsage(userId, 'message_sent', 1);
}

// For storage, track the delta:
async function uploadFile(userId, fileSizeBytes) {
  await saveFile(userId, fileSizeBytes);
  var sizeGB = fileSizeBytes / (1024 * 1024 * 1024);
  await trackUsage(userId, 'storage_gb', sizeGB);
}

For high-volume products (thousands of API calls per second), batch usage events. Instead of inserting one row per API call, aggregate in memory and flush to the database every few seconds or every 100 events. Use a Redis counter for real-time tracking and sync to the database periodically.

Calculating the Charge Amount

async function calculateUsageBill(userId, periodStart, periodEnd) {
  var plan = await getUserPlan(userId);
  var pricing = await db.query(
    'SELECT event_type, included_units, price_per_unit FROM usage_pricing WHERE plan_id = $1',
    [plan.id]
  );

  var lineItems = [];
  var total = 0;

  for (var i = 0; i < pricing.rows.length; i++) {
    var rule = pricing.rows[i];

    // Get usage for this event type in the billing period
    var usage = await db.query(
      'SELECT COALESCE(SUM(quantity), 0) as total FROM usage_events WHERE user_id = $1 AND event_type = $2 AND recorded_at >= $3 AND recorded_at < $4',
      [userId, rule.event_type, periodStart, periodEnd]
    );

    var usedUnits = parseFloat(usage.rows[0].total);
    var billableUnits = Math.max(0, usedUnits - rule.included_units);
    var lineTotal = Math.round(billableUnits * rule.price_per_unit);

    lineItems.push({
      eventType: rule.event_type,
      totalUsed: usedUnits,
      includedUnits: rule.included_units,
      billableUnits: billableUnits,
      pricePerUnit: rule.price_per_unit,
      lineTotal: lineTotal,
    });

    total = total + lineTotal;
  }

  return {
    userId: userId,
    periodStart: periodStart,
    periodEnd: periodEnd,
    lineItems: lineItems,
    totalAmount: total,
  };
}

Handle edge cases:

  • Zero usage: Total is 0. Do not charge. Create a zero-amount invoice for records.
  • Minimum charge: Some plans have a minimum monthly fee. If usage-based charges are below the minimum, charge the minimum instead.
  • Usage within included quota: If the plan includes 10,000 API calls free, subtract those before calculating the billable amount.

Running the Monthly Billing Cycle

async function runUsageBilling() {
  var now = new Date();
  var periodEnd = new Date(now.getFullYear(), now.getMonth(), 1); // First of current month
  var periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); // First of last month

  var usageAccounts = await db.query(
    'SELECT ba.user_id, ba.payment_method_id, u.email FROM billing_accounts ba JOIN users u ON ba.user_id = u.id WHERE ba.status = $1 AND ba.billing_type = $2',
    ['active', 'usage']
  );

  for (var i = 0; i < usageAccounts.rows.length; i++) {
    var account = usageAccounts.rows[i];

    try {
      var bill = await calculateUsageBill(account.user_id, periodStart, periodEnd);

      // Create invoice
      var invoice = await createUsageInvoice(account.user_id, bill);

      if (bill.totalAmount === 0) {
        console.log('Zero usage for user ' + account.user_id + '. No charge.');
        await db.query('UPDATE invoices SET status = $1, paid_at = NOW() WHERE id = $2', ['paid', invoice.id]);
        continue;
      }

      // Charge
      var authCode = await getAuthorizationCode(account.payment_method_id);
      var reference = 'USAGE_' + account.user_id + '_' + periodStart.toISOString().slice(0, 7);

      var result = await chargeAuthorization(account.email, bill.totalAmount, authCode, reference);

      if (result.status === 'success') {
        await db.query('UPDATE invoices SET status = $1, paid_at = NOW(), paystack_reference = $2 WHERE id = $3',
          ['paid', reference, invoice.id]);
      } else {
        await db.query('UPDATE invoices SET status = $1 WHERE id = $2', ['failed', invoice.id]);
        await startDunning(account.user_id, reference, result.gateway_response);
      }
    } catch (error) {
      console.log('Error billing user ' + account.user_id + ': ' + error.message);
    }
  }
}

Schedule this to run on the 1st of each month. Process in the early morning when API call volumes are lower. For larger customer bases, process in batches to avoid overwhelming Paystack with simultaneous charge requests.

Generating Detailed Usage Invoices

Usage invoices should itemize what the customer consumed. This transparency reduces billing disputes and builds trust.

async function createUsageInvoice(userId, bill) {
  var invoiceNumber = 'INV-' + userId + '-' + bill.periodStart.toISOString().slice(0, 7);

  var invoiceId = await db.query(
    'INSERT INTO invoices (billing_account_id, invoice_number, amount, currency, status, period_start, period_end, created_at) VALUES ((SELECT id FROM billing_accounts WHERE user_id = $1), $2, $3, $4, $5, $6, $7, NOW()) RETURNING id',
    [userId, invoiceNumber, bill.totalAmount, 'NGN', 'pending', bill.periodStart, bill.periodEnd]
  );

  var id = invoiceId.rows[0].id;

  for (var i = 0; i < bill.lineItems.length; i++) {
    var item = bill.lineItems[i];
    var description = item.eventType + ': ' + item.totalUsed + ' used';
    if (item.includedUnits > 0) {
      description = description + ' (' + item.includedUnits + ' included, ' + item.billableUnits + ' billed)';
    }

    await db.query(
      'INSERT INTO invoice_line_items (invoice_id, description, quantity, unit_amount, total_amount) VALUES ($1, $2, $3, $4, $5)',
      [id, description, item.billableUnits, item.pricePerUnit, item.lineTotal]
    );
  }

  return { id: id, number: invoiceNumber };
}

An example invoice might read:

  • API Calls: 45,230 used (10,000 included, 35,230 billed) at 10 kobo each: 3,523 NGN
  • Messages Sent: 1,200 used (500 included, 700 billed) at 500 kobo each: 3,500 NGN
  • Storage: 15.2 GB (5 GB included, 10.2 GB billed) at 10,000 kobo per GB: 10,200 NGN
  • Total: 17,223 NGN

Real-Time Usage Dashboard for Customers

Customers need to see their current usage and estimated bill before the billing date. Nobody wants a surprise charge.

// Backend API: GET /api/billing/usage
router.get('/api/billing/usage', async function(req, res) {
  var userId = req.user.id;
  var billing = await getCurrentBilling(userId);

  var periodStart = billing.current_period_start || new Date(new Date().getFullYear(), new Date().getMonth(), 1);
  var periodEnd = new Date(periodStart);
  periodEnd.setMonth(periodEnd.getMonth() + 1);

  var currentBill = await calculateUsageBill(userId, periodStart, new Date());

  var daysInPeriod = Math.ceil((periodEnd - periodStart) / (24 * 60 * 60 * 1000));
  var daysElapsed = Math.ceil((new Date() - periodStart) / (24 * 60 * 60 * 1000));
  var projectedMultiplier = daysInPeriod / Math.max(daysElapsed, 1);

  res.json({
    currentUsage: currentBill.lineItems,
    currentTotal: currentBill.totalAmount,
    projectedTotal: Math.round(currentBill.totalAmount * projectedMultiplier),
    periodStart: periodStart,
    periodEnd: periodEnd,
    daysRemaining: daysInPeriod - daysElapsed,
  });
});

Show the projected total prominently. If a customer is on pace to spend significantly more than last month, that is useful information they need before the bill arrives.

For metered billing with a base fee plus overages, see metered billing and overages.

Key Takeaways

  • Paystack has no native usage-based billing. Build it on charge_authorization with your own usage tracking and billing calculation.
  • Track usage events in real time. Store each event (API call, message, storage change) with a timestamp and the associated user.
  • Calculate the charge amount at the end of the billing period based on accumulated usage. Apply any included quotas, tiered pricing, or minimum charges.
  • Use charge_authorization to bill the calculated amount. The customer does not re-enter card details.
  • Handle zero-usage periods gracefully. If the customer used nothing, skip the charge but create an invoice record.
  • Usage-based billing has higher engineering complexity than fixed subscriptions. Budget for usage tracking infrastructure, billing calculators, and detailed invoices.

Frequently Asked Questions

Can I use the Paystack Subscriptions API for usage-based billing?
No. The Subscriptions API charges a fixed amount defined by the plan. For usage-based billing where the amount varies each cycle, use charge_authorization directly. You calculate the amount based on usage and charge it yourself.
What if the customer has zero usage in a billing period?
Skip the charge but create a zero-amount invoice for record-keeping. Some products have a minimum monthly charge even with zero usage. Implement this in your billing calculator by applying a floor amount.
How do I handle usage tracking at scale?
For high-volume products, use in-memory counters (Redis) for real-time tracking and flush aggregates to the database periodically. This avoids a database write for every single API call or event. Aggregate usage by minute or by batch size (every 100 events).
Should I bill at the beginning or end of the usage period?
Bill at the end (post-paid). The customer uses the service during the month, and you charge them at the start of the next month for what they consumed. Pre-paid usage billing is possible but requires usage estimation and credit/refund logic.
How do I prevent bill shock for customers?
Show real-time usage and projected costs in the customer dashboard. Send alerts when usage exceeds certain thresholds (50%, 80%, 100% of typical usage). Let customers set usage caps or spending limits.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon