DEV Community

Cover image for Designing a Scalable On-Demand Booking Platform with FastAPI, Flutter, and Location-Based Matching
Zestminds Technologies
Zestminds Technologies

Posted on • Originally published at zestminds.com

Designing a Scalable On-Demand Booking Platform with FastAPI, Flutter, and Location-Based Matching

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:

  1. Select a service
  2. Find a provider
  3. Choose a time slot
  4. Pay
  5. 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
Enter fullscreen mode Exit fullscreen mode

A simplified relationship model looked like this:

Customer
   |
  creates
   | 
   v
Booking
   |
  assigned to
   | 
   v
Provider
   |
  offers
   | 
   v
ProviderService
   |
  belongs to
   | 
   v
ServiceCategory
Enter fullscreen mode Exit fullscreen mode

Availability and location rules were separate from the provider profile because both could change independently.

Provider
 ├── ServiceArea
 ├── AvailabilitySlot
 ├── ProviderService
 └── ProviderStatus
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Eligibility filtering
  2. 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It moved through controlled transitions.

REQUESTED
   |
   +--> ACCEPTED
   |       |
   |       +--> CONFIRMED
   |               |
   |               +--> IN_PROGRESS
   |                       |
   |                       +--> COMPLETED
   |
   +--> DECLINED
   |
   +--> CANCELLED
Enter fullscreen mode Exit fullscreen mode

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}"
        )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conceptually:

SELECT id
FROM availability_slots
WHERE provider_id = :provider_id
  AND start_time <= :requested_start
  AND end_time >= :requested_end
FOR UPDATE;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    },
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)