Bonaventure OgetoBy Bonaventure Ogeto|

Build a Real Estate Viewing Deposit System

Build a real estate viewing deposit system by requiring a refundable deposit via Paystack before confirming a property viewing slot. Generate a unique viewing pass on the charge.success webhook and send it to the prospective buyer. After the viewing, refund the deposit via the Paystack Refund API if the buyer attended. Forfeit the deposit for no-shows, compensating the agent via Paystack Transfers.

Viewing Booking and Deposit Collection

Tables: properties (title, address, type, price, bedrooms, agent_id, status, images[]), agents (name, phone, email, paystack_recipient_code), viewings (property_id, prospect_email, prospect_phone, prospect_name, scheduled_at, deposit_amount, status, viewing_code, paystack_ref, attended_at, refunded_at).

var VIEWING_DEPOSIT = 300000; // NGN 3,000

async function bookViewing(prospectEmail, prospectData, propertyId, scheduledAt) {
  var property = await db.properties.findById(propertyId);
  if (property.status !== 'available') throw new Error('Property is no longer available for viewing');

  // Check no conflicts for this slot
  var conflicts = await db.viewings.findConflicts(propertyId, scheduledAt);
  if (conflicts.length >= 3) throw new Error('This slot is fully booked — choose another time');

  var reference = 'VIEW-' + propertyId + '-' + Date.now();

  var viewing = await db.viewings.create({
    property_id: propertyId,
    prospect_email: prospectEmail,
    prospect_name: prospectData.name,
    prospect_phone: prospectData.phone,
    scheduled_at: scheduledAt,
    deposit_amount: VIEWING_DEPOSIT,
    status: 'pending_payment',
    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: prospectEmail,
      amount: VIEWING_DEPOSIT,
      currency: 'NGN',
      reference,
      metadata: { viewing_id: viewing.id, property_id: propertyId, prospect_phone: prospectData.phone },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: confirm viewing and issue viewing code
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.viewing_id) return res.sendStatus(200);

  var viewingCode = 'VW-' + Math.random().toString(36).toUpperCase().slice(2, 9);
  await db.viewings.update(meta.viewing_id, { status: 'confirmed', viewing_code: viewingCode });

  var viewing = await db.viewings.findById(meta.viewing_id);
  var property = await db.properties.findById(meta.property_id);
  await sendViewingConfirmation(viewing.prospect_email, viewing.prospect_name, property, viewing.scheduled_at, viewingCode);
}

Attendance Confirmation, Refund, and No-Show Handling

// Agent marks prospect as attended — trigger refund
async function confirmAttendance(agentId, viewingId) {
  var viewing = await db.viewings.findById(viewingId);
  var property = await db.properties.findById(viewing.property_id);
  if (property.agent_id !== agentId) throw new Error('Not your listing');
  if (viewing.status !== 'confirmed') throw new Error('Viewing not in confirmed state');

  // Refund the deposit in full
  await fetch('https://api.paystack.co/refund', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ transaction: viewing.paystack_ref }),
  });

  await db.viewings.update(viewingId, {
    status: 'attended',
    attended_at: new Date(),
    refunded_at: new Date(),
  });
  await sendRefundNotification(viewing.prospect_email, viewing.deposit_amount / 100);
}

// Agent marks no-show — forfeit deposit
async function markNoShow(agentId, viewingId) {
  var viewing = await db.viewings.findById(viewingId);
  var property = await db.properties.findById(viewing.property_id);
  if (property.agent_id !== agentId) throw new Error('Not your listing');

  await db.viewings.update(viewingId, { status: 'no_show' });

  // Pay 60% of forfeited deposit to the agent as compensation
  var agent = await db.agents.findById(property.agent_id);
  var agentShare = Math.floor(viewing.deposit_amount * 0.60);

  await fetch('https://api.paystack.co/transfer', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      source: 'balance', amount: agentShare,
      recipient: agent.paystack_recipient_code,
      reason: 'No-show compensation: viewing #' + viewingId,
    }),
  });
  await sendNoShowNotification(viewing.prospect_phone);
}

// Viewing performance dashboard for agents
async function getAgentViewingStats(agentId, month) {
  var viewings = await db.viewings.findByAgentAndMonth(agentId, month);
  return {
    total_viewings: viewings.length,
    attended: viewings.filter(function(v) { return v.status === 'attended'; }).length,
    no_shows: viewings.filter(function(v) { return v.status === 'no_show'; }).length,
    compensation_earned: viewings
      .filter(function(v) { return v.status === 'no_show'; })
      .reduce(function(sum, v) { return sum + Math.floor(v.deposit_amount * 0.60); }, 0),
  };
}

Learn More

See build a hotel booking and deposit system for a related deposit collection pattern with a longer hold window and balance at check-in.

Sign up for the McTaba newsletter

Key Takeaways

  • Viewing deposits filter out time-wasters — only serious buyers commit to a small, refundable fee.
  • Generate a unique viewing confirmation code in the charge.success webhook.
  • Refund deposits in full via Paystack Refund API when the buyer attends as confirmed by the agent.
  • Forfeit deposits on no-shows and pay the agent a portion as compensation for their wasted time.
  • Track viewing-to-offer conversion rate per property to measure the quality of your leads.

Frequently Asked Questions

Is charging a viewing deposit legal in Nigeria and Kenya?
There is no law prohibiting viewing fees as long as the refund policy is clearly stated upfront and honored. In practice, most established real estate agencies do not charge viewing fees — it is more common among high-demand rental markets and property platforms trying to reduce no-shows. Disclose the fee and refund terms prominently before the prospect pays to avoid disputes.
What if the property is let before the booked viewing date?
Cancel all pending viewings and refund all deposits in full via the Paystack Refund API. Send an apology email to each prospect with information about similar properties. Update the property status to "let" to prevent new bookings. Quick full refunds protect your reputation — delays or partial refunds cause lasting damage to buyer trust.
How do I prevent agents from incorrectly marking attendees as no-shows to collect compensation?
Require the prospect to confirm attendance as well — send them an SMS with a confirmation link after the viewing. Both the agent and the prospect must confirm for the viewing to be marked "attended" and the refund triggered. If only the agent marks no-show but the prospect disputes it, flag for manual review. This two-sided confirmation prevents fraud from either party.

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