Bonaventure OgetoBy Bonaventure Ogeto|

Build a Job Board with Paid Listings

Build a paid job board by creating listings in "draft" state, initializing a Paystack payment for the listing fee, and publishing the listing only when the charge.success webhook fires. Support listing tiers (standard, featured, urgent) and optional employer subscription bundles for frequent posters.

Listing Tiers and Payment Flow

Tables: employers (name, email, company, subscription_credits), listings (employer_id, title, description, location, type, salary, tier, status, paystack_ref, published_at, expires_at), listing_tiers (name, price, duration_days, features).

var LISTING_TIERS = {
  standard: { price: 1500000, duration: 30, label: 'Standard 30-day listing' }, // NGN 15,000
  featured:  { price: 3000000, duration: 30, label: 'Featured listing — top placement' }, // NGN 30,000
  urgent:    { price: 2000000, duration: 14, label: 'Urgent — highlighted for 14 days' }, // NGN 20,000
};

async function createListing(employerEmail, employerId, listingData, tier) {
  var tierConfig = LISTING_TIERS[tier];
  if (!tierConfig) throw new Error('Invalid listing tier');

  // Check if employer has subscription credits
  var employer = await db.employers.findById(employerId);
  if (employer.subscription_credits > 0) {
    await db.employers.decrement(employerId, 'subscription_credits', 1);
    return await publishListing(employerId, listingData, tier, null); // no payment needed
  }

  // Create listing in draft
  var reference = 'LISTING-' + Date.now();
  var listing = await db.listings.create({
    employer_id: employerId, ...listingData,
    tier, status: 'draft', paystack_ref: reference,
  });

  // Initialize payment
  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: employerEmail, amount: tierConfig.price, currency: 'NGN', reference,
      metadata: { listing_id: listing.id, tier, duration_days: tierConfig.duration },
      label: tierConfig.label,
    }),
  });
  return { listing, authorizationUrl: (await res.json()).data.authorization_url };
}

// Webhook: publish listing on payment
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  var publishedAt = new Date();
  var expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + meta.duration_days);

  await db.listings.update(meta.listing_id, { status: 'published', published_at: publishedAt, expires_at: expiresAt });
  await scheduleExpiryReminder(meta.listing_id, expiresAt);
}

Employer Subscription Bundles and Listing Expiry

// Employer buys a posting bundle — e.g., 5 posts for the price of 4
async function purchasePostingBundle(employerEmail, employerId, bundleSize) {
  var BUNDLES = { 5: 6000000, 10: 10000000 }; // NGN 60k for 5, NGN 100k for 10
  var price = BUNDLES[bundleSize];
  if (!price) throw new Error('Invalid bundle size');

  var reference = 'BUNDLE-' + Date.now();
  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: employerEmail, amount: price, currency: 'NGN', reference,
      metadata: { employer_id: employerId, bundle_credits: bundleSize, purpose: 'bundle_purchase' },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: credit bundle
if (event.event === 'charge.success' && event.data.metadata.purpose === 'bundle_purchase') {
  await db.employers.increment(event.data.metadata.employer_id, 'subscription_credits', event.data.metadata.bundle_credits);
}

// Cron: expire listings and send renewal reminders
async function processExpiredListings() {
  var today = new Date();
  await db.listings.updateWhere({ status: 'published', expires_at_lt: today }, { status: 'expired' });

  var expiringSoon = await db.listings.findExpiringSoon(addDays(today, 5));
  for (var listing of expiringSoon) {
    await sendRenewalReminder(listing.employer_id, listing.id);
  }
}

Learn More

See build a classifieds site with featured listing payments for the same paid listing model applied to a broader classifieds platform.

Sign up for the McTaba newsletter

Key Takeaways

  • Create listings in draft state; publish only after the Paystack webhook confirms payment.
  • Support multiple listing tiers: standard (30 days), featured (top placement), urgent (highlighted).
  • Auto-expire listings after their duration and send renewal reminders 5 days before expiry.
  • Employer subscription bundles (NGN 50,000/month for 5 postings) reduce per-post friction for heavy users.
  • Track which listings converted (received applications) to improve pricing and feature value.

Frequently Asked Questions

Should job seekers pay or only employers?
Employer-pays is the dominant model for job boards (LinkedIn, Jobberman, Careers24). Free for job seekers maximizes the candidate pool, which is the core value you sell to employers. Premium job seeker features (CV builder, application boosting, skills assessments) can generate additional revenue, but the core board should be free for candidates.
How do I handle listings from employers who request a refund after publishing?
Define a no-refund policy for published listings (the listing has already received visibility). Refunds are reasonable for listings that were never published due to a technical issue. If you unpublish a listing at the employer's request before it expires, offer a credit for future listings rather than a cash refund — this retains the revenue while giving the employer flexibility.
How do I prevent duplicate or fraudulent job listings?
Require employer email verification before allowing listing creation. Moderate new employers' first listing before publishing (auto-publish after the first listing is approved). Flag listings with vague descriptions, requests for personal financial information, or suspicious salary ranges. A simple admin review queue catches most fraud before it goes live.

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