Bonaventure OgetoBy Bonaventure Ogeto|

Build a Home Services Booking Marketplace

Build a home services marketplace by registering each provider as a Paystack Subaccount, routing service bookings through the subaccount split at payment time, and confirming the booking only after the charge.success webhook fires. The provider receives their share automatically; your platform keeps the service fee. Use Paystack Transfers for bonus payouts or adjustments.

Provider Onboarding and Service Booking

Tables: providers (name, phone, email, category, skills[], bio, avg_rating, paystack_subaccount_code, is_verified, is_available), services (provider_id, name, category, base_price, duration_hrs, description), bookings (customer_id, provider_id, service_id, address, scheduled_at, status, total_amount, paystack_ref).

var PLATFORM_FEE_PERCENT = 20; // platform keeps 20%, provider gets 80%

// Onboard a new provider with Paystack Subaccount
async function onboardProvider(providerData) {
  var provider = await db.providers.create({ ...providerData, is_verified: false });

  var res = await fetch('https://api.paystack.co/subaccount', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      business_name: providerData.name,
      settlement_bank: providerData.bank_code,
      account_number: providerData.account_number,
      percentage_charge: PLATFORM_FEE_PERCENT,
    }),
  });
  var subaccountCode = (await res.json()).data.subaccount_code;
  await db.providers.update(provider.id, { paystack_subaccount_code: subaccountCode });
  return provider;
}

// Customer books a service
async function bookService(customerEmail, customerId, serviceId, address, scheduledAt) {
  var service = await db.services.findById(serviceId);
  var provider = await db.providers.findById(service.provider_id);

  if (!provider.is_available) throw new Error('Provider is not currently available');

  // Travel surcharge based on distance from provider base
  var travelFee = await estimateTravelFee(provider, address);
  var total = service.base_price + travelFee;
  var reference = 'HOME-' + serviceId + '-' + Date.now();

  var booking = await db.bookings.create({
    customer_id: customerId, provider_id: provider.id, service_id: serviceId,
    address, scheduled_at: scheduledAt, status: 'pending_payment',
    total_amount: total, 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: customerEmail, amount: total, currency: 'NGN', reference,
      subaccount: provider.paystack_subaccount_code,
      bearer: 'account', // platform bears transaction fee
      metadata: { booking_id: booking.id, customer_id: customerId, provider_id: provider.id, service: service.name },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: confirm booking
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.booking_id) return res.sendStatus(200);

  await db.bookings.update(meta.booking_id, { status: 'confirmed' });
  var booking = await db.bookings.findById(meta.booking_id);
  var provider = await db.providers.findById(meta.provider_id);
  await sendBookingConfirmation(meta.customer_id, provider.name, booking.scheduled_at, booking.address);
  await sendJobNotification(provider.phone, meta.booking_id, booking.scheduled_at, booking.address);
}

Job Completion and Provider Ratings

// Provider marks job complete
async function completeJob(providerId, bookingId) {
  var booking = await db.bookings.findById(bookingId);
  if (booking.provider_id !== providerId) throw new Error('Not your booking');
  if (booking.status !== 'confirmed') throw new Error('Booking not confirmed');

  await db.bookings.update(bookingId, { status: 'completed', completed_at: new Date() });

  // Send rating request to customer
  var customer = await db.customers.findById(booking.customer_id);
  await sendRatingRequest(customer.email, customer.name, booking.id, providerId);
}

// Customer rates the provider
async function rateProvider(customerId, bookingId, rating, review) {
  if (rating < 1 || rating > 5) throw new Error('Rating must be between 1 and 5');
  var booking = await db.bookings.findById(bookingId);
  if (booking.customer_id !== customerId) throw new Error('Not your booking');

  await db.reviews.create({ booking_id: bookingId, provider_id: booking.provider_id, customer_id: customerId, rating, review, created_at: new Date() });

  // Update provider average rating
  var allRatings = await db.reviews.findByProvider(booking.provider_id);
  var avgRating = allRatings.reduce(function(sum, r) { return sum + r.rating; }, 0) / allRatings.length;
  await db.providers.update(booking.provider_id, { avg_rating: Math.round(avgRating * 10) / 10 });
}

// Customer disputes the job — escalate to admin
async function disputeJob(customerId, bookingId, reason) {
  var booking = await db.bookings.findById(bookingId);
  if (booking.customer_id !== customerId) throw new Error('Not your booking');

  await db.bookings.update(bookingId, { status: 'disputed' });
  await db.disputes.create({ booking_id: bookingId, raised_by: customerId, reason, status: 'open' });
  await notifyAdmin('New dispute on booking #' + bookingId + ': ' + reason);
}

Learn More

See build a tutoring marketplace with split payments for the same provider subaccount split pattern applied to educational services.

Sign up for the McTaba newsletter

Key Takeaways

  • Create a Paystack Subaccount per service provider — revenue splitting is automated at payment time.
  • Set platform commission as the bearer's share (15-20%); providers receive the remainder automatically.
  • Confirm bookings only in the charge.success webhook — never before payment clears.
  • Rate providers after job completion — use average rating to rank providers in search results.
  • For multi-person jobs (e.g., cleaning crew of 3), assign a lead provider and split internally.

Frequently Asked Questions

How do I verify that providers are qualified (e.g., licensed electricians)?
Require document upload at onboarding: professional certification, national ID, and a selfie with the document. Run a manual review before setting is_verified: true. For high-risk services (electrical, gas), partner with professional bodies (e.g., ERB in Kenya, COREN in Nigeria) to verify membership numbers via their APIs or manual lookup. Display a "Verified Provider" badge prominently on the profile.
What if the customer is not home when the provider arrives?
Give the provider a 15-minute wait window. If the customer does not answer after 15 minutes, the provider marks the job as "customer_absent." No refund is issued — the provider's time was committed. The provider receives 50% of the service fee as a show-up payment via Paystack Transfers. The customer gets the remaining 50% as a platform credit for a future booking, not cash refund.
How do I handle services that take longer than the estimated duration?
Include an overtime surcharge in your terms: NGN X per additional 30 minutes beyond the booked duration. After the job, the provider submits the actual duration in the completion form. If overtime applies, generate a supplementary Paystack payment link for the extra amount and send it to the customer. The customer pays the overtime charge before rating the provider.

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