A practical engineering breakdown of provider matching, booking-state management, payments, mobile apps, and backend architecture for a two-sided services marketplace.
An on-demand booking platform often looks simple from the user’s perspective:
- Select a service
- Find a provider
- Choose a time slot
- Pay
- Track the booking
But implementing that flow reliably requires coordination between customer accounts, service providers, locations, availability, booking states, payments, notifications, and administrative operations.
At Zestminds, we worked on modernizing an on-demand services marketplace that connected customers with local professionals across categories such as plumbing, electrical work, cleaning, appliance repair, maintenance, and personal-care services.
The client identity has been anonymized because this was a historical engagement. The technical challenges and architecture described here reflect the actual project work.
The Real Complexity Behind a Booking Platform
The platform had two primary user groups:
- Customers searching for services and available providers
- Service professionals managing requests, schedules, and completed jobs
It also required an administrative layer for:
- Provider verification
- Booking monitoring
- Payment review
- Category management
- Dispute handling
- Operational reporting
The main engineering challenge was keeping all of these workflows synchronized.
A provider could be visible in search but unavailable for a requested time. A booking could be accepted while a payment remained pending. A payment could succeed while the frontend failed to receive the redirect response.
These edge cases meant that the platform needed explicit domain rules rather than relying on frontend state alone.
Core Domain Model
The system was organized around several primary entities:
Customer
Provider
ServiceCategory
Service
ProviderService
ServiceArea
AvailabilitySlot
Booking
BookingStatusHistory
Payment
Notification
Review
A simplified relationship model looked like this:
Customer
|
creates
|
v
Booking
|
assigned to
|
v
Provider
|
offers
|
v
ProviderService
|
belongs to
|
v
ServiceCategory
Availability and location rules were separate from the provider profile because both could change independently.
Provider
├── ServiceArea
├── AvailabilitySlot
├── ProviderService
└── ProviderStatus
This separation made it easier to support:
- Providers serving multiple areas
- Different prices by service
- Temporary unavailability
- Category-specific eligibility
- Future multi-location expansion
Why We Used a Backend API Layer
The platform needed to support web and mobile applications without duplicating marketplace logic.
FastAPI was used to expose structured APIs for:
- Authentication
- Provider discovery
- Availability lookup
- Booking creation
- Booking-status transitions
- Payment initiation
- Payment webhook processing
- Notifications
- Administrative reporting
A simplified booking endpoint might look like this:
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, status
router = APIRouter(prefix="/bookings", tags=["bookings"])
class BookingStatus(str, Enum):
REQUESTED = "requested"
ACCEPTED = "accepted"
DECLINED = "declined"
CONFIRMED = "confirmed"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CreateBookingRequest(BaseModel):
provider_id: int
service_id: int
scheduled_at: datetime
address_id: int
notes: str | None = Field(default=None, max_length=1000)
class BookingResponse(BaseModel):
id: int
status: BookingStatus
scheduled_at: datetime
provider_id: int
service_id: int
@router.post(
"",
response_model=BookingResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_booking(
payload: CreateBookingRequest,
current_user=Depends(get_current_user),
booking_service=Depends(get_booking_service),
):
try:
return await booking_service.create_booking(
customer_id=current_user.id,
payload=payload,
)
except ProviderUnavailableError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
The API itself was only one layer.
The actual booking rules belonged in a service layer that validated:
- Provider eligibility
- Requested service support
- Service-area coverage
- Slot availability
- Existing booking conflicts
- Provider operational status
This prevented frontend applications from implementing business rules differently.
Provider Discovery and Location Matching
The provider-discovery flow was intentionally separated into two stages:
- Eligibility filtering
- Relevance ranking
Eligibility filtering
A provider was removed from consideration if they did not meet mandatory conditions such as:
Requested service supported
AND customer location inside service area
AND provider account active
AND provider available
AND provider approved
A simplified SQL-style query could look like:
SELECT DISTINCT p.id
FROM providers p
JOIN provider_services ps
ON ps.provider_id = p.id
JOIN service_areas sa
ON sa.provider_id = p.id
JOIN availability_slots av
ON av.provider_id = p.id
WHERE ps.service_id = :service_id
AND p.status = 'active'
AND p.is_verified = TRUE
AND av.start_time <= :requested_start
AND av.end_time >= :requested_end;
Geographic filtering could then be applied through:
- Radius calculations
- Postal-code matching
- Polygon-based service areas
- Google Maps distance services
- Database-level geospatial extensions
Relevance ranking
After filtering, eligible providers were ranked using signals such as:
Distance
Availability match
Rating
Completed job count
Cancellation rate
Response time
Profile completeness
Verification status
A basic weighted score could be represented as:
def provider_score(provider) -> float:
return (
provider.rating_normalized * 0.30
+ provider.distance_score * 0.25
+ provider.availability_score * 0.20
+ provider.reliability_score * 0.15
+ provider.profile_quality_score * 0.10
)
This kind of transparent ranking logic is often more useful at an early stage than introducing machine learning too soon.
Machine learning becomes more valuable when the platform has enough behavioural data to learn from:
- Search history
- Booking completion
- Provider acceptance rates
- Repeat usage
- Customer preferences
- Seasonal demand
- Cancellation patterns
Calling every weighted ranking system “AI” can create unnecessary complexity and make debugging harder.
Booking States Should Be Explicit
One of the most important architecture decisions was treating bookings as stateful workflows.
A booking was not simply:
confirmed = true
It moved through controlled transitions.
REQUESTED
|
+--> ACCEPTED
| |
| +--> CONFIRMED
| |
| +--> IN_PROGRESS
| |
| +--> COMPLETED
|
+--> DECLINED
|
+--> CANCELLED
A transition matrix helped prevent invalid changes:
| Current status | Allowed next statuses |
|---|---|
| Requested | Accepted, Declined, Cancelled |
| Accepted | Confirmed, Cancelled |
| Confirmed | In Progress, Cancelled, Rescheduled |
| In Progress | Completed |
| Completed | Refunded or Reviewed, depending on workflow |
A service-layer check could enforce this:
ALLOWED_TRANSITIONS = {
"requested": {"accepted", "declined", "cancelled"},
"accepted": {"confirmed", "cancelled"},
"confirmed": {"in_progress", "cancelled", "rescheduled"},
"in_progress": {"completed"},
"completed": set(),
"declined": set(),
"cancelled": set(),
}
def validate_transition(current_status: str, next_status: str) -> None:
allowed = ALLOWED_TRANSITIONS.get(current_status, set())
if next_status not in allowed:
raise InvalidBookingTransition(
f"Cannot move booking from {current_status} to {next_status}"
)
We also maintained a status-history record rather than only overwriting the current value.
BookingStatusHistory
- booking_id
- previous_status
- new_status
- changed_by
- changed_at
- reason
This helped with:
- Operational debugging
- Customer support
- Disputes
- Audit history
- Notification retries
Avoiding Double Booking
Availability needed more than a calendar interface.
The backend had to prevent two customers from booking the same provider for overlapping periods.
A transaction-based flow could be:
Begin transaction
Lock provider availability
Check overlapping bookings
Create booking
Reserve slot
Commit transaction
Conceptually:
SELECT id
FROM availability_slots
WHERE provider_id = :provider_id
AND start_time <= :requested_start
AND end_time >= :requested_end
FOR UPDATE;
Then check for overlapping active bookings:
SELECT COUNT(*)
FROM bookings
WHERE provider_id = :provider_id
AND status IN ('accepted', 'confirmed', 'in_progress')
AND scheduled_start < :requested_end
AND scheduled_end > :requested_start;
If the count was greater than zero, the booking request had to fail with a conflict response.
This validation belonged on the server, even if the mobile application had already shown the slot as available.
Payment Processing and Webhooks
A common payment mistake is treating the browser redirect as proof of success.
The customer may close the browser, lose connectivity, or fail to return to the application after payment.
The reliable source of truth should be the payment-provider webhook.
A simplified webhook flow:
Payment provider
|
v
Webhook endpoint
|
v
Verify signature
|
v
Check event idempotency
|
v
Update payment record
|
v
Update booking state
|
v
Trigger notification
Example:
@router.post("/payments/webhook")
async def payment_webhook(
request: Request,
payment_service=Depends(get_payment_service),
):
payload = await request.body()
signature = request.headers.get("x-payment-signature")
if not payment_service.verify_signature(payload, signature):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid webhook signature",
)
event = payment_service.parse_event(payload)
if await payment_service.event_already_processed(event.id):
return {"status": "ignored"}
await payment_service.process_event(event)
return {"status": "processed"}
Idempotency was essential because payment providers can retry webhook delivery.
The payment model needed to distinguish:
Created
Pending
Authorized
Captured
Failed
Cancelled
Refunded
Partially Refunded
These states should not be mixed directly with booking states.
A booking could remain confirmed while a partial refund was being processed, depending on the business rules.
Customer and Provider Mobile Applications
Flutter was used for a shared cross-platform implementation across iOS and Android.
The customer application supported:
- Service search
- Provider discovery
- Provider profile review
- Availability selection
- Booking creation
- Payment initiation
- Booking tracking
- Rescheduling and support
The provider application supported:
- Incoming requests
- Accept and decline actions
- Schedule management
- Availability updates
- Service-area management
- Job-status updates
- Payment and earnings visibility
The mobile applications consumed the same backend rules as the web platform.
This avoided scenarios where:
Web app allows one transition
Mobile app allows another
Admin panel applies a third rule
Administrative Operations
A marketplace also requires operational tooling.
The administrative dashboard provided visibility into:
- Pending provider approvals
- Active and suspended providers
- Booking statuses
- Failed or pending payments
- Refunds
- Service categories
- Reviews and disputes
- Marketplace activity
Admin actions also needed explicit permissions.
Super Admin
Operations Manager
Provider Reviewer
Support Agent
Finance User
Content Manager
Role-based access control helped restrict sensitive actions such as:
- Refund processing
- Provider suspension
- Payment export
- Account deletion
- Service pricing changes
Cache and Background Processing
Redis was useful for workloads such as:
- Frequently requested category data
- Provider-search result caching
- Rate limiting
- Temporary booking holds
- Background-job coordination
- Notification queues
However, transactional booking and payment data still belonged in the relational database.
A cache entry should never become the only record that a time slot was reserved.
Background jobs were used for:
- Email and SMS notifications
- Push notifications
- Payment reconciliation
- Provider reminders
- Expired request cleanup
- Search-index updates
- Reporting aggregation
This kept slower integration work outside the main API response cycle.
Observability and Failure Handling
Marketplace systems fail in ways that are difficult to reproduce without good observability.
Useful logging context included:
request_id
customer_id
provider_id
booking_id
payment_id
current_status
requested_transition
webhook_event_id
device_type
application_version
For example:
logger.info(
"booking_status_changed",
extra={
"booking_id": booking.id,
"provider_id": booking.provider_id,
"previous_status": old_status,
"new_status": new_status,
"changed_by": actor.id,
},
)
Metrics worth tracking included:
- Provider-search response time
- Booking creation failure rate
- Slot-conflict rate
- Provider acceptance time
- Payment webhook failures
- Notification retry count
- Booking cancellation rate
- API error rate by application version
What We Learned
Several lessons from this project apply to most marketplace products.
1. Model operational states before building screens
The customer interface becomes easier to build when booking, payment, provider, and availability states are clearly defined.
2. Separate filtering from ranking
Mandatory eligibility conditions and relevance scoring solve different problems and should not be mixed together.
3. Do not trust the frontend for booking availability
Availability must be validated inside a database transaction at the time of booking.
4. Treat payment webhooks as the source of truth
Redirect pages are useful for user experience, but webhook events should control payment-state updates.
5. Provider experience affects customer experience
Poor provider tools lead to slower responses, missed appointments, and inconsistent booking updates.
6. Introduce machine learning only when data supports it
Transparent scoring rules are often easier to operate and improve during the early stages of a marketplace.
Final Thoughts
The difficult part of an on-demand marketplace is not creating a provider card or a booking button.
The real work is designing a system that can reliably coordinate:
Customers
Providers
Services
Locations
Availability
Bookings
Payments
Notifications
Operations
When those domain relationships and state transitions are modelled properly, the customer experience becomes much simpler.
The complete project case study, including representative platform visuals and the broader product context, is available here:
Read the complete on-demand booking platform case study
Zestminds works with startups and established businesses on marketplace platforms, FastAPI backends, Flutter applications, SaaS products, and legacy-system modernization.
Top comments (0)