Bonaventure OgetoBy Bonaventure Ogeto|

Build a Veterinary Clinic Booking and Payment System

Build a vet clinic booking system by managing appointment slots per vet, requiring a deposit (NGN 500-2,000) at booking time via Paystack, confirming the slot only after the charge.success webhook fires, and maintaining a pet health record per patient with vaccination history and upcoming due dates for automated reminders.

Appointment Booking and Deposit Collection

Tables: vets (name, specialty, avatar_url), services (name, duration_mins, base_price), slots (vet_id, date, start_time, status, booking_id), bookings (owner_id, pet_id, vet_id, service_id, slot_id, deposit_amount, total_estimate, status, paystack_ref), pets (owner_id, name, species, breed, dob, weight_kg, vaccination_records[]).

async function bookAppointment(ownerEmail, ownerId, petId, vetId, serviceId, slotId) {
  var slot = await db.slots.findById(slotId);
  if (slot.status !== 'available') throw new Error('Slot not available');

  var service = await db.services.findById(serviceId);
  var deposit = 100000; // NGN 1,000 deposit
  var reference = 'VET-' + Date.now();

  // Hold the slot
  await db.slots.update(slotId, { status: 'held', held_until: new Date(Date.now() + 15 * 60000) });

  var booking = await db.bookings.create({
    owner_id: ownerId, pet_id: petId, vet_id: vetId, service_id: serviceId, slot_id: slotId,
    deposit_amount: deposit, total_estimate: service.base_price,
    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: ownerEmail, amount: deposit, currency: 'NGN', reference,
      metadata: { booking_id: booking.id, slot_id: slotId, pet_id: petId },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: confirm booking on deposit payment
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.booking_id) return res.sendStatus(200);
  await db.slots.update(meta.slot_id, { status: 'booked', held_until: null });
  await db.bookings.update(meta.booking_id, { status: 'confirmed' });

  var booking = await db.bookings.findById(meta.booking_id);
  var pet = await db.pets.findById(meta.pet_id);
  var slot = await db.slots.findById(meta.slot_id);
  await sendConfirmationSMS(booking.owner_id, pet.name, slot.date, slot.start_time);
}

// Cron: release expired holds
async function releaseExpiredHolds() {
  var expired = await db.slots.findAll({ status: 'held', held_until_lt: new Date() });
  for (var slot of expired) {
    await db.slots.update(slot.id, { status: 'available', held_until: null });
    await db.bookings.update({ slot_id: slot.id, status: 'pending_payment' }, { status: 'expired' });
  }
}

Pet Health Records and Vaccination Reminders

// Record vaccination at clinic visit
async function recordVaccination(petId, vaccineName, doseNumber, nextDueDate) {
  await db.pets.addVaccinationRecord(petId, {
    vaccine: vaccineName,
    dose: doseNumber,
    administered_at: new Date(),
    next_due_date: nextDueDate,
  });
}

// Cron: check upcoming vaccinations and send reminders
async function sendVaccinationReminders() {
  var oneWeekAhead = new Date();
  oneWeekAhead.setDate(oneWeekAhead.getDate() + 7);

  var petsDue = await db.pets.findVaccinationsDueBefore(oneWeekAhead);
  for (var record of petsDue) {
    var pet = await db.pets.findById(record.pet_id);
    var owner = await db.owners.findById(pet.owner_id);
    await sendSMS(owner.phone,
      pet.name + ''s ' + record.vaccine + ' (dose ' + record.dose + ') is due on ' +
      record.next_due_date.toDateString() + '. Book: yourvet.com/book'
    );
    await db.pets.markReminderSent(record.id);
  }
}

// Balance payment after clinic visit (via link or card swipe)
async function requestBalancePayment(bookingId, ownerEmail, actualAmount) {
  var booking = await db.bookings.findById(bookingId);
  var balanceDue = actualAmount - booking.deposit_amount;
  if (balanceDue <= 0) {
    await db.bookings.update(bookingId, { status: 'paid_in_full' });
    return null; // deposit covered everything
  }

  var reference = 'VETBAL-' + bookingId + '-' + 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: ownerEmail, amount: balanceDue, reference,
      metadata: { booking_id: bookingId, payment_type: 'balance' },
    }),
  });
  return (await res.json()).data.authorization_url;
}

Learn More

See build a barbershop appointment and payment app for the same slot-holding deposit pattern applied to personal care bookings.

Sign up for the McTaba newsletter

Key Takeaways

  • Require a deposit at booking to eliminate no-shows — pet emergencies excepted.
  • Maintain a pet health record per animal with vaccination history and next-due dates.
  • Send automated SMS reminders for upcoming vaccinations 7 days before due.
  • Apply the deposit toward the final bill — balance collected at the clinic or via a second Paystack link.
  • Support multi-vet scheduling: each vet has their own calendar and specialty tags.

Frequently Asked Questions

How do I handle emergency appointments that skip the booking and payment flow?
Add an "Emergency Walk-in" button for clinic staff to manually create a booking in "emergency" status, bypassing the deposit requirement. Collect payment at the end of the visit instead. Emergency bookings are always confirmed immediately without Paystack — the payment link is generated and sent after the consultation is complete.
Can I support mobile vet services (home visits)?
Yes. Add a service_type field (clinic_visit vs home_visit). Home visits have a higher base price to cover travel. At booking, collect the customer's address and add a travel_fee calculated by zone or distance. Include the home visit flag in the webhook metadata so the vet receives the correct route information.
How do I manage multiple clinics under one platform?
Add a clinics table and link vets to clinics. For multi-clinic platforms where each clinic is independently managed, create a Paystack Subaccount per clinic — deposits route to the correct clinic automatically. The platform keeps a small platform fee from each subaccount split.

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