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.
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:
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.
Usage-based billing has four components:
-- 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
);
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// 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.
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:
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.
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:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course