McTaba Labs logo
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),
  };
}

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account