Building a Salon and Barbershop Booking and Payment App
Build a salon booking app by creating a service menu with pricing, implementing time slot management tied to individual stylists, collecting booking deposits through Paystack (M-Pesa or card), charging the remaining balance on completion, tracking customer visit history, handling no-shows with deposit forfeiture, and accepting M-Pesa payments for walk-in customers using the Paystack Charge API or Payment Links.
Why Kenyan Salons Need a Booking and Payment App
Walk into any busy salon in Nairobi, Kisumu, or Mombasa and you will see the same problems. Customers arrive without appointments and wait 90 minutes. Other customers who booked on WhatsApp do not show up, leaving stylists idle. The cash register (often a drawer or a notebook) does not reconcile at the end of the day. And the owner has no data on which services are popular, which stylists are busiest, or which customers have not returned.
A booking app solves all four problems at once. Customers book and pay a deposit online. The salon knows exactly who is coming and when. Walk-in payments go through M-Pesa, creating a digital record. And the system tracks everything automatically.
The Kenyan beauty and grooming market is massive. Nairobi alone has thousands of salons and barbershops. Most still run on phone calls, WhatsApp messages, and cash. The ones that adopt digital booking and payment gain a real competitive edge: fewer no-shows, better cash flow, and happier customers who do not have to wait.
Paystack handles the payment collection. You build the booking logic, staff management, and customer experience.
Data Model: Services, Staff, Slots, and Bookings
Your database needs five core tables.
Salons table. Each salon record stores: name, location, phone, opening hours (stored as JSON for each day of the week), and owner_id. A single app can serve multiple salons.
Staff table. Each stylist or barber: name, phone, salon_id, specialties (braiding, cuts, colour, etc.), and a working_hours JSON that may differ from the salon hours. Staff drive the calendar. A booking is always with a specific person.
Services table. The menu: name, description, duration_minutes, price (in smallest currency unit for Paystack), category (hair, nails, beard, facial), and salon_id. A single service might take 30 minutes at a barbershop but 3 hours at a braiding salon. Duration matters because it determines slot length.
Time slots table (or computed). You have two options. Option A: pre-generate slots for each staff member per day (e.g., Jane has slots at 9:00, 9:30, 10:00, etc.) and store them in a table with a status column (available, booked, blocked). Option B: compute available slots on the fly by checking the staff member's working hours against existing bookings. Option A is simpler and faster to query. Option B is more flexible. For most salons, Option A works well.
Bookings table. The core record: booking_id, customer_phone, customer_name, staff_id, service_id, slot_datetime, duration_minutes, deposit_amount, deposit_reference (Paystack reference), balance_amount, balance_reference, status (pending_deposit, confirmed, completed, no_show, cancelled), and notes.
const createTablesSQL = `
CREATE TABLE salons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
location TEXT,
phone TEXT,
opening_hours JSONB,
owner_id UUID REFERENCES users(id)
);
CREATE TABLE staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
salon_id UUID REFERENCES salons(id),
name TEXT NOT NULL,
phone TEXT,
specialties TEXT[],
working_hours JSONB,
active BOOLEAN DEFAULT true
);
CREATE TABLE services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
salon_id UUID REFERENCES salons(id),
name TEXT NOT NULL,
duration_minutes INT NOT NULL,
price INT NOT NULL,
category TEXT,
active BOOLEAN DEFAULT true
);
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
salon_id UUID REFERENCES salons(id),
customer_phone TEXT NOT NULL,
customer_name TEXT,
staff_id UUID REFERENCES staff(id),
service_id UUID REFERENCES services(id),
slot_datetime TIMESTAMPTZ NOT NULL,
duration_minutes INT NOT NULL,
deposit_amount INT DEFAULT 0,
deposit_reference TEXT,
balance_amount INT DEFAULT 0,
balance_reference TEXT,
status TEXT DEFAULT 'pending_deposit',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`;
Time Slot Management and Availability
Time slot logic is where booking apps get complicated. A barbershop with 15-minute haircuts has a different rhythm than a salon that does 4-hour braiding sessions. Your slot system needs to handle both.
Generating available slots. For each staff member, take their working hours for the requested day, subtract existing bookings (including the service duration as a buffer), and return the remaining slots.
async function getAvailableSlots(staffId, date, serviceDurationMinutes) {
// Get staff working hours for this day of week
const staff = await db.query('SELECT working_hours FROM staff WHERE id = $1', [staffId]);
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'lowercase' });
const hours = staff.rows[0].working_hours[dayOfWeek];
if (!hours || !hours.start) return []; // Staff not working this day
// Get existing bookings for this staff on this date
const bookings = await db.query(
`SELECT slot_datetime, duration_minutes FROM bookings
WHERE staff_id = $1
AND DATE(slot_datetime) = $2
AND status IN ('confirmed', 'pending_deposit')
ORDER BY slot_datetime`,
[staffId, date]
);
// Generate all possible slots
const slots = [];
const startTime = parseTime(hours.start); // e.g., 08:00
const endTime = parseTime(hours.end); // e.g., 18:00
const slotInterval = 30; // 30-minute intervals
let current = startTime;
while (current + serviceDurationMinutes <= endTime) {
const slotEnd = current + serviceDurationMinutes;
// Check if this slot overlaps with any existing booking
const isAvailable = !bookings.rows.some((booking) => {
const bookingStart = getMinutesSinceMidnight(booking.slot_datetime);
const bookingEnd = bookingStart + booking.duration_minutes;
return current < bookingEnd && slotEnd > bookingStart;
});
if (isAvailable) {
slots.push(formatSlotTime(date, current));
}
current += slotInterval;
}
return slots;
}
Buffer time. Add 10 to 15 minutes between appointments for cleanup, especially for hair services. This buffer prevents the situation where one appointment runs long and pushes every subsequent booking behind schedule. Calculate it as: slot_end = slot_start + service_duration + buffer.
Blocking slots. Give staff the ability to block out time for lunch, personal breaks, or administrative tasks. A blocked slot is treated the same as a booked slot in the availability calculation.
Race conditions. Two customers viewing the same available slot might both try to book it. Handle this the same way we handle ticket overselling: use a database-level constraint or an atomic operation when creating the booking. If the slot is taken by the time the INSERT runs, return an error and ask the customer to pick another time.
Collecting Booking Deposits with Paystack
The deposit is what turns a casual WhatsApp "I'll come at 2" into a real commitment. When a customer selects a service, staff member, and time slot, calculate the deposit (typically 30% to 50% of the service price) and initiate a Paystack transaction.
async function createBookingWithDeposit(bookingData) {
const service = await getService(bookingData.service_id);
const depositPercent = 0.3; // 30% deposit
const depositAmount = Math.ceil(service.price * depositPercent);
const balanceAmount = service.price - depositAmount;
const reference = `booking_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// Create booking in pending state
const booking = await db.query(
`INSERT INTO bookings
(salon_id, customer_phone, customer_name, staff_id, service_id,
slot_datetime, duration_minutes, deposit_amount, deposit_reference,
balance_amount, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending_deposit')
RETURNING *`,
[
bookingData.salon_id, bookingData.phone, bookingData.name,
bookingData.staff_id, bookingData.service_id, bookingData.slot_datetime,
service.duration_minutes, depositAmount, reference, balanceAmount,
]
);
// Initialize Paystack transaction for deposit
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: `${bookingData.phone}@booking.yourapp.com`,
amount: depositAmount,
reference: reference,
currency: 'KES',
channels: ['mobile_money', 'card'],
callback_url: `https://yourapp.com/booking/confirm?ref=${reference}`,
metadata: {
booking_id: booking.rows[0].id,
salon_name: bookingData.salon_name,
service_name: service.name,
appointment_time: bookingData.slot_datetime,
},
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
return {
booking: booking.rows[0],
paymentUrl: response.data.data.authorization_url,
};
}
When the charge.success webhook fires, update the booking status to "confirmed" and send the customer an SMS with their appointment details.
async function handleDepositSuccess(reference) {
// Verify with Paystack
const verification = await verifyTransaction(reference);
if (verification.status !== 'success') return;
const booking = await db.query(
`UPDATE bookings SET status = 'confirmed'
WHERE deposit_reference = $1 AND status = 'pending_deposit'
RETURNING *`,
[reference]
);
if (booking.rowCount === 0) return;
const b = booking.rows[0];
// Send confirmation SMS
await sendSMS(
b.customer_phone,
`Booking confirmed at ${b.salon_name}! ${b.service_name} on ${formatDate(b.slot_datetime)} at ${formatTime(b.slot_datetime)}. Deposit of KES ${b.deposit_amount / 100} received. Balance: KES ${b.balance_amount / 100} due at the salon.`
);
// Notify the stylist
const staff = await getStaff(b.staff_id);
await sendSMS(
staff.phone,
`New booking: ${b.customer_name} for ${b.service_name} at ${formatTime(b.slot_datetime)}`
);
}
Deposit expiry. If the customer does not complete the deposit payment within 30 minutes, cancel the booking and release the slot. A scheduled job checks for bookings with status "pending_deposit" older than 30 minutes and flips them to "cancelled."
Balance Payment and Walk-In M-Pesa Payments
When the customer arrives and the service is complete, collect the remaining balance. Services often change during the visit: the customer might add a wash, upgrade to a different style, or skip the conditioning treatment. So the balance amount might differ from what was calculated at booking time.
async function chargeBalance(bookingId, actualBalanceAmount) {
const reference = `balance_${bookingId}_${Date.now()}`;
await db.query(
'UPDATE bookings SET balance_amount = $1, balance_reference = $2 WHERE id = $3',
[actualBalanceAmount, reference, bookingId]
);
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: `${booking.customer_phone}@booking.yourapp.com`,
amount: actualBalanceAmount,
reference: reference,
currency: 'KES',
channels: ['mobile_money', 'card'],
metadata: {
booking_id: bookingId,
payment_type: 'balance',
},
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
return response.data.data.authorization_url;
}
Walk-in payments. Not every customer books online. Walk-ins are the bread and butter of most Kenyan salons. For these customers, you need a quick payment option that the receptionist or stylist can trigger from a tablet or phone at the counter.
The simplest approach: generate a Paystack Payment Link for the service amount and display the payment page on a tablet. The customer pays via M-Pesa on their phone (they will get the STK push) or scans a QR code if you display one.
async function createWalkInPayment(salonId, serviceName, amount, customerPhone) {
const reference = `walkin_${salonId}_${Date.now()}`;
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: `${customerPhone}@booking.yourapp.com`,
amount: amount,
reference: reference,
currency: 'KES',
channels: ['mobile_money'],
metadata: {
salon_id: salonId,
service_name: serviceName,
payment_type: 'walk_in',
customer_phone: customerPhone,
},
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
return response.data.data.authorization_url;
}
For walk-ins, restrict the channel to mobile_money only. The customer is standing at the counter. They have their phone. M-Pesa is the fastest path to payment completion. Card entry on a shared tablet is awkward and raises security concerns.
Handling No-Shows
No-shows cost salons real money. A stylist who blocks out 2 hours for a braiding appointment that does not happen could have served two or three other customers in that time. The deposit system discourages no-shows, but it does not eliminate them entirely.
The policy. Define a no-show policy clearly at booking time: "If you do not show up or cancel less than 2 hours before your appointment, your deposit is forfeited." Display this before the customer pays. Store the policy acceptance timestamp in the booking record.
Detection. A booking is a no-show if the customer has not checked in by 15 or 30 minutes after the appointment time (configurable per salon). Run a scheduled job that marks overdue confirmed bookings as no_show.
async function detectNoShows() {
const gracePeriod = 30; // minutes
const noShows = await db.query(
`UPDATE bookings
SET status = 'no_show'
WHERE status = 'confirmed'
AND slot_datetime + interval '${gracePeriod} minutes' < NOW()
RETURNING *`
);
for (const booking of noShows.rows) {
// Notify the salon owner
await sendSMS(
booking.salon_phone,
`No-show: ${booking.customer_name} missed their ${formatTime(booking.slot_datetime)} appointment. Deposit of KES ${booking.deposit_amount / 100} retained.`
);
// Optionally notify the customer
await sendSMS(
booking.customer_phone,
`You missed your appointment at ${booking.salon_name}. Your deposit of KES ${booking.deposit_amount / 100} has been forfeited per our booking policy.`
);
}
}
Deposit forfeiture. The deposit has already been collected through Paystack and is sitting in the salon's Paystack balance (or has been settled to their bank account). A no-show means you simply do not refund it. No additional API call is needed. The money stays with the salon.
Repeat offenders. Track no-show counts per customer phone number. Customers who no-show more than twice might be required to pay a higher deposit (50% instead of 30%) or pay the full amount upfront for future bookings. This is a business logic decision, but your system should provide the data to enforce it.
Cancellation versus no-show. Let customers cancel through the app or by replying to their confirmation SMS. If they cancel more than 2 hours before the appointment, refund the deposit through Paystack Refunds API and release the slot. If they cancel within 2 hours, treat it as a no-show and forfeit the deposit. The cutoff time is configurable per salon.
Customer History and Loyalty
A salon's best customers are repeat customers. Your app should track every visit, service received, amount paid, and which stylist handled the appointment. This data powers three things.
Personalized service. When a returning customer books, the stylist can see their history: "Last visit: box braids with Jane, 3 weeks ago. Before that: cornrows with Mary." The stylist knows what to expect and can suggest something new based on pattern. Store notes on each booking so stylists can record things like "prefers extra-long braids" or "sensitive scalp, use gentle products."
async function getCustomerHistory(phone, salonId) {
const history = await db.query(
`SELECT b.slot_datetime, b.service_name, b.staff_name,
b.deposit_amount + b.balance_amount AS total_paid,
b.notes, b.status
FROM bookings b
WHERE b.customer_phone = $1
AND b.salon_id = $2
AND b.status IN ('completed', 'no_show')
ORDER BY b.slot_datetime DESC
LIMIT 20`,
[phone, salonId]
);
const stats = await db.query(
`SELECT COUNT(*) as total_visits,
SUM(deposit_amount + balance_amount) as total_spent,
MAX(slot_datetime) as last_visit
FROM bookings
WHERE customer_phone = $1
AND salon_id = $2
AND status = 'completed'`,
[phone, salonId]
);
return { visits: history.rows, stats: stats.rows[0] };
}
Loyalty programs. "Every 10th haircut is free" is a common offer in Kenyan barbershops. With digital tracking, this becomes automatic instead of relying on easily-lost paper punch cards. When a customer reaches the threshold, apply a discount to their next booking or generate a free-service voucher.
Re-engagement. If a regular customer has not visited in 6 weeks, send them an SMS: "Hi Sarah, it has been a while since your last visit at Glamour Salon. Book this week and get 20% off." This automated outreach brings back customers who might have simply forgotten or gotten busy. The data to power it is already in your bookings table.
Appointment Reminders via SMS
SMS reminders are one of the simplest features you can build and one of the most impactful. Send two reminders per booking.
24-hour reminder. "Reminder: Your appointment at Glamour Salon is tomorrow at 2:00 PM. Service: Box Braids with Jane. Reply CANCEL to cancel." This gives the customer time to cancel and free the slot for someone else.
2-hour reminder. "Your appointment at Glamour Salon is in 2 hours at 2:00 PM. See you there!" This is a final nudge. By this point, cancellation forfeits the deposit, so the message does not include a cancel option.
const cron = require('node-cron');
// Run every 15 minutes to catch upcoming appointments
cron.schedule('*/15 * * * *', async () => {
const now = new Date();
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const in2Hours = new Date(now.getTime() + 2 * 60 * 60 * 1000);
// 24-hour reminders
const upcoming24h = await db.query(
`SELECT * FROM bookings
WHERE status = 'confirmed'
AND reminder_24h_sent = false
AND slot_datetime BETWEEN $1 AND $2`,
[new Date(in24Hours.getTime() - 15 * 60 * 1000), in24Hours]
);
for (const booking of upcoming24h.rows) {
await sendSMS(
booking.customer_phone,
`Reminder: Your appointment at ${booking.salon_name} is tomorrow at ${formatTime(booking.slot_datetime)}. ${booking.service_name} with ${booking.staff_name}. Reply CANCEL to cancel.`
);
await db.query('UPDATE bookings SET reminder_24h_sent = true WHERE id = $1', [booking.id]);
}
// 2-hour reminders (similar pattern)
const upcoming2h = await db.query(
`SELECT * FROM bookings
WHERE status = 'confirmed'
AND reminder_2h_sent = false
AND slot_datetime BETWEEN $1 AND $2`,
[new Date(in2Hours.getTime() - 15 * 60 * 1000), in2Hours]
);
for (const booking of upcoming2h.rows) {
await sendSMS(
booking.customer_phone,
`Your appointment at ${booking.salon_name} is in 2 hours at ${formatTime(booking.slot_datetime)}. See you there!`
);
await db.query('UPDATE bookings SET reminder_2h_sent = true WHERE id = $1', [booking.id]);
}
});
Add reminder_24h_sent and reminder_2h_sent boolean columns to your bookings table to prevent duplicate sends if the cron job runs multiple times.
The Salon Owner Dashboard
Salon owners and managers need a dashboard that shows them what is happening today and what happened this week. Build it around three views.
Today's schedule. A timeline showing all bookings for each staff member, colour-coded by status (confirmed, completed, no-show, cancelled). The receptionist uses this view to manage the floor. Show the customer name, service, and arrival status at a glance.
Revenue summary. Daily and weekly revenue broken down by payment type (deposit, balance, walk-in), staff member (who is generating the most revenue), and service category. Pull this from your bookings table, not from Paystack directly. Your database is the source of truth for business metrics. Use Paystack's transaction data only for reconciliation.
Customer insights. Top customers by spend, customers due for a return visit, no-show rates, and booking trends by day of week and time of day. This helps the salon owner make staffing decisions: if Saturdays between 10 AM and 2 PM are consistently packed, they need more stylists during that window.
Mobile-first. Salon owners are not sitting behind a desktop. They are on their feet, moving between stations, checking their phone between clients. Build the dashboard as a PWA or responsive web app that works well on a phone screen. The most important actions (view today's schedule, mark a customer as checked in, process a walk-in payment) should be accessible with one tap.
For the broader picture on how Paystack works with M-Pesa and other Kenyan payment methods, see our Paystack in Kenya hub guide.
Key Takeaways
- ✓Deposits solve the no-show problem. Charge 30% to 50% of the service cost upfront when the customer books. Customers who pay something are far more likely to show up.
- ✓Build time slots around individual staff members, not the salon as a whole. A salon with three stylists has three independent calendars running in parallel.
- ✓Use Paystack Initialize Transaction for online bookings and deposits. For walk-in M-Pesa payments, use Paystack Payment Links or the Charge API with the mobile_money channel.
- ✓Store customer history: services received, amounts paid, preferred stylist, and notes. Repeat customers are the business backbone, and personalization keeps them coming back.
- ✓Handle the balance payment (service total minus deposit) as a separate Paystack transaction at checkout time. Do not try to charge the full amount in advance since services often change during the visit.
- ✓SMS reminders 24 hours and 2 hours before the appointment reduce no-shows by 30% or more, even before deposit enforcement.
Frequently Asked Questions
- Should I charge a deposit for every booking or only for expensive services?
- Start with deposits for services over a certain price threshold or duration. A 15-minute beard trim probably does not need a deposit, but a 3-hour braiding session absolutely does. Let each salon owner configure their deposit threshold. Common practice in Kenya: require deposits for services over KES 1,000 or longer than 1 hour.
- How do I handle a customer who wants to change their appointment time?
- Allow rescheduling without forfeiting the deposit, as long as it is done outside the no-show window (more than 2 hours before the appointment). Release the old time slot, create a new booking linked to the same deposit Paystack reference, and send an updated confirmation SMS. No new payment needed.
- What if the final service costs less than the deposit already paid?
- This is rare but possible. If the customer paid a KES 500 deposit for a service that ended up costing KES 400, you have two options: issue a partial refund of KES 100 through Paystack, or carry the KES 100 as a credit for their next visit. The credit approach is better for the business since it guarantees a return visit.
- Can the salon accept cash alongside Paystack payments?
- Yes, and many salons will need to during the transition period. Build a "cash payment" option in your app that records the transaction without going through Paystack. This keeps your revenue tracking complete even when payment goes through cash. Over time, encourage salons to shift toward M-Pesa for better record-keeping.
- How do I handle group bookings, like a bridal party?
- Create multiple bookings under one group reference. The bride might book a stylist for herself and three friends, each with different services and time slots. Collect one deposit for the whole group or individual deposits per person. The group booking is essentially a collection of individual bookings linked by a group_id, which makes scheduling and payment tracking straightforward.
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