Build a Loyalty and Points System Tied to Payments
Build a loyalty points system by listening to the charge.success webhook to award points (e.g., 1 point per NGN 100 spent), maintaining a points balance per customer, and allowing points redemption at checkout by reducing the Paystack transaction amount. Always calculate points on the backend — never trust client-submitted point values.
Earning Points on Payments
Tables: loyalty_accounts (customer_id, points_balance, lifetime_points, tier), point_transactions (customer_id, type, points, order_id, paystack_ref, description, expires_at, created_at).
var POINTS_PER_KOBO = 1 / 10000; // 1 point per NGN 100 (10000 kobo)
var POINT_VALUE_IN_KOBO = 500; // 1 point = NGN 5 (500 kobo) when redeemed
// Webhook: award points on successful payment
if (event.event === 'charge.success') {
var customerId = event.data.metadata.customer_id;
if (!customerId) return res.sendStatus(200);
var paidAmount = event.data.amount; // in kobo
var pointsEarned = Math.floor(paidAmount * POINTS_PER_KOBO);
if (pointsEarned > 0) {
var expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
await db.pointTransactions.create({
customer_id: customerId,
type: 'earn',
points: pointsEarned,
paystack_ref: event.data.reference,
description: 'Points earned on purchase',
expires_at: expires,
});
await db.loyaltyAccounts.increment(customerId, { points_balance: pointsEarned, lifetime_points: pointsEarned });
// Update tier based on lifetime spend
var account = await db.loyaltyAccounts.findByCustomer(customerId);
var tier = account.lifetime_points >= 10000 ? 'gold' : account.lifetime_points >= 3000 ? 'silver' : 'bronze';
if (tier !== account.tier) await db.loyaltyAccounts.update(customerId, { tier });
}
}
Points Redemption at Checkout
async function redeemPoints(customerId, cartTotal, pointsToRedeem) {
var account = await db.loyaltyAccounts.findByCustomer(customerId);
if (account.points_balance < pointsToRedeem) throw new Error('Insufficient points');
var discountInKobo = pointsToRedeem * POINT_VALUE_IN_KOBO;
var maxDiscount = Math.floor(cartTotal * 0.3); // max 30% off via points
var actualDiscount = Math.min(discountInKobo, maxDiscount);
var actualPointsUsed = Math.ceil(actualDiscount / POINT_VALUE_IN_KOBO);
var remainingToPay = cartTotal - actualDiscount;
return { actualPointsUsed, actualDiscount, remainingToPay };
}
async function checkoutWithPoints(customerId, customerEmail, cartTotal, pointsToRedeem, orderId) {
var { actualPointsUsed, actualDiscount, remainingToPay } = await redeemPoints(customerId, cartTotal, pointsToRedeem);
// Deduct points immediately
await db.loyaltyAccounts.decrement(customerId, 'points_balance', actualPointsUsed);
await db.pointTransactions.create({
customer_id: customerId, type: 'redeem',
points: -actualPointsUsed, order_id: orderId, description: 'Points redeemed at checkout',
});
if (remainingToPay <= 0) {
await db.orders.update(orderId, { status: 'paid', payment_method: 'points' });
return { fullyPaid: true };
}
var res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: customerEmail,
amount: remainingToPay,
metadata: { customer_id: customerId, order_id: orderId, points_redeemed: actualPointsUsed },
}),
});
return { fullyPaid: false, authorizationUrl: (await res.json()).data.authorization_url };
}
Learn More
See build a gift card system on Paystack for a complementary stored-value mechanism alongside loyalty points.
Key Takeaways
- ✓Award points only after charge.success webhook — never on checkout initiation.
- ✓Calculate points redemption value server-side; never trust the client to report how many points to apply.
- ✓Store point transactions (earned, redeemed) for a complete audit trail and customer balance history.
- ✓Implement point expiry (12 months from earning) to manage liability and encourage spending.
- ✓Support tiered membership (Bronze, Silver, Gold) based on cumulative spend with different earn rates.
Frequently Asked Questions
- What if the customer's Paystack payment fails after I deducted their points?
- Listen for the charge.failed webhook. If a payment fails for an order where points were redeemed, restore the points to the customer's account immediately. Implement this as a compensating transaction in your point_transactions table (type: "restore") and increment the points_balance accordingly.
- How do I handle point expiry?
- Each point_transaction row has an expires_at date. When calculating the available balance, sum only non-expired points. Run a nightly cron job that marks expired points as "expired" and adjusts the loyalty_accounts points_balance accordingly. Notify customers 30 days before their points expire to encourage spending.
- Should I display the points balance on every page or just at checkout?
- Display the points balance prominently in the customer's account header and on the checkout page. A customer who sees "You have 850 points = NGN 4,250" is more likely to make a purchase to use those points. On the product page, consider showing "Earn 25 points with this purchase" to incentivize buying.
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