WhatsApp-based appointment booking platform for local service businesses. Tier-2 city salons, parlours, barbershops, tailors — no app, no website, just WhatsApp.
Local service businesses in tier-2 cities run entirely on phone calls and walk-ins. They have no digital presence beyond word of mouth. The result: missed calls during busy hours, double bookings, no-shows, and zero visibility into the day ahead.
On the customer side, calling to book feels like friction. But these same customers are already on WhatsApp all day. The tool they need already lives on their phone — nobody has connected it to the salon yet.
Bookie is a multi-tenant SaaS platform where customers book appointments through a natural WhatsApp conversation. No app install. No account creation. They scan a QR code, type what they want in plain language, and reserve a slot.
The booking flow is guided but not rigid. Instead of forcing customers to reply with numbered options, Bookie uses an LLM to extract intent from each message — service name, staff preference, date, time — and maps it to the correct booking step. The flow stays structured; the input becomes human.
Each business gets its own dashboard to manage staff, services, schedules, and bookings. The platform runs multiple businesses from a single backend with complete data isolation between tenants.
Customer side:
Scan QR code at the salon
↓
WhatsApp opens with a pre-filled message
↓
Bot greets the customer and shows services
↓
Customer picks: Service → Staff → Date → Slot
↓
Booking confirmed via WhatsApp message
↓
Dashboard updates in real time
Example conversation:
Bot: "Welcome to Glow Salon! What can we help you with today?
We offer: Hair Cut, Facial, Beard Trim"
Customer: "I want a haircut"
Bot: "Who would you like?
Alice is available for Hair Cut.
Bob is also available. Or say 'anyone' for the next free slot."
Customer: "Alice please"
Bot: "What date works for you?"
Customer: "This Saturday"
Bot: "Available slots with Alice on 26 July:
9:00 AM, 10:30 AM, 2:00 PM"
Customer: "10:30 in the morning"
Bot: "Confirm booking?
Service: Hair Cut | Staff: Alice | 10:30 AM, 26 July
Reply YES or NO"
Customer: "yes"
Bot: "Booking confirmed! See you at 10:30 AM on 26 July.
Alice will be ready for you."
The flow is structured — the bot always knows which step it is on. But the customer's input is natural language. The LLM extracts the intent from each message and maps it to the current step rather than requiring numbered replies.
Business Owner
- Register and configure their business
- Manage services, staff, working hours, and holidays
- View and manage all bookings from the dashboard
- Generate and display their WhatsApp QR code
Customer
- Scan the QR code
- Book an appointment via WhatsApp
- Receive confirmation
Platform Admin (future)
- Manage all businesses on the platform
- View platform-wide statistics
- Suspend or activate accounts
| Layer | Technology |
|---|---|
| Frontend | React, Material UI, React Router, TanStack Query, Axios |
| Backend | Node.js, Express.js |
| ORM | Prisma |
| Database | PostgreSQL (Neon) |
| Auth | JWT, bcrypt |
| WhatsApp Cloud API (Meta) | |
| AI | Openrouter AI API key(free tier) |
| Deployment | Vercel (frontend), Railway or Render (backend) |
PostgreSQL is chosen over MongoDB because the data is relational — businesses have staff, staff have services, services have bookings. Foreign key constraints enforce integrity at the database level. Prisma sits on top as a TypeScript-first ORM that stays close to raw SQL.
Customer (WhatsApp)
↓
WhatsApp Cloud API → Webhook POST
↓
Node / Express Backend
↓
Intent Parser — Openrouter AI API
(reads current step + customer message,
returns structured JSON: { intent, serviceName, staffName, date, time })
↓
Conversation State Manager (ConversationState table in PostgreSQL)
↓
PostgreSQL — Neon
(User, Business, Staff, Service, StaffService,
WorkingHours, Holiday, Booking, ConversationState)
↓
React Dashboard (business owner)
↓
WhatsApp Cloud API (outbound reply to customer)
Webhooks are stateless — every incoming WhatsApp message is a fresh HTTP POST with no memory of prior messages. Conversation state (where each phone number is in the booking flow) is stored per customer in the ConversationState table. This is the core engineering problem the system solves.
The Intent Parser sits between the raw message and the state machine. It receives the customer's message, the current step, and the list of valid options for that step. It returns a structured JSON object. The state machine only ever works with that structured output — it never tries to parse raw text itself.
User
id, name, email, passwordHash, createdAt
└── has one Business
Business
id, userId, name, address, phone, timezone, bookingSlug, createdAt
└── has many Staff, Services, Holidays, Bookings
Staff
id, businessId, name, phone, isActive, createdAt
└── has many StaffService, WorkingHours
Service
id, businessId, name, durationMins, price, isActive, createdAt
└── has many StaffService, Bookings
StaffService (join table)
id, staffId, serviceId
→ many-to-many: one staff member offers multiple services
WorkingHours
id, staffId, dayOfWeek, startTime, endTime, breakStart, breakEnd
Holiday
id, businessId, date, reason
Booking
id, businessId, staffId, serviceId, customerPhone, customerName,
date, startTime, status (confirmed | cancelled | no_show), createdAt
ConversationState
id, customerPhone, businessId, step, serviceId, staffId, date,
availableSlots (JSON), lastUpdated
Why foreign keys matter: PostgreSQL blocks deleting a staff member with active bookings unless you handle it in application logic. This prevents orphaned booking records without writing extra guards.
The state machine is the core of the WhatsApp bot. Each customer phone number has exactly one row in ConversationState that tracks where they are in the flow.
IDLE
↓ (customer sends any message)
SELECTING_SERVICE
↓ (LLM extracts service name from message, matched against business services)
SELECTING_STAFF
↓ (LLM extracts staff name or "any" from message)
SELECTING_DATE
↓ (LLM extracts and normalises date from message, e.g. "this Saturday" → "2026-08-08")
SELECTING_SLOT
↓ (LLM extracts time from message, matched against available slots)
CONFIRMING
↓ (LLM detects YES / NO intent)
BOOKED → resets to IDLE
At any step: LLM returns { intent: "unclear" } → re-prompt the current step
Customer expresses cancellation intent at any step → reset to IDLE with a message
Every incoming WhatsApp message passes through an intent parser before reaching the state machine. The parser is a single function — parseIntent(step, message, context) — that calls the Anthropic API and returns structured JSON.
How it works:
The function receives three things: the current state machine step, the customer's raw message, and a context object containing the valid options for that step (e.g. the list of services, or the list of available slots). It sends these to Claude with a carefully engineered prompt that instructs the model to return only JSON — no prose, no explanation.
Prompt structure:
System: You are a booking assistant parser. You extract structured intent
from customer messages. You always respond with valid JSON only.
Never include explanation or markdown.
User: Current step: SELECTING_SERVICE
Available services: ["Hair Cut", "Facial", "Beard Trim"]
Customer message: "I want a haircut please"
Return JSON in this exact shape:
{
"intent": "selected" | "cancel" | "unclear",
"value": <extracted value or null>
}
Structured output contract:
Every response from the parser is validated against this shape before the state machine uses it:
{
"intent": "selected" | "cancel" | "unclear",
"value": "Hair Cut" | "Alice" | "2026-07-26" | "10:30" | true | null
}The value field is always normalised — dates become ISO strings, times become 24-hour strings, service and staff names are matched to exact DB values. If the LLM returns something that does not match the schema, the parser treats it as { intent: "unclear", value: null } and the state machine re-prompts.
Why this approach:
The state machine stays simple and deterministic. It never sees raw text. All the complexity of natural language sits in one isolated function with one well-defined output contract. This makes the system easy to test, debug, and improve independently of the booking logic.
Slots are not pre-stored in the database. They are computed on demand from:
- Staff working hours for the selected day
- Service duration
- Break timings (e.g. lunch 1–2 PM)
- Existing confirmed bookings for that staff on that date
- Holidays (blocked dates return no slots)
Example:
Working hours: 9:00 AM – 6:00 PM
Service: 30 mins
Break: 1:00 PM – 2:00 PM
Existing bookings: 10:30 AM, 2:00 PM
Generated available slots:
9:00 AM, 9:30 AM, 10:00 AM, 11:00 AM, 11:30 AM,
12:00 PM, 12:30 PM, 2:30 PM, 3:00 PM, 3:30 PM, ...
This avoids storing thousands of rows and keeps the DB lean.
Auth
POST /api/auth/register— create user + business, return JWTPOST /api/auth/login— verify credentials, return JWT
Business
PATCH /api/business/profile— update name, address, timezone (protected)
Services
GET /api/services— list services for logged-in businessPOST /api/services— create a servicePATCH /api/services/:id— update a serviceDELETE /api/services/:id— delete a service
Staff
GET /api/staff— list staffPOST /api/staff— create staff memberPATCH /api/staff/:id— update staffDELETE /api/staff/:id— soft deactivate (preserve booking history)POST /api/staff/:id/services— assign services to staffGET /api/staff/:id/hours— get working schedulePOST /api/staff/:id/hours— set working hoursPATCH /api/staff/:id/hours— update schedule
Holidays
GET /api/holidays— list blocked datesPOST /api/holidays— add a blocked dateDELETE /api/holidays/:id— remove a blocked date
Slots
GET /api/slots?staffId=&serviceId=&date=— returns dynamically generated available slots
Bookings
GET /api/bookings— list bookings with filters (today / upcoming / cancelled)POST /api/bookings— create a booking (used internally by the bot)PATCH /api/bookings/:id/cancel— cancel a booking
WhatsApp Webhook
GET /api/webhook— Meta webhook verification challengePOST /api/webhook— receive and route incoming WhatsApp messages
| Scenario | How it's handled |
|---|---|
| Two customers book the same slot simultaneously | Prisma transaction — first write wins, second gets "slot just taken, here are remaining slots" |
| Customer sends a random message mid-flow | Bot reads their current step from DB and re-prompts it |
| Staff member deleted with active bookings | Soft delete (isActive = false) — booking history is preserved |
| Date is a holiday | Slot generator returns empty list — bot says "no slots available that day" |
| Customer says "cancel" at any point | State resets to IDLE, customer receives a cancellation message |
| Owner cancels a booking from dashboard | Booking status → cancelled, customer receives WhatsApp notification |
Every business on Bookie shares the same backend and database. Data isolation is enforced by filtering every query by businessId derived from the authenticated user's JWT.
The WhatsApp bot identifies which business a customer belongs to via the bookingSlug embedded in the QR code URL. The webhook reads this slug, resolves the businessId, and scopes the entire conversation to that business.
Each business gets a unique booking URL:
https://book.bookie.app/glow-salon
When a customer scans the QR code, WhatsApp opens with a pre-filled message sent to the business's WhatsApp number. The backend reads the slug from the message payload and starts the booking flow for that business.
Overview
- Cards: today's bookings, upcoming bookings, active staff, total services
- Table: today's schedule
Bookings page
- Filter tabs: Today / Upcoming / Cancelled
- Each row: customer name, phone, service, staff, date, time, status
- One-click cancel — sends WhatsApp notification to customer automatically
Services page
- Add, edit, toggle active/inactive
Staff page
- Add staff, assign services, set working hours per day
QR Code page
- Display and download the business's WhatsApp booking QR code
Real-time updates
- Dashboard polls bookings every 10 seconds via TanStack Query
refetchInterval
| Service | Platform |
|---|---|
| Frontend (React) | Vercel |
| Backend (Node/Express) | Railway or Render |
| Database (PostgreSQL) | Neon |
| Meta WhatsApp Cloud API |
During development, the webhook URL is exposed via ngrok. In production, the Railway URL is registered directly with Meta.
These are intentionally deferred:
- Fully open-ended AI conversations (the bot handles natural language but within a structured flow)
- Online payments and subscription billing
- Customer login / account portal
- Appointment rescheduling
- Automatic reminder messages
- Google Calendar integration
- Multiple branches per business
- Multiple WhatsApp numbers per business
- Reviews, ratings, loyalty programs
- Inventory management and POS
- Marketing broadcasts
- Advanced analytics
- Multi-language support
- SMS fallback for non-WhatsApp users
A business owner can register, configure services, staff, working schedules, and holidays, then generate a unique QR code for customers. A customer scans the QR code, books an appointment by typing naturally in WhatsApp — no numbered menus, no rigid commands — and the appointment immediately appears in the business's dashboard. The platform supports multiple independent businesses using a single WhatsApp integration with complete data isolation between tenants.