Building Your Own Billing Engine on Paystack Authorizations
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.
When to Build Your Own Billing Engine
The Subscriptions API is convenient but rigid. Build your own engine when:
- Charge amounts vary each cycle (usage-based, metered, or instalment billing)
- Billing intervals do not match Paystack's options (biweekly, every 10 days, school-term dates)
- You need proration on plan changes
- You need to apply credits, discounts, or coupons per charge
- You bill multiple line items per customer per cycle
- You need fine-grained control over retry timing and escalation
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.
The Billing Database
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.
The Charge Scheduler
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.
The Billing State Machine
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:
- active: Payment is current. Full access to the product.
- past_due: Last charge failed, retries in progress. Access continues during grace period. Show a payment warning banner.
- suspended: All retries exhausted. No payment received. Access revoked. Customer must update their card to reactivate.
- cancelled: Customer cancelled voluntarily or account was terminated. No further charges.
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.
Calculating Variable Charge Amounts
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.
Invoice Generation and Records
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.
Reconciliation with Paystack
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.
Starting Simple and Growing
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.
Key Takeaways
- ✓Build your own billing engine when you need variable amounts, custom intervals, complex proration, or billing logic the Subscriptions API cannot handle.
- ✓Your billing database should mirror Paystack objects (plans, subscriptions, invoices, payment methods) plus your own state fields.
- ✓Use a reliable task scheduler (cron + database or a job queue like BullMQ) to trigger charges. A missed scheduler run means missed revenue.
- ✓Implement a billing state machine with clear transitions: active to past_due on failure, past_due to active on recovery, past_due to suspended after retries exhaust.
- ✓Generate invoices before charging and update them after. This gives you a clean audit trail for accounting and customer support.
- ✓Run daily reconciliation between your billing records and Paystack transaction records to catch discrepancies.
- ✓Start simple. You can add complexity (proration, credits, multi-currency) incrementally as your business needs grow.
Frequently Asked Questions
- How long does it take to build a custom billing engine on Paystack?
- Phase 1 (basic recurring charges with success/failure handling) takes 1-2 weeks for an experienced developer. A full-featured engine with state machines, dunning, invoice generation, variable pricing, and reconciliation takes 4-8 weeks. Start with Phase 1 and iterate.
- Can I use a task queue instead of cron for the charge scheduler?
- Yes. Task queues like BullMQ (Redis-based) or cloud services like AWS SQS with scheduled messages are often better than cron because they handle retries, failure tracking, and concurrency. A task queue also lets you process charges in parallel safely.
- How do I handle zero-amount invoices?
- If a customer has credits that cover the full charge amount, create the invoice for record-keeping but skip the Paystack charge. Mark the invoice as paid with a note that it was covered by credits. This keeps your accounting clean.
- What happens if my scheduler crashes during a billing run?
- This is why you create invoices before charging. On the next run, the scheduler finds accounts that are still due (next_charge_date in the past) and processes them. Stale invoices from the crashed run are caught by the reconciliation job. Use unique references to prevent double charges.
- Should I store Paystack plan codes even if I manage billing myself?
- Not necessarily. If you never use the Subscriptions API, you do not need Paystack plans. Your billing_plans table is your plan system. However, if you use a hybrid approach (some customers on subscriptions, others on manual charges), keep both.
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