FastAPI and PostgreSQL booking system with a same-origin owner dashboard. A business owner can create an account, edit business details, add personnel and services, define weekly working hours and breaks for each person, and manage the daily appointment list.
The dashboard is deliberately plain HTML, CSS, and JavaScript. It is served by the API itself, so there is no Node build or separate frontend deployment to configure.
The simplest complete setup is Docker Compose:
docker compose up --buildCompose waits for PostgreSQL, applies every missing Alembic migration, and starts the API. Open:
- Owner dashboard: http://localhost:8000/dashboard/
- Interactive API docs: http://localhost:8000/docs
- Health endpoint: http://localhost:8000/health
For local Python development instead:
venv/bin/python -m pip install -r requirements.txt
docker compose up -d postgres
venv/bin/alembic upgrade head
venv/bin/uvicorn app.main:app --reloadThe API intentionally does not create or alter tables when an application
worker starts. alembic upgrade head is the explicit schema deployment step.
- Visit
/dashboard/and create an account. Registration atomically creates the user, their first business, anownermembership, and a login session. - Add active team members under Team.
- Add duration and optional price information under Services.
- Choose each team member under Working hours and save their weekly hours and optional break.
- Use Appointments to review a date and move confirmed bookings to completed, no-show, or cancelled.
Passwords are salted with scrypt. The browser receives a random opaque session
in an HttpOnly, SameSite=Lax cookie; only its SHA-256 digest is stored in
PostgreSQL. Sessions are revocable and expire after SESSION_DAYS. Every owner
API query checks the authenticated user's business membership. Roles currently
supported by the schema are owner, manager, and staff; owner and manager
roles may change configuration.
The old unauthenticated business/staff/service configuration routers are not mounted. Customer-facing availability and appointment creation remain public; appointment listing and cancellation now require a business membership.
The included chatbot uses Meta's official WhatsApp Cloud API. A customer can
send BOOK, choose a numbered service, enter a date, choose a live slot, and
receive an appointment confirmation. The business is inferred from Meta's
phone_number_id; it is never accepted from the customer's message.
In the owner dashboard:
- Create at least one active team member.
- Create at least one active service with a duration.
- Set working hours for that team member. Days without working hours produce no slots.
- Under Business → WhatsApp chatbot, enter Meta's Phone number ID, not the visible telephone number. The display number is optional.
The MVP assumes every active team member can perform every active service. Service-to-staff capability mapping would be a separate domain feature.
In Meta for Developers:
- Create or select a business app and add the WhatsApp product.
- Connect the WhatsApp Business Account and register a sending phone number.
- Record the WhatsApp Business Account ID (
WABA_ID) and the numeric Phone number ID shown under API Setup. - Copy the Meta App Secret from the app settings.
- Create a production system-user access token. Sending requires
whatsapp_business_messaging; subscribing the app to the WABA requireswhatsapp_business_management. Temporary dashboard tokens are suitable only for short tests. - Choose your own long random webhook verification token. It is not supplied by Meta and must be different from the access token and App Secret.
Generate a verification token, for example:
openssl rand -hex 32Official references:
- https://developers.facebook.com/docs/whatsapp/cloud-api
- https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-whatsapp
- https://www.postman.com/meta/whatsapp-business-platform/request/8gvd47s/send-text-message
Create the ignored local configuration file:
cp .env.example .env.localSet these values in .env.local:
WHATSAPP_VERIFY_TOKEN=the-random-value-you-created
WHATSAPP_APP_SECRET=the-meta-app-secret
WHATSAPP_ACCESS_TOKEN=the-system-user-access-token
WHATSAPP_GRAPH_API_VERSION=v25.0
WHATSAPP_BOOKING_HORIZON_DAYS=90
WHATSAPP_CONVERSATION_TTL_MINUTES=60.env.local is ignored by Git and excluded from Docker builds. Never paste
the App Secret or access token into the owner dashboard, source code, logs, or
database. In production, inject them from the hosting platform's secret
manager instead of a file.
Restart the API after changing secrets:
venv/bin/alembic upgrade head
venv/bin/uvicorn app.main:app --reloadFor Docker:
docker compose up --buildCompose reads .env.local into the backend container and runs the migration
before starting the API.
Meta must reach the API over public HTTPS with a valid certificate. The callback path is:
https://your-api.example.com/webhooks/whatsapp
For local development, expose http://localhost:8000 through a trusted HTTPS
tunnel and use the tunnel's HTTPS hostname. A plain localhost URL or
self-signed certificate cannot receive Meta webhooks.
In the Meta app's WhatsApp webhook configuration:
- Set the callback URL above.
- Enter exactly the same
WHATSAPP_VERIFY_TOKENvalue. - Subscribe the
whatsapp_business_accountobject to themessagesfield.
The verification GET request is accepted only when hub.mode=subscribe and
the token matches; the API returns Meta's raw hub.challenge. Every POST is
then verified using X-Hub-Signature-256, an HMAC-SHA256 over the exact raw
body with the Meta App Secret. Invalid or unsigned payloads are rejected
before database access.
You can test the verification endpoint yourself:
curl "http://localhost:8000/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=YOUR_VERIFY_TOKEN&hub.challenge=123456"The response should be exactly:
123456
The webhook field configuration and WABA subscription are separate. Subscribe the app once with a system-user token:
curl -X POST \
"https://graph.facebook.com/v25.0/YOUR_WABA_ID/subscribed_apps" \
-H "Authorization: Bearer YOUR_SYSTEM_USER_ACCESS_TOKEN"A successful response is:
{"success": true}Send BOOK from a WhatsApp account to the connected business number. The
conversation is deterministic and does not require an AI model:
Customer: BOOK
Bot: 1. Haircut (30 min) — €25.00
Customer: 1
Bot: Which date? Reply with YYYY-MM-DD, TODAY, or TOMORROW.
Customer: 2026-08-12
Bot: 1. 09:00 — Giulia
2. 09:30 — Giulia
Customer: 1
Bot: Your appointment is confirmed! Reference: 42 ...
TODAY/TOMORROW and OGGI/DOMANI are accepted. CANCEL or ANNULLA
resets an unfinished conversation; it does not cancel an already-created
appointment. BOOK, MENU, START, HELLO, HI, CIAO, or PRENOTA
starts a new booking flow.
Immediately before booking, the bot recomputes availability and exact-matches
the selected staff/time. PostgreSQL's exclusion constraint is still the final
race-safe guard. Meta retries are deduplicated by inbound wamid, and the
booking uses the same message ID as its idempotency key, so a retry cannot
create a second appointment.
Inspect configured channels:
docker compose exec postgres psql -U booking_user -d booking_db \
-c "SELECT c.id, c.business_id, b.name, c.phone_number_id, c.active FROM whatsapp_channels c JOIN businesses b ON b.id = c.business_id ORDER BY c.id;"Inspect conversation state:
docker compose exec postgres psql -U booking_user -d booking_db \
-c "SELECT channel_id, whatsapp_id, state, updated_at FROM whatsapp_conversations ORDER BY updated_at DESC;"Inspect appointments created by the chatbot:
docker compose exec postgres psql -U booking_user -d booking_db \
-c "SELECT id, business_id, staff_member_id, service_id, start_time, status FROM appointments WHERE source = 'whatsapp' ORDER BY created_at DESC;"- Meta cannot verify the callback: confirm public HTTPS, the exact callback
path,
hub.mode=subscribe, and matching verify-token values. - Webhook returns 403: the POST signature does not match the raw body;
verify
WHATSAPP_APP_SECRETand ensure a proxy is not rewriting the body. - Webhook returns 502: check the access token, its permissions, the pinned
Graph API version, and
docker compose logs -f backend. - Messages are ignored: the inbound
metadata.phone_number_iddoes not match an active channel saved under the business dashboard. - No slots appear: verify active staff/service records, the business IANA timezone, working hours for the selected date, breaks, and existing active appointments.
- Replies work only after the customer writes: free-form WhatsApp replies are restricted to the rolling 24-hour customer-service window. Future reminders require an approved WhatsApp template and customer consent.
- Changed an environment value: restart the API/container; settings are read at process startup.
This implementation sends replies synchronously after persisting the reply. For high traffic, move outbound delivery to a durable worker/outbox so webhook acknowledgements remain fast during Graph API outages.
Using the included same-origin dashboard is recommended. If a separate browser application needs the same API, configure its exact origins as a comma-separated list:
FRONTEND_ORIGINS=https://owner.example.com,http://localhost:5173
SECURE_COOKIES=true
SESSION_DAYS=7Send JSON requests with credentials enabled:
const login = await fetch("https://api.example.com/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "owner@example.com",
password: "a-long-owner-password"
})
});
const businesses = await fetch("https://api.example.com/owner/businesses", {
credentials: "include"
}).then(response => response.json());For production, use HTTPS, set SECURE_COOKIES=true, list only trusted CORS
origins, and replace the demonstration database password in Compose. Keeping
the API and owner frontend on the same site avoids third-party-cookie policy
differences between browsers.
POST /auth/register
POST /auth/login
POST /auth/logout
GET /auth/me
GET /owner/businesses
POST /owner/businesses
GET /owner/businesses/{business_id}
PATCH /owner/businesses/{business_id}
GET /owner/businesses/{business_id}/whatsapp
PUT /owner/businesses/{business_id}/whatsapp
GET /owner/businesses/{business_id}/staff
POST /owner/businesses/{business_id}/staff
PATCH /owner/businesses/{business_id}/staff/{staff_id}
GET /owner/businesses/{business_id}/services
POST /owner/businesses/{business_id}/services
PATCH /owner/businesses/{business_id}/services/{service_id}
GET /owner/businesses/{business_id}/staff/{staff_id}/working-hours
PUT /owner/businesses/{business_id}/staff/{staff_id}/working-hours
GET /owner/businesses/{business_id}/appointments?target_date=YYYY-MM-DD
PATCH /owner/businesses/{business_id}/appointments/{appointment_id}/status
GET /webhooks/whatsapp
POST /webhooks/whatsapp
The OpenAPI page at /docs contains the exact request and response schemas.
Customer websites can request availability and create a booking through the existing endpoints:
GET /availability?business_id=1&service_id=1&target_date=2035-01-15
POST /appointments
Every POST /appointments request must include a stable, unique
Idempotency-Key header. Retrying the same payload with the same key returns
the original booking. PostgreSQL also rejects overlapping active appointments,
so two customers racing for one slot cannot both win.
An ORM model describes the schema the current application expects. An Alembic migration is an ordered, reviewable operation that moves an existing database from one known version to the next while preserving its data.
Alembic stores the installed revision in the database's alembic_version
table. upgrade head reads that value and runs each missing revision once, in
order. This project currently has:
base
└── 0001_initial_schema
└── 0002_atomic_bookings
└── 0003_owner_portal
└── 0004_whatsapp_chatbot (head)
0001_initial_schemacreates businesses, staff, services, weekly schedules, customers, and appointments.0002_atomic_bookingsadds idempotency records, positive-duration checks, and PostgreSQL GiST overlap protection.0003_owner_portalbackfills a stable slug for every existing business and adds users, memberships, and revocable authentication sessions.0004_whatsapp_chatbotadds business phone-number channels, persistent conversation state, inbound-message deduplication, and reply-delivery state.
Useful commands:
# Show the database's installed revision
venv/bin/alembic current
# Show the complete revision chain
venv/bin/alembic history --verbose
# Apply every missing migration
venv/bin/alembic upgrade head
# Compare the migrated schema with ORM metadata
venv/bin/alembic check
# Roll back one revision in a disposable development/test database
venv/bin/alembic downgrade -1Downgrades are not automatically safe in production. Downgrading
0004_whatsapp_chatbot removes channel configuration and conversation history;
it does not delete appointments already booked through WhatsApp. Downgrading
0003_owner_portal removes all account, membership, and session records;
downgrading 0002_atomic_bookings removes the database-level double-booking
guard. Prefer a new forward migration when data or availability could be lost.
After changing app/db/models.py:
venv/bin/alembic revision --autogenerate -m "describe the schema change"Review the generated revision under alembic/versions/. Autogeneration is a
starting point: data backfills, PostgreSQL-specific constraints, locking, and
safe rollout checks often require handwritten operations. Test both upgrade
and downgrade. Never edit a migration that has already run on a shared
database; add a new forward revision instead.
alembic stamp records a version without running its DDL. Use it only after
manually proving an unversioned legacy schema already matches that exact
revision. It is not a repair for a partially applied migration.
Install the project dependencies and run against PostgreSQL:
venv/bin/python -m pip install -r requirements.txt
TEST_DATABASE_URL=postgresql://user:password@localhost/database \
venv/bin/python -m unittest discover -s tests -vTests create randomly named schemas and remove them afterward. Coverage includes migration upgrade/downgrade/re-upgrade and drift detection, booking rollback and concurrency, idempotent retries, registration and cookies, cross-business authorization, owner setup and weekly hours, logout, and static dashboard delivery. WhatsApp coverage includes verification challenges, raw body signatures, status callbacks, channel configuration, a complete booking dialogue, duplicate webhook delivery, and creation of exactly one appointment.