Bonaventure OgetoBy Bonaventure Ogeto|

Build a Classifieds Site with Featured Listing Payments

Build a classifieds site with paid features by allowing free listing creation but gating "featured" and "bumped" placement behind a Paystack payment. Create the listing immediately on submission, but only elevate it to featured status after the charge.success webhook confirms payment. Auto-expire featured status after the paid duration.

Listing Creation and Featured Upgrade

Tables: listings (seller_id, category, title, description, price, images[], location, status, is_featured, featured_until, bumped_until, paystack_ref), feature_packages (name, price, duration_days, benefit).

var FEATURE_PACKAGES = {
  featured: { price: 500000, duration: 7,  label: 'Featured — top of search for 7 days' }, // NGN 5,000
  bumped:   { price: 200000, duration: 3,  label: 'Bumped — pushed to top for 3 days' },   // NGN 2,000
  urgent:   { price: 150000, duration: 3,  label: 'Urgent badge — highlight for 3 days' }, // NGN 1,500
};

// Free listing — goes live immediately
async function createListing(sellerEmail, sellerId, listingData) {
  return await db.listings.create({
    seller_id: sellerId, ...listingData,
    status: 'active',
    is_featured: false,
  });
}

// Seller wants to feature an existing listing
async function purchaseFeature(sellerEmail, listingId, packageName) {
  var pkg = FEATURE_PACKAGES[packageName];
  if (!pkg) throw new Error('Invalid package');

  var reference = 'FEAT-' + listingId + '-' + Date.now();
  await db.listings.update(listingId, { paystack_ref: reference });

  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: sellerEmail,
      amount: pkg.price,
      currency: 'NGN',
      reference,
      metadata: { listing_id: listingId, package_name: packageName, duration_days: pkg.duration },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: activate featured status
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.listing_id) return res.sendStatus(200);

  var featuredUntil = new Date();
  featuredUntil.setDate(featuredUntil.getDate() + meta.duration_days);

  var updateData = { is_featured: true };
  if (meta.package_name === 'featured') updateData.featured_until = featuredUntil;
  if (meta.package_name === 'bumped')   updateData.bumped_until = featuredUntil;

  await db.listings.update(meta.listing_id, updateData);
}

Featured Expiry and Renewal Reminders

// Cron: expire featured listings and send renewal reminders
async function processFeaturedExpiry() {
  var now = new Date();

  // Expire featured listings
  var expiredFeatured = await db.listings.findAll({ is_featured: true, featured_until_lt: now });
  for (var listing of expiredFeatured) {
    await db.listings.update(listing.id, { is_featured: false, featured_until: null });
    await sendFeaturedExpiredNotification(listing.seller_id, listing.id);
  }

  // Send renewal reminders 1 day before expiry
  var tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
  var expiringSoon = await db.listings.findExpiringSoon(tomorrow);
  for (var listing of expiringSoon) {
    await sendRenewalReminder(listing.seller_id, listing.id, listing.featured_until);
  }
}

// Seller dashboard: listing performance
async function getListingStats(listingId) {
  var listing = await db.listings.findById(listingId);
  var views = await db.listingViews.countByListing(listingId);
  var contacts = await db.listingContacts.countByListing(listingId);
  return {
    views,
    contacts,
    contact_rate: views > 0 ? (contacts / views * 100).toFixed(1) + '%' : '0%',
    is_featured: listing.is_featured,
    featured_until: listing.featured_until,
  };
}

Learn More

See build a job board with paid listings for the same paid listing model applied specifically to job postings.

Sign up for the McTaba newsletter

Key Takeaways

  • Free listings go live immediately; featured status requires Paystack payment first.
  • Support multiple upgrade options: featured (7 days), bumped (3 days), urgent badge (3 days).
  • Auto-expire featured status via cron and send renewal prompts 1 day before expiry.
  • Use the same pattern for any listing category: cars, property, electronics, services.
  • Track which featured listings generate more views/contacts to refine your pricing.

Frequently Asked Questions

Should all listings require payment, or just the featured upgrade?
Start with free listings to build inventory — a classifieds site with no listings has no value for anyone. Once you have enough active listings (100+), you can test paid features. Some large classifieds (Jiji, OLX) start free, add featured upgrades, and only later gate basic posting. Free listings + paid upgrades is the proven model for early-stage growth.
How do I prevent the same listing from being posted multiple times by the same seller?
Add a duplicate detection check: before saving a new listing, query for active listings from the same seller in the same category with a similar title (fuzzy match) or same price. Flag duplicates for admin review or block them outright. For professional sellers (car dealers, agents), allow multiple listings but require account verification first.
Can I support a subscription model where sellers pay a flat monthly fee for unlimited featured listings?
Yes. Create a Paystack Plan for the seller subscription. On subscription.create, set a seller_plan: "pro" flag in your sellers table. In the purchaseFeature function, check if the seller has an active pro subscription and skip the Paystack charge if so. This is a good upsell for power sellers who post frequently.

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