Building your own billing engine on Paystack means managing authorization codes, charge scheduling, and lifecycle state yourself. You need a billing database (plans, subscriptions, invoices, payment methods), a charge scheduler (cron or task queue), a state machine (active, past_due, suspended, cancelled), retry logic for failures, and a reconciliation process to keep your records in sync with Paystack. The charge_authorization endpoint handles the actual payment. Everything else is your code.
The Subscriptions API is convenient but rigid. Build your own engine when:
Building a billing engine is real engineering work. Expect it to take weeks, not days. But the flexibility you gain is worth it for complex billing scenarios.
This article is part of the subscriptions and recurring billing guide. For a comparison of both approaches, see subscriptions API vs manual charges.
Your billing database is the foundation. It mirrors key Paystack objects and adds your own business state.
-- Your internal plans (may or may not map to Paystack plans)
CREATE TABLE billing_plans (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
base_amount INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'NGN',
billing_interval VARCHAR(20) NOT NULL,
billing_interval_days INTEGER,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Customer billing accounts
CREATE TABLE billing_accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER UNIQUE REFERENCES users(id),
plan_id INTEGER REFERENCES billing_plans(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
next_charge_date TIMESTAMPTZ,
payment_method_id INTEGER REFERENCES payment_methods(id),
retry_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Invoices (one per billing cycle per customer)
CREATE TABLE invoices (
id SERIAL PRIMARY KEY,
billing_account_id INTEGER REFERENCES billing_accounts(id),
invoice_number VARCHAR(50) UNIQUE NOT NULL,
amount INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
due_date TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
paystack_reference VARCHAR(100),
paystack_transaction_id INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Line items on invoices (for detailed billing)
CREATE TABLE invoice_line_items (
id SERIAL PRIMARY KEY,
invoice_id INTEGER REFERENCES invoices(id),
description VARCHAR(200) NOT NULL,
quantity INTEGER DEFAULT 1,
unit_amount INTEGER NOT NULL,
total_amount INTEGER NOT NULL
);
CREATE INDEX idx_billing_accounts_next_charge ON billing_accounts(next_charge_date);
CREATE INDEX idx_invoices_status ON invoices(status);
The billing_accounts.status field is your state machine. The next_charge_date is what your scheduler queries. The invoices table gives you a clean record of every billing attempt.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The scheduler is the heartbeat of your billing engine. It runs periodically (every hour or every few minutes), finds accounts that are due for charging, and processes them.
var axios = require('axios');
var crypto = require('crypto');
async function runBillingCycle() {
console.log('Billing cycle started at ' + new Date().toISOString());
// Find accounts due for charging
var due = await db.query(
'SELECT ba.id, ba.user_id, ba.plan_id, ba.payment_method_id, u.email, bp.base_amount, bp.currency FROM billing_accounts ba JOIN users u ON ba.user_id = u.id JOIN billing_plans bp ON ba.plan_id = bp.id WHERE ba.next_charge_date <= NOW() AND ba.status = $1',
['active']
);
console.log('Found ' + due.rows.length + ' accounts to charge');
for (var i = 0; i < due.rows.length; i++) {
var account = due.rows[i];
try {
await processAccountCharge(account);
} catch (error) {
console.log('Error processing account ' + account.id + ': ' + error.message);
}
}
}
async function processAccountCharge(account) {
// 1. Calculate the charge amount (may include usage, discounts, etc.)
var amount = await calculateChargeAmount(account);
// 2. Create an invoice
var invoiceNumber = 'INV-' + account.user_id + '-' + Date.now();
var periodStart = new Date();
var periodEnd = calculatePeriodEnd(periodStart, account.plan_id);
await db.query(
'INSERT INTO invoices (billing_account_id, invoice_number, amount, currency, status, period_start, period_end, due_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
[account.id, invoiceNumber, amount, account.currency, 'pending', periodStart, periodEnd, periodStart]
);
// 3. Get the authorization code
var authCode = await getAuthorizationCode(account.payment_method_id);
// 4. Charge the card
var reference = 'BILL_' + account.user_id + '_' + crypto.randomBytes(6).toString('hex');
var result = await chargeAuthorization(account.email, amount, authCode, reference, account.currency);
// 5. Update based on result
if (result.status === 'success') {
await handleSuccessfulCharge(account, invoiceNumber, reference, result, periodEnd);
} else {
await handleFailedCharge(account, invoiceNumber, reference, result);
}
}
async function handleSuccessfulCharge(account, invoiceNumber, reference, result, periodEnd) {
// Update invoice
await db.query(
'UPDATE invoices SET status = $1, paid_at = NOW(), paystack_reference = $2, paystack_transaction_id = $3 WHERE invoice_number = $4',
['paid', reference, result.id, invoiceNumber]
);
// Advance the billing cycle
var nextChargeDate = periodEnd;
await db.query(
'UPDATE billing_accounts SET current_period_start = $1, current_period_end = $2, next_charge_date = $3, retry_count = 0, updated_at = NOW() WHERE id = $4',
[new Date(), periodEnd, nextChargeDate, account.id]
);
console.log('Charged account ' + account.id + ' successfully. Next charge: ' + nextChargeDate);
}
async function handleFailedCharge(account, invoiceNumber, reference, result) {
await db.query(
'UPDATE invoices SET status = $1, paystack_reference = $2 WHERE invoice_number = $3',
['failed', reference, invoiceNumber]
);
await db.query(
'UPDATE billing_accounts SET status = $1, retry_count = retry_count + 1, updated_at = NOW() WHERE id = $2',
['past_due', account.id]
);
// Schedule retry
await scheduleRetry(account, result.gateway_response);
}
Run this scheduler using a cron job (0 * * * * for hourly) or a task queue. The critical requirement is reliability. If the scheduler fails to run, charges are missed. Use monitoring to alert on scheduler failures.
Every billing account moves through defined states. Clear state transitions prevent bugs like charging a cancelled account or double-charging an active one.
var STATE_TRANSITIONS = {
active: ['past_due', 'cancelled'],
past_due: ['active', 'suspended', 'cancelled'],
suspended: ['active', 'cancelled'],
cancelled: ['active'],
};
async function transitionState(accountId, newState, reason) {
var account = await db.query(
'SELECT status FROM billing_accounts WHERE id = $1',
[accountId]
);
var currentState = account.rows[0].status;
var allowed = STATE_TRANSITIONS[currentState] || [];
if (allowed.indexOf(newState) === -1) {
throw new Error(
'Cannot transition from ' + currentState + ' to ' + newState +
' for account ' + accountId
);
}
await db.query(
'UPDATE billing_accounts SET status = $1, updated_at = NOW() WHERE id = $2',
[newState, accountId]
);
// Audit log
await db.query(
'INSERT INTO billing_state_log (billing_account_id, from_state, to_state, reason, created_at) VALUES ($1, $2, $3, $4, NOW())',
[accountId, currentState, newState, reason]
);
return true;
}
State meanings and access rules:
The state machine is your source of truth for access control. Every API endpoint and page render checks the billing state before serving premium content or features.
This is where your billing engine differs from the Subscriptions API. You calculate the charge amount at billing time, not at plan creation time.
async function calculateChargeAmount(account) {
var plan = await getPlan(account.plan_id);
var baseAmount = plan.base_amount;
// Add usage-based charges
var usage = await getUsageForPeriod(account.user_id, account.current_period_start, new Date());
var usageCharge = calculateUsageCharge(usage, plan);
// Apply credits
var credits = await getAvailableCredits(account.user_id);
var creditAmount = Math.min(credits, baseAmount + usageCharge);
// Apply active coupon
var coupon = await getActiveCoupon(account.user_id);
var discount = 0;
if (coupon) {
if (coupon.type === 'percentage') {
discount = Math.floor((baseAmount + usageCharge) * coupon.value / 100);
} else {
discount = coupon.value;
}
}
var totalAmount = baseAmount + usageCharge - creditAmount - discount;
// Never charge less than zero
if (totalAmount < 0) totalAmount = 0;
// If total is zero, skip the Paystack charge
// but still create the invoice for record keeping
return totalAmount;
}
function calculateUsageCharge(usage, plan) {
if (!plan.usage_pricing) return 0;
var included = plan.usage_pricing.included_units || 0;
var overage = Math.max(0, usage.total_units - included);
return overage * plan.usage_pricing.per_unit_cost;
}
This flexibility is the whole point of building your own engine. You can implement tiered pricing, volume discounts, per-seat billing, usage buckets, and any other pricing model your business needs. The Subscriptions API cannot do any of this.
For a focused guide on usage-based billing, see usage-based billing with Paystack. For metered billing with base fees plus overages, see metered billing and overages.
Create invoices before charging, not after. This gives you a record of billing intent even if the charge fails.
async function createInvoice(billingAccountId, lineItems, periodStart, periodEnd) {
var invoiceNumber = generateInvoiceNumber();
var totalAmount = 0;
for (var i = 0; i < lineItems.length; i++) {
totalAmount = totalAmount + lineItems[i].total_amount;
}
// Create the invoice
var invoice = await db.query(
'INSERT INTO invoices (billing_account_id, invoice_number, amount, currency, status, period_start, period_end, due_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id',
[billingAccountId, invoiceNumber, totalAmount, 'NGN', 'pending', periodStart, periodEnd, periodStart]
);
var invoiceId = invoice.rows[0].id;
// Add line items
for (var j = 0; j < lineItems.length; j++) {
var item = lineItems[j];
await db.query(
'INSERT INTO invoice_line_items (invoice_id, description, quantity, unit_amount, total_amount) VALUES ($1, $2, $3, $4, $5)',
[invoiceId, item.description, item.quantity, item.unit_amount, item.total_amount]
);
}
return { id: invoiceId, number: invoiceNumber, amount: totalAmount };
}
function generateInvoiceNumber() {
var date = new Date();
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, '0');
var random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return 'INV-' + year + month + '-' + random;
}
Invoice statuses: pending (created, not yet charged), paid (charge succeeded), failed (charge failed), voided (cancelled before charging), refunded (charge was reversed).
Line items give your customers a clear breakdown of what they are paying for. "Pro Plan - July 2026: 5,000 NGN. API overage (2,340 calls above quota): 1,170 NGN. Coupon SAVE20: -1,234 NGN. Total: 4,936 NGN." This transparency reduces billing disputes.
Your billing database and Paystack's records can drift apart. A charge might succeed on Paystack but your database update might fail due to a crash. Or a charge might fail on Paystack but your database records it as pending because the response never arrived.
Run a daily reconciliation job:
async function reconcile() {
console.log('Starting billing reconciliation');
// Find invoices that are stuck in pending status
var staleInvoices = await db.query(
'SELECT id, paystack_reference FROM invoices WHERE status = $1 AND created_at < NOW() - INTERVAL '2 hours'',
['pending']
);
for (var i = 0; i < staleInvoices.rows.length; i++) {
var invoice = staleInvoices.rows[i];
if (!invoice.paystack_reference) {
// Never sent to Paystack. Mark as failed or retry.
console.log('Invoice ' + invoice.id + ' never charged. Marking failed.');
await db.query('UPDATE invoices SET status = $1 WHERE id = $2', ['failed', invoice.id]);
continue;
}
// Verify with Paystack
try {
var response = await axios.get(
'https://api.paystack.co/transaction/verify/' + invoice.paystack_reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var txn = response.data.data;
if (txn.status === 'success') {
console.log('Invoice ' + invoice.id + ' was actually paid. Updating.');
await db.query(
'UPDATE invoices SET status = $1, paid_at = $2, paystack_transaction_id = $3 WHERE id = $4',
['paid', txn.paid_at, txn.id, invoice.id]
);
} else {
await db.query('UPDATE invoices SET status = $1 WHERE id = $2', ['failed', invoice.id]);
}
} catch (error) {
console.log('Could not verify invoice ' + invoice.id + ': ' + error.message);
}
}
console.log('Reconciliation complete. Processed ' + staleInvoices.rows.length + ' stale invoices.');
}
Schedule reconciliation to run daily during off-peak hours. This catches edge cases that your main billing flow misses. It is your safety net.
Do not build everything on day one. Start with the minimum viable billing engine and add features as your business needs them.
Phase 1: Basic recurring charges
Store authorization codes. Run a scheduler that charges fixed amounts monthly. Handle success and failure. This alone handles 80% of billing needs.
Phase 2: Add the state machine and dunning
Implement billing states (active, past_due, suspended). Add retry logic and customer notifications. This recovers revenue from failed charges.
Phase 3: Add invoice generation
Create proper invoices with line items. This matters for accounting, tax compliance, and customer transparency.
Phase 4: Add variable pricing
Implement usage tracking and variable charge calculations. Add coupon and credit support. This unlocks complex pricing models.
Phase 5: Add reconciliation and reporting
Build the reconciliation job and billing dashboards. This gives you operational confidence and financial visibility.
Each phase is a meaningful improvement. You can run a production billing system on Phase 1 alone. Add complexity only when your business requires it.
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