Bonaventure OgetoBy Bonaventure Ogeto|

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.

Sign up for the McTaba newsletter

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